use crate::check::Check;
use crate::project::Project;
use crate::runner::HelpOutput;
use crate::types::{CheckGroup, CheckLayer, CheckResult, CheckStatus, Confidence};
const SECRET_NAME_FRAGMENTS: &[&str] = &[
"token",
"password",
"passwd",
"secret",
"api-key",
"apikey",
"auth-key",
"credential",
"private-key",
];
const STDIN_SIGNALS: &[&str] = &[
"stdin",
"STDIN",
"standard input",
"read from -",
"from `-`",
];
pub struct SecretNonLeakyPathCheck;
impl Check for SecretNonLeakyPathCheck {
fn id(&self) -> &str {
"p1-secret-non-leaky-path"
}
fn label(&self) -> &'static str {
"Secret-bearing flags expose stdin or *-file companion"
}
fn group(&self) -> CheckGroup {
CheckGroup::P1
}
fn layer(&self) -> CheckLayer {
CheckLayer::Behavioral
}
fn covers(&self) -> &'static [&'static str] {
&["p1-must-secret-non-leaky-path"]
}
fn applicable(&self, project: &Project) -> bool {
project.runner.is_some()
}
fn run(&self, project: &Project) -> anyhow::Result<CheckResult> {
let status = match project.help_output() {
None => CheckStatus::Skip("could not probe --help".into()),
Some(help) => check_secret_non_leaky_path(help),
};
Ok(CheckResult {
id: self.id().to_string(),
label: self.label().into(),
group: self.group(),
layer: self.layer(),
status,
confidence: Confidence::Medium,
})
}
}
pub(crate) fn check_secret_non_leaky_path(help: &HelpOutput) -> CheckStatus {
let flag_long_names: Vec<String> = help.flags().iter().filter_map(|f| f.long.clone()).collect();
let secret_flags: Vec<&str> = flag_long_names
.iter()
.filter(|long| is_secret_flag(long))
.map(|s| s.as_str())
.collect();
if secret_flags.is_empty() {
return CheckStatus::Pass;
}
let raw = help.raw();
let mentions_stdin = STDIN_SIGNALS.iter().any(|sig| raw.contains(sig));
let mut leaky: Vec<&str> = Vec::new();
for flag in &secret_flags {
if flag.ends_with("-file") {
continue;
}
let file_companion = format!("{flag}-file");
let has_companion = flag_long_names.iter().any(|f| f == &file_companion);
if !has_companion && !mentions_stdin {
leaky.push(flag);
}
}
if leaky.is_empty() {
CheckStatus::Pass
} else {
CheckStatus::Fail(format!(
"secret-bearing flag(s) without `*-file` companion or stdin path: {}. \
Flag values leak via process tables, shell history, and CI logs; \
provide stdin support or a `--<flag>-file` variant.",
leaky.join(", ")
))
}
}
fn is_secret_flag(long: &str) -> bool {
let stripped = long.trim_start_matches("--");
SECRET_NAME_FRAGMENTS
.iter()
.any(|frag| stripped.contains(frag))
}
#[cfg(test)]
mod tests {
use super::*;
const HELP_TOKEN_WITH_FILE: &str = r#"Usage: tool [OPTIONS]
Options:
--token <TOKEN> API token used for authentication.
--token-file <PATH> Read API token from PATH (recommended for CI).
-h, --help Show help.
"#;
const HELP_TOKEN_BARE: &str = r#"Usage: tool [OPTIONS]
Options:
--token <TOKEN> API token used for authentication.
-h, --help Show help.
"#;
const HELP_TOKEN_WITH_STDIN: &str = r#"Usage: tool [OPTIONS]
Reads the auth token from stdin when --token is not provided.
Options:
--token <TOKEN> API token used for authentication.
-h, --help Show help.
"#;
const HELP_NO_SECRETS: &str = r#"Usage: tool [OPTIONS]
Options:
--output <FORMAT> Output format.
-q, --quiet Suppress output.
-h, --help Show help.
"#;
const HELP_FILE_FLAG_ONLY: &str = r#"Usage: tool [OPTIONS]
Options:
--secret-file <PATH> Path to the secret material.
-h, --help Show help.
"#;
const HELP_PASSWORD_ONLY_LEAKY: &str = r#"Usage: tool [OPTIONS]
Options:
--password <PASS> Database password.
--user <NAME> Database user.
-h, --help Show help.
"#;
#[test]
fn happy_path_token_with_file_companion() {
let help = HelpOutput::from_raw(HELP_TOKEN_WITH_FILE);
assert_eq!(check_secret_non_leaky_path(&help), CheckStatus::Pass);
}
#[test]
fn happy_path_token_with_stdin_mention() {
let help = HelpOutput::from_raw(HELP_TOKEN_WITH_STDIN);
assert_eq!(check_secret_non_leaky_path(&help), CheckStatus::Pass);
}
#[test]
fn happy_path_no_secrets_vacuous_pass() {
let help = HelpOutput::from_raw(HELP_NO_SECRETS);
assert_eq!(check_secret_non_leaky_path(&help), CheckStatus::Pass);
}
#[test]
fn happy_path_file_flag_only() {
let help = HelpOutput::from_raw(HELP_FILE_FLAG_ONLY);
assert_eq!(check_secret_non_leaky_path(&help), CheckStatus::Pass);
}
#[test]
fn fail_password_only_leaky() {
let help = HelpOutput::from_raw(HELP_PASSWORD_ONLY_LEAKY);
match check_secret_non_leaky_path(&help) {
CheckStatus::Fail(msg) => {
assert!(
msg.contains("--password"),
"msg should name the flag: {msg}"
);
}
other => panic!("expected Fail, got {other:?}"),
}
}
#[test]
fn fail_token_bare_no_companion_no_stdin() {
let help = HelpOutput::from_raw(HELP_TOKEN_BARE);
match check_secret_non_leaky_path(&help) {
CheckStatus::Fail(msg) => {
assert!(msg.contains("--token"));
}
other => panic!("expected Fail, got {other:?}"),
}
}
#[test]
fn detects_apikey_fragment() {
let raw = r#"Options:
--apikey <KEY> API key.
-h, --help Show help.
"#;
let help = HelpOutput::from_raw(raw);
match check_secret_non_leaky_path(&help) {
CheckStatus::Fail(msg) => assert!(msg.contains("--apikey")),
other => panic!("expected Fail, got {other:?}"),
}
}
}