From ea8949287f45b14b82b34cb4914811ab59ada8ca Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 29 Jul 2026 12:00:22 -0400 Subject: [PATCH] fix(cli): mask credential env values in --help output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clap renders live env var values in help text by default. Three args carrying credentials — BUZZ_PRIVATE_KEY in buzz-cli and buzz-acp, and BUZZ_AUTH_TAG in buzz-cli — were exposed this way. Add `hide_env_values = true` to each. Env var names remain visible for discoverability; only their values are withheld. Also adds a regression guard in each crate that walks the clap command tree and asserts every arg whose env var name contains KEY, SECRET, TOKEN, PASSWORD, CRED, or AUTH sets hide_env_values. The guard covers args added in future, not just today's three. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-acp/src/config.rs | 32 +++++++++++++++++++++++- crates/buzz-cli/src/lib.rs | 46 +++++++++++++++++++++++++++++++++-- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 19304bf1866..dab61be30a0 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -240,7 +240,7 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_RELAY_URL", default_value = "ws://localhost:3000")] pub relay_url: String, - #[arg(long, env = "BUZZ_PRIVATE_KEY")] + #[arg(long, env = "BUZZ_PRIVATE_KEY", hide_env_values = true)] pub private_key: String, /// Agent owner pubkey (64-char hex). Used for --respond-to=owner-only gate. @@ -2898,4 +2898,34 @@ channels = "ALL" let agent = "a".repeat(SESSION_TITLE_MAX_CHARS); assert_eq!(compose_session_title(&agent, Some("buzz-dev")), agent); } + + /// Every arg whose env var name contains KEY/SECRET/TOKEN/PASSWORD/CRED/AUTH + /// must set `hide_env_values = true` to prevent credential leakage in --help. + #[test] + fn secret_env_args_hide_their_values_in_help() { + use clap::CommandFactory; + + const SECRET_PATTERNS: &[&str] = &["KEY", "SECRET", "TOKEN", "PASSWORD", "CRED", "AUTH"]; + + let cmd = CliArgs::command(); + let violations: Vec = cmd + .get_arguments() + .filter_map(|arg| { + let env_key = arg.get_env()?; + let env_name = env_key.to_string_lossy().to_uppercase(); + let is_secret = SECRET_PATTERNS.iter().any(|pat| env_name.contains(pat)); + if is_secret && !arg.is_hide_env_values_set() { + Some(env_name) + } else { + None + } + }) + .collect(); + + assert!( + violations.is_empty(), + "Found secret-bearing env args without hide_env_values=true. \ + Add `hide_env_values = true` to each: {violations:?}" + ); + } } diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 0b467345844..74656258040 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -82,11 +82,11 @@ struct Cli { relay: String, /// Nostr private key (hex or nsec). This is the CLI's identity. - #[arg(long, env = "BUZZ_PRIVATE_KEY")] + #[arg(long, env = "BUZZ_PRIVATE_KEY", hide_env_values = true)] private_key: Option, /// NIP-OA auth tag JSON (owner attestation). Injected into every signed event. - #[arg(long, env = "BUZZ_AUTH_TAG")] + #[arg(long, env = "BUZZ_AUTH_TAG", hide_env_values = true)] auth_tag: Option, /// Output format: 'json' (default, full fields) or 'compact' (reduced fields). @@ -2075,4 +2075,46 @@ mod tests { ); } } + + /// Collect all args (recursing into subcommands) whose env var name looks + /// like a credential but does NOT have `hide_env_values` set. + fn collect_unhidden_secret_args(cmd: &clap::Command) -> Vec<(String, String)> { + const SECRET_PATTERNS: &[&str] = &["KEY", "SECRET", "TOKEN", "PASSWORD", "CRED", "AUTH"]; + + let mut violations: Vec<(String, String)> = Vec::new(); + + for arg in cmd.get_arguments() { + if let Some(env_key) = arg.get_env() { + let env_name = env_key.to_string_lossy().to_uppercase(); + let is_secret = SECRET_PATTERNS.iter().any(|pat| env_name.contains(pat)); + if is_secret && !arg.is_hide_env_values_set() { + violations.push((cmd.get_name().to_string(), env_name)); + } + } + } + + for sub in cmd.get_subcommands() { + violations.extend(collect_unhidden_secret_args(sub)); + } + + violations + } + + /// Every arg whose env var name contains KEY/SECRET/TOKEN/PASSWORD/CRED/AUTH + /// must set `hide_env_values = true` to prevent credential leakage in --help. + #[test] + fn secret_env_args_hide_their_values_in_help() { + let cmd = Cli::command(); + let violations = collect_unhidden_secret_args(&cmd); + assert!( + violations.is_empty(), + "Found secret-bearing env args without hide_env_values=true. \ + Add `hide_env_values = true` to each:\n{}", + violations + .iter() + .map(|(cmd, env)| format!(" command={cmd:?} env={env:?}")) + .collect::>() + .join("\n") + ); + } }