Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
1e9e43e
fix(node-lib): keep secrets out of the command line log
nullPointerEnjoyer Oct 17, 2026
e7ab8f4
fix(api-server): keep secrets out of the command line log
nullPointerEnjoyer Oct 17, 2026
386f224
fix(node-lib): reuse the existing network name helper in the options log
nullPointerEnjoyer Oct 17, 2026
97023af
fix(api-server): redact credentials in debug output and log summary
nullPointerEnjoyer Oct 17, 2026
401b572
test(api-server): assert option output keeps every expected field
nullPointerEnjoyer Oct 17, 2026
be30564
test(api-server): verify option output keeps non-secret fields visible
nullPointerEnjoyer Oct 17, 2026
dc1f971
fix(node-lib): restrict log file permissions to the file owner
nullPointerEnjoyer Oct 17, 2026
1f857f9
fix(node-lib): restrict permissions of existing log files on startup
nullPointerEnjoyer Oct 17, 2026
8020c9c
fix(node-lib): only adjust permissions of regular log files
nullPointerEnjoyer Oct 17, 2026
ee0a614
fix(options): redact rpc credentials and tighten debug output
nullPointerEnjoyer Oct 17, 2026
2d74064
fix(node-lib): keep rpc credentials out of the startup config log
nullPointerEnjoyer Oct 17, 2026
0d99e75
fix(api-server): cover node rpc address in redacted summary tests
nullPointerEnjoyer Oct 17, 2026
a08d40f
fix(api-server): tie rpc address assertions to the port field
nullPointerEnjoyer Oct 17, 2026
5d46513
fix(node-lib): fix log file modes even when files are not writable
nullPointerEnjoyer Oct 17, 2026
6ce8408
refactor(api-server): share the redaction marker between debug outputs
nullPointerEnjoyer Oct 17, 2026
ab86de0
fix(node-lib): report and scope log permission fixes
nullPointerEnjoyer Oct 17, 2026
90b23f1
fix(api-server): keep clap errors from echoing secret arguments
nullPointerEnjoyer Oct 17, 2026
1e6d81d
fix(node-lib): verify log file identity before changing permissions
nullPointerEnjoyer Oct 17, 2026
6e10fd2
fix(api-server): redact the postgres password in the connection log
nullPointerEnjoyer Oct 17, 2026
677b78e
fix(api-server): keep clap diagnostics and tests free of secret echoes
nullPointerEnjoyer Oct 17, 2026
8bf85ff
fix(cli): hide secret env values and make defaults literal
nullPointerEnjoyer Oct 17, 2026
6551058
fix(cli): hide secret env values on the commands that use them
nullPointerEnjoyer Oct 17, 2026
77e589d
fix(cli): hide every secret-bearing option from the help output
nullPointerEnjoyer Oct 17, 2026
0fd290e
test(cli): pin the env value hiding of wallet secret options
nullPointerEnjoyer Oct 17, 2026
d2c8057
test(cli): match the secret option lists against real arguments
nullPointerEnjoyer Oct 17, 2026
938bcdd
refactor(utils): share the secret option pin between wallet crates
nullPointerEnjoyer Oct 17, 2026
15126c6
fix(api-server): pin the help output against secret env values
nullPointerEnjoyer Oct 17, 2026
a86b6bd
test(cli): search nested subcommands when pinning hidden env values
nullPointerEnjoyer Oct 17, 2026
854d435
fix(cli): build the postgres config without a password string
nullPointerEnjoyer Oct 17, 2026
1313fa7
refactor(api-server): pass the database name by value
nullPointerEnjoyer Oct 17, 2026
88e3d30
docs(utils): correct how the guard tests are selected
nullPointerEnjoyer Oct 17, 2026
15b7c9a
fix(node-lib): restrict old log files even without file logging
nullPointerEnjoyer Oct 17, 2026
0606708
fix(node-lib): report log listing failures with their cause
nullPointerEnjoyer Oct 17, 2026
fd41e31
refactor(node-lib): compute the log name match once per entry
nullPointerEnjoyer Oct 17, 2026
2bb6526
fix(node-lib): declare the log name helper before its first use
nullPointerEnjoyer Oct 17, 2026
4f38b9c
fix(cli): hide staking settings secrets and zeroize rpc credentials
nullPointerEnjoyer Oct 17, 2026
02b583b
docs(api-server): scope the secret option list notes
nullPointerEnjoyer Oct 17, 2026
b0bb3d2
test(cli): classify every help option as secret or non-secret
nullPointerEnjoyer Oct 18, 2026
13459fb
test(api-server): flag stale non-secret option entries
nullPointerEnjoyer Oct 18, 2026
a0ba911
refactor(utils): share the option classification check
nullPointerEnjoyer Oct 18, 2026
21eb9e0
fix(utils): classify subcommand options too
nullPointerEnjoyer Oct 18, 2026
6c42c77
fix(common): keep staking keys out of debug output
nullPointerEnjoyer Oct 18, 2026
69c2acf
fix(node-lib): anchor the log scan identity in the directory entry
nullPointerEnjoyer Oct 18, 2026
62af497
fix(utils): classify subcommand options too
nullPointerEnjoyer Oct 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

218 changes: 212 additions & 6 deletions api-server/api-server-common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,145 @@ pub mod streaming;
use clap::Parser;
use common::chain::config::ChainType;

/// Marker printed in place of any secret-bearing value.
pub const REDACTED: &str = "<redacted>";

/// Debug-safe replacement for an `Option` that may hold a secret.
pub fn redact<T>(value: &Option<T>) -> Option<&'static str> {
value.as_ref().map(|_| REDACTED)
}

/// A loggable summary of the options, omitting every value that can hold a secret.
/// Implementors must consciously provide the summary; for types whose hand-written Debug
/// impl redacts the secret-bearing fields (with an exhaustive destructure guarding new
/// fields), the body is the Debug output itself.
pub trait RedactedSummary: std::fmt::Debug {
fn redacted_summary(&self) -> String;
}

/// Parses command line arguments, and on failure exits without echoing them: clap errors
/// for value-bearing arguments can repeat raw `--flag=value` tokens, which may carry
/// secrets. Only errors whose clap rendering is guaranteed to name flags or static text
/// (ArgumentConflict, MissingRequiredArgument, MissingSubcommand, NoEquals, plus help
/// and version) are printed as-is; every other error, including UnknownArgument, is
/// replaced by a generic message.
///
/// # Contract with the argument definitions
///
/// Help and version output is echoed verbatim, and the error fallback tells users to
/// run `--help`. Any secret-bearing argument bound to an environment variable must
/// therefore set `hide_env_values(true)` (see
/// `clap_utils::env_adder_hiding_env_values`), otherwise clap renders
/// `[env: NAME=value]` into the help text and this function prints the secret.
pub fn parse_args_or_exit<T: clap::Parser>() -> T {
match T::try_parse() {
Ok(args) => args,
Err(err) => {
if err.use_stderr() {
if error_is_value_free(err.kind()) {
let _ = err.print();
} else {
// The error kind's rendering is a static category name and can never
// contain argument values, so it is safe to include here.
eprintln!(
"Invalid command line arguments ({:?}); run with --help for usage",
err.kind()
);
}
std::process::exit(2);
}
let _ = err.print();
std::process::exit(0);
}
}
}

/// Whether the clap rendering of this error kind is guaranteed to name flags or static
/// text only, never raw argument values.
fn error_is_value_free(kind: clap::error::ErrorKind) -> bool {
use clap::error::ErrorKind;
// `DisplayHelpOnMissingArgumentOrSubcommand` is deliberately excluded even though its
// rendering is stderr-routed and value-free under the `hide_env_values` contract: a
// binary configured with `arg_required_else_help` should print the generic message
// rather than its full help when invoked with no arguments.
matches!(
kind,
ErrorKind::ArgumentConflict
| ErrorKind::MissingRequiredArgument
| ErrorKind::MissingSubcommand
| ErrorKind::NoEquals
)
}

#[cfg(test)]
mod parse_args_tests {
use clap::error::ErrorKind;

#[test]
fn value_bearing_error_kinds_are_suppressed() {
assert!(!super::error_is_value_free(ErrorKind::InvalidValue));
assert!(!super::error_is_value_free(ErrorKind::ValueValidation));
assert!(!super::error_is_value_free(ErrorKind::UnknownArgument));
}

#[test]
fn flag_naming_error_kinds_are_printed() {
assert!(super::error_is_value_free(
ErrorKind::MissingRequiredArgument
));
assert!(super::error_is_value_free(ErrorKind::ArgumentConflict));
assert!(super::error_is_value_free(ErrorKind::MissingSubcommand));
assert!(super::error_is_value_free(ErrorKind::NoEquals));
}

/// Pins that the clap rendering of the allowlisted kinds never embeds raw argument
/// values, so a clap upgrade that starts echoing them fails here instead of leaking
/// secrets in production.
#[test]
fn allowlisted_kinds_never_render_raw_values() {
let secret = "s3cret-canary-value";
let cmd = clap::Command::new("canary")
.arg(clap::Arg::new("a").long("a").num_args(1))
.arg(
clap::Arg::new("b")
.long("b")
.conflicts_with("a")
.action(clap::ArgAction::SetTrue),
)
.arg(clap::Arg::new("c").long("c").required(true));

let conflict =
cmd.clone().try_get_matches_from(["canary", "--a", secret, "--b"]).unwrap_err();
assert_eq!(conflict.kind(), clap::error::ErrorKind::ArgumentConflict);
assert!(!conflict.render().to_string().contains(secret));

let missing_required =
cmd.clone().try_get_matches_from(["canary", "--a", secret]).unwrap_err();
assert_eq!(
missing_required.kind(),
clap::error::ErrorKind::MissingRequiredArgument
);
assert!(!missing_required.render().to_string().contains(secret));

let requires_equals = clap::Command::new("canary")
.arg(clap::Arg::new("a").long("a").require_equals(true).num_args(1));
let no_equals =
requires_equals.try_get_matches_from(["canary", "--a", secret]).unwrap_err();
assert_eq!(no_equals.kind(), clap::error::ErrorKind::NoEquals);
assert!(!no_equals.render().to_string().contains(secret));

let subcommanded = clap::Command::new("canary")
.subcommand_required(true)
.subcommand(clap::Command::new("run"));
let missing_subcommand = subcommanded.try_get_matches_from(["canary"]).unwrap_err();
assert_eq!(
missing_subcommand.kind(),
clap::error::ErrorKind::MissingSubcommand
);
assert!(!missing_subcommand.render().to_string().contains(secret));
}
}

#[derive(clap::ValueEnum, Clone, Debug)]
pub enum Network {
Mainnet,
Expand All @@ -38,18 +177,23 @@ impl From<Network> for ChainType {
}
}

#[derive(Parser, Debug)]
const DEFAULT_POSTGRES_HOST: &str = "localhost";
const DEFAULT_POSTGRES_PORT: u16 = 5432;
const DEFAULT_POSTGRES_USER: &str = "postgres";
const DEFAULT_POSTGRES_MAX_CONNECTIONS: u32 = 10;

#[derive(Parser, PartialEq)]
pub struct PostgresConfig {
/// Postgres host
#[clap(long, default_value = "localhost")]
#[clap(long, default_value = DEFAULT_POSTGRES_HOST)]
pub postgres_host: String,

/// Postgres port
#[clap(long, default_value = "5432")]
#[clap(long, default_value_t = DEFAULT_POSTGRES_PORT)]
pub postgres_port: u16,

/// Postgres user
#[clap(long, default_value = "postgres")]
#[clap(long, default_value = DEFAULT_POSTGRES_USER)]
pub postgres_user: String,

/// Postgres password
Expand All @@ -61,12 +205,74 @@ pub struct PostgresConfig {
pub postgres_database: Option<String>,

/// Postgres max connections
#[clap(long, default_value = "10")]
#[clap(long, default_value_t = DEFAULT_POSTGRES_MAX_CONNECTIONS)]
pub postgres_max_connections: u32,
}

// The password must never appear in debug output; anything that embeds this struct in a
// derived Debug (command line option dumps, error types) is safe by construction. The
// destructure has no `..` so a newly added field fails to compile until it is either added
// here or shown to be safe. The database role name in `postgres_user` is not a credential
// and is shown in clear text, unlike the RPC username, which is part of the basic-auth
// credential pair.
impl std::fmt::Debug for PostgresConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let Self {
postgres_host,
postgres_port,
postgres_user,
postgres_password,
postgres_database,
postgres_max_connections,
} = self;
f.debug_struct("PostgresConfig")
.field("postgres_host", postgres_host)
.field("postgres_port", postgres_port)
.field("postgres_user", postgres_user)
.field("postgres_password", &redact(postgres_password))
.field("postgres_database", postgres_database)
.field("postgres_max_connections", postgres_max_connections)
.finish()
}
}

impl Default for PostgresConfig {
fn default() -> Self {
Self::parse()
// Shared constants, not parsing: parsing here would read the process's real
// arguments and is not safe outside of the actual CLI startup.
Self {
postgres_host: DEFAULT_POSTGRES_HOST.to_string(),
postgres_port: DEFAULT_POSTGRES_PORT,
postgres_user: DEFAULT_POSTGRES_USER.to_string(),
postgres_password: None,
postgres_database: None,
postgres_max_connections: DEFAULT_POSTGRES_MAX_CONNECTIONS,
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn default_matches_clap_defaults() {
let from_clap = PostgresConfig::parse_from(["postgres-config"]);
assert_eq!(from_clap, PostgresConfig::default());
}

#[test]
fn debug_output_redacts_postgres_password() {
let config = PostgresConfig::parse_from([
"postgres-config",
"--postgres-host",
"dbhost",
"--postgres-password",
"dbsecret",
]);
let dump = format!("{config:?}");
assert!(dump.contains("postgres_host: \"dbhost\""));
assert!(dump.contains("postgres_password: Some(\"<redacted>\")"));
assert!(!dump.contains("dbsecret"));
}
}
38 changes: 18 additions & 20 deletions api-server/api-server-common/src/storage/impls/postgres/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ pub mod transactional;

mod queries;

use std::str::FromStr;
use std::sync::Arc;

use bb8_postgres::PostgresConnectionManager;
Expand Down Expand Up @@ -64,27 +63,26 @@ impl TransactionalApiServerPostgresStorage {
max_connections: u32,
chain_config: Arc<ChainConfig>,
) -> Result<Self, ApiServerStorageError> {
let password_part = match password {
Some(p) => format!("password={}", p),
None => "".to_string(),
};

let database_part = match database {
Some(d) => format!("dbname={}", d),
None => format!("dbname=mintlayer-{}", chain_config.chain_type().name()),
// The configuration is built from the discrete fields instead of a connection
// string, so the password never exists as a string that a parse error could echo.
let database = match database {
Some(d) => d.to_owned(),
None => format!("mintlayer-{}", chain_config.chain_type().name()),
};
logging::log::debug!(
"Using postgres config: host={host} port={port} user={user} password={} database={database}",
if password.is_some() {
crate::REDACTED
} else {
"<unset>"
}
);

let config_str =
format!("host={host} port={port} user={user} {password_part} {database_part}");
logging::log::debug!("Using postgres config connection string: {}", config_str);

let config: tokio_postgres::Config =
config_str.parse().map_err(|e: <tokio_postgres::Config as FromStr>::Err| {
ApiServerStorageError::InitializationError(format!(
"Postgres configuration parsing error: {}",
e
))
})?;
let mut config = tokio_postgres::Config::new();
config.host(host).port(port).user(user).dbname(database);
if let Some(password) = password {
config.password(password);
}

let manager = PostgresConnectionManager::new(config.clone(), NoTls);
let pool = Pool::builder().max_size(max_connections).build(manager).await.map_err(|e| {
Expand Down
1 change: 1 addition & 0 deletions api-server/scanner-daemon/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ tokio = { workspace = true, features = ["full"] }

[dev-dependencies]
test-utils = { path = "../../test-utils" }
utils = { path = "../../utils", features = ["test-util"] }

[build-dependencies]
build_utils = { path = "../../utils/build_utils" }
Loading
Loading