use std::io::{Read, Write};
use std::path::PathBuf;
use std::process::ExitCode;
use crate::detect::{Confidence, Options};
use crate::scan::{self, FileReport};
use crate::walk::{self, WalkOptions};
const USAGE: &str = "usage: secrets-le [options] <file|dir>...
secrets-le [options] --stdin
secrets-le mcp
secrets-le --version | --help
Finds hardcoded credentials and reports where they are — never what they
are. One JSON report per line on stdout, human summary on stderr.
Options:
--sensitivity <level> low, medium (default) or high. Higher reports
fewer, more certain findings.
--no-api-keys skip the API-key detectors
--no-passwords skip the password detectors
--no-tokens skip the token detectors
--no-private-keys skip the private-key detectors
--strict exit 2 if any file could not be read, rather
than reporting it and carrying on
--stdin read one document from stdin
--hidden scan hidden files and directories too
--no-ignore scan files that .gitignore excludes
No preview is ever the whole value, and there is no flag that changes
that: a flag which turned it off would end up in someone's CI config.
Files that are not text, or that cannot be opened, are named on stderr
and carried in the report, and do not by themselves fail the run — every
repository has a PNG in it. --strict turns them back into a failure, and
a detector that gives up part way always does.
Exit codes: 0 nothing found · 1 findings · 2 malformed question.
For a run over many files, the exit code is the worst outcome in it.";
const FLAGS: [&str; 9] = [
"--strict",
"--sensitivity",
"--no-api-keys",
"--no-passwords",
"--no-tokens",
"--no-private-keys",
"--stdin",
"--hidden",
"--no-ignore",
];
#[derive(Debug)]
struct Parsed {
strict: bool,
inputs: Vec<PathBuf>,
stdin: bool,
options: Options,
walk: WalkOptions,
}
pub(crate) fn run() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
if let Some(first) = args.first() {
match first.as_str() {
"mcp" => return crate::mcp::serve(),
"--help" | "-h" => {
println!("{USAGE}");
return ExitCode::SUCCESS;
}
"--version" | "-V" => {
println!("secrets-le {}", env!("CARGO_PKG_VERSION"));
return ExitCode::SUCCESS;
}
_ => {}
}
}
match execute(&args) {
Ok(code) => ExitCode::from(code),
Err(message) => {
eprintln!("secrets-le: {message}");
ExitCode::from(2)
}
}
}
fn execute(args: &[String]) -> Result<u8, String> {
let parsed = parse(args)?;
let (reports, walked, scanned) = if parsed.stdin {
let mut content = String::new();
std::io::stdin()
.read_to_string(&mut content)
.map_err(|error| format!("could not read stdin: {error}"))?;
(
vec![scan::scan_content(
&content,
"<stdin>".to_string(),
parsed.options,
)],
walk::Walked::default(),
1,
)
} else {
let walked = walk::collect(&parsed.inputs, &parsed.walk)?;
let total = walked.files.len();
let reports: Vec<FileReport> = walked
.files
.iter()
.map(|file| scan::scan_file(file, parsed.options))
.collect();
(reports, walked, total)
};
let mut stdout = std::io::stdout().lock();
for report in &reports {
let line = serde_json::to_string(report).expect("a report serializes");
writeln!(stdout, "{line}")
.map_err(|error| format!("could not write the report: {error}"))?;
}
drop(stdout);
summarise(&reports, &walked, scanned);
Ok(scan::exit_code(&reports, parsed.strict))
}
fn parse(args: &[String]) -> Result<Parsed, String> {
let mut parsed = Parsed {
inputs: Vec::new(),
stdin: false,
strict: false,
options: Options::default(),
walk: WalkOptions::default(),
};
let mut rest = args.iter();
while let Some(arg) = rest.next() {
if arg.starts_with('-') && !FLAGS.contains(&arg.as_str()) {
return Err(format!("{arg} is not an option. Try --help."));
}
match arg.as_str() {
"--stdin" => parsed.stdin = true,
"--strict" => parsed.strict = true,
"--hidden" => parsed.walk.hidden = true,
"--no-ignore" => parsed.walk.respect_ignore = false,
"--no-api-keys" => parsed.options.api_keys = false,
"--no-passwords" => parsed.options.passwords = false,
"--no-tokens" => parsed.options.tokens = false,
"--no-private-keys" => parsed.options.private_keys = false,
"--sensitivity" => {
let value = rest
.next()
.ok_or_else(|| "--sensitivity needs a level".to_string())?;
parsed.options.sensitivity = match value.as_str() {
"low" => Confidence::Low,
"medium" => Confidence::Medium,
"high" => Confidence::High,
other => {
return Err(format!(
"{other} is not a sensitivity; one of: low, medium, high"
));
}
};
}
path => parsed.inputs.push(PathBuf::from(path)),
}
}
if parsed.stdin && !parsed.inputs.is_empty() {
return Err("reading from stdin takes no file arguments".to_string());
}
if !parsed.stdin && parsed.inputs.is_empty() {
return Err("name a file or a directory to scan. Try --help.".to_string());
}
Ok(parsed)
}
fn summarise(reports: &[FileReport], walked: &walk::Walked, scanned: usize) {
let mut stderr = std::io::stderr().lock();
let mut findings = 0;
for report in reports {
for diagnostic in &report.diagnostics {
let _ = writeln!(stderr, "{}: {}", report.file, diagnostic.message);
}
for finding in &report.findings {
findings += 1;
let _ = writeln!(stderr, "{}", scan::describe(report, finding));
}
}
for path in &walked.skipped_of_note {
let _ = writeln!(
stderr,
"not scanned: {} — excluded by .gitignore or the hidden rule, and its name says it \
holds credentials. --no-ignore --hidden reaches it.",
scan::reported_path(path)
);
}
let _ = writeln!(
stderr,
"{} in {}{}",
plural(findings, "finding", "findings"),
plural(scanned, "file", "files"),
if walked.skipped == 0 {
String::new()
} else {
format!(
" — {} others excluded by .gitignore or the hidden rule, mostly dependencies",
walked.skipped - walked.skipped_of_note.len()
)
}
);
}
fn plural(count: usize, one: &str, many: &str) -> String {
format!("{count} {}", if count == 1 { one } else { many })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_documented_flag_is_parsed_and_the_reverse() {
let mut documented: Vec<&str> = USAGE
.split_whitespace()
.filter(|word| word.starts_with("--"))
.map(|word| word.trim_end_matches([',', '.', ':', ';']))
.filter(|word| !matches!(*word, "--version" | "--help"))
.collect();
documented.sort_unstable();
documented.dedup();
let mut implemented = FLAGS.to_vec();
implemented.sort_unstable();
assert_eq!(documented, implemented);
}
#[test]
fn the_parser_accepts_every_flag_it_lists() {
for flag in FLAGS {
let args: Vec<String> = match flag {
"--sensitivity" => {
vec![flag.to_string(), "high".to_string(), "x".to_string()]
}
"--stdin" => vec![flag.to_string()],
_ => vec![flag.to_string(), "x".to_string()],
};
assert!(parse(&args).is_ok(), "{flag}");
}
}
#[test]
fn an_unknown_flag_is_refused_rather_than_ignored() {
let error = parse(&["--no-passwrods".to_string(), "x".to_string()]).expect_err("a refusal");
assert!(error.contains("--no-passwrods"), "{error}");
}
#[test]
fn no_flag_reveals_a_value() {
for attempt in [
"--show-values",
"--unsafe",
"--raw",
"--no-mask",
"--values",
] {
assert!(
parse(&[attempt.to_string(), "x".to_string()]).is_err(),
"{attempt} was accepted"
);
}
assert!(!USAGE.contains("show"), "the usage text offers one");
}
#[test]
fn an_unknown_sensitivity_is_refused_by_name() {
let error = parse(&[
"--sensitivity".to_string(),
"paranoid".to_string(),
"x".to_string(),
])
.expect_err("a refusal");
assert!(error.contains("paranoid"), "{error}");
}
#[test]
fn sensitivity_defaults_to_medium() {
let parsed = parse(&["x".to_string()]).expect("parses");
assert_eq!(parsed.options.sensitivity, Confidence::Medium);
}
#[test]
fn each_detector_family_can_be_switched_off() {
let parsed = parse(&[
"--no-api-keys".to_string(),
"--no-passwords".to_string(),
"--no-tokens".to_string(),
"--no-private-keys".to_string(),
"x".to_string(),
])
.expect("parses");
assert!(!parsed.options.api_keys);
assert!(!parsed.options.passwords);
assert!(!parsed.options.tokens);
assert!(!parsed.options.private_keys);
}
#[test]
fn naming_nothing_is_refused() {
assert!(parse(&[]).is_err());
}
#[test]
fn stdin_and_file_arguments_together_are_refused() {
assert!(parse(&["--stdin".to_string(), "x".to_string()]).is_err());
}
#[test]
fn the_usage_text_names_the_exit_codes_it_returns() {
for code in ["0", "1", "2"] {
assert!(USAGE.contains(code), "exit code {code} is undocumented");
}
}
}