use std::path::PathBuf;
use std::process::Command;
use tempfile::TempDir;
fn binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_keyhog"))
}
fn scan_text_file(content: &str, extra_args: &[&str]) -> (String, String, Option<i32>) {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("planted.txt");
std::fs::write(&path, content).expect("write fixture");
let output = Command::new(binary())
.arg("scan")
.args(extra_args)
.arg("--format")
.arg("json")
.arg(&path)
.output()
.expect("spawn keyhog scan");
(
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
output.status.code(),
)
}
#[test]
fn scan_finds_planted_aws_key_and_returns_exit_1() {
let fixture = concat!("AWS_ACCESS_KEY_ID = \"AKIA", "QYLPMN5HFIQR7XYA\"\n");
let (stdout, _stderr, code) = scan_text_file(fixture, &[]);
assert_eq!(
code,
Some(1),
"expected exit 1 (unverified findings); got {code:?}"
);
let findings: serde_json::Value = serde_json::from_str(&stdout).expect("stdout is valid JSON");
let arr = findings.as_array().expect("findings JSON is an array");
assert!(!arr.is_empty(), "expected at least one finding");
let aws = arr.iter().find(|f| {
matches!(
f.get("detector_id").and_then(|v| v.as_str()),
Some("aws-access-key" | "hot-aws_key")
)
});
assert!(aws.is_some(), "expected an AWS key finding; got: {arr:?}");
}
#[test]
fn scan_returns_exit_0_on_clean_file() {
let fixture = "fn main() { println!(\"hello\"); }\n";
let (stdout, _stderr, code) = scan_text_file(fixture, &[]);
assert_eq!(code, Some(0), "expected exit 0 on clean file; got {code:?}");
let findings: serde_json::Value = serde_json::from_str(&stdout).expect("stdout is valid JSON");
let arr = findings.as_array().expect("findings JSON is an array");
assert!(arr.is_empty(), "expected zero findings; got: {arr:?}");
}
#[test]
fn scan_json_schema_carries_required_fields() {
let fixture = "GH_TOKEN = \"ghp_aBcD1234EFgh5678ijkl9012MNop3456qrST\"\n";
let (stdout, _stderr, _code) = scan_text_file(fixture, &[]);
let findings: serde_json::Value = serde_json::from_str(&stdout).expect("stdout is valid JSON");
let arr = findings.as_array().expect("findings JSON is an array");
assert!(!arr.is_empty(), "expected the GH token to fire");
for f in arr {
for required in [
"detector_id",
"detector_name",
"service",
"severity",
"credential_redacted",
"credential_hash",
"location",
"verification",
] {
assert!(
f.get(required).is_some(),
"finding is missing required field `{required}`: {f}",
);
}
let loc = f.get("location").unwrap();
for required in ["source", "file_path", "line", "offset"] {
assert!(
loc.get(required).is_some(),
"location is missing required field `{required}`: {loc}",
);
}
}
}
#[test]
fn readme_banner_counts_match_loaded_corpus() {
const README_DETECTOR_COUNT: usize = 891;
const README_PATTERN_COUNT: usize = 1645;
let output = Command::new(binary())
.arg("detectors")
.arg("--json")
.output()
.expect("spawn keyhog detectors --json");
assert_eq!(output.status.code(), Some(0));
let arr: Vec<serde_json::Value> =
serde_json::from_slice(&output.stdout).expect("detectors JSON parse");
let actual_patterns: usize = arr
.iter()
.map(|d| {
d.get("patterns")
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(0)
})
.sum();
assert_eq!(
arr.len(),
README_DETECTOR_COUNT,
"README banner says {README_DETECTOR_COUNT} detectors; actual={}. \
Update README and the constant in this test together.",
arr.len(),
);
assert_eq!(
actual_patterns, README_PATTERN_COUNT,
"README banner says {README_PATTERN_COUNT} patterns; actual={actual_patterns}. \
Update README and the constant in this test together.",
);
}
#[test]
fn detectors_subcommand_emits_json_array() {
let output = Command::new(binary())
.arg("detectors")
.arg("--json")
.output()
.expect("spawn keyhog detectors --json");
assert_eq!(
output.status.code(),
Some(0),
"detectors --json should exit 0; stderr={}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("detectors --json stdout is valid JSON");
let arr = parsed.as_array().expect("--json output is a JSON array");
assert!(
arr.len() > 100,
"expected hundreds of detectors; got {}",
arr.len()
);
let aws = arr
.iter()
.find(|d| d.get("id").and_then(|v| v.as_str()) == Some("aws-access-key"));
assert!(
aws.is_some(),
"aws-access-key should appear in --json output"
);
let aws = aws.unwrap();
assert_eq!(
aws.get("service").and_then(|v| v.as_str()),
Some("aws"),
"aws-access-key should have service=aws",
);
}
#[test]
fn no_suppress_test_fixtures_surfaces_stripe_demo_key() {
let stripe_key = concat!("sk_", "live_", "4eC39HqLyjWDarjtT1zdp7dc");
let fixture = format!("STRIPE_KEY = \"{stripe_key}\"\n");
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("planted.txt");
std::fs::write(&path, &fixture).expect("write fixture");
let default_out = Command::new(binary())
.arg("scan")
.arg("--format")
.arg("json")
.arg(&path)
.output()
.expect("spawn keyhog scan (default)");
let default_json = String::from_utf8_lossy(&default_out.stdout);
let default_findings: serde_json::Value =
serde_json::from_str(&default_json).expect("default-mode stdout is JSON");
let default_arr = default_findings.as_array().expect("array");
let has_stripe = default_arr
.iter()
.any(|f| f.get("service").and_then(|v| v.as_str()) == Some("stripe"));
assert!(
!has_stripe,
"default mode MUST suppress the Stripe demo key; got findings: {default_arr:?}"
);
let optout_out = Command::new(binary())
.arg("scan")
.arg("--no-suppress-test-fixtures")
.arg("--format")
.arg("json")
.arg(&path)
.output()
.expect("spawn keyhog scan (opt-out)");
let optout_json = String::from_utf8_lossy(&optout_out.stdout);
let optout_findings: serde_json::Value =
serde_json::from_str(&optout_json).expect("opt-out stdout is JSON");
let optout_arr = optout_findings.as_array().expect("array");
let has_stripe_now = optout_arr
.iter()
.any(|f| f.get("service").and_then(|v| v.as_str()) == Some("stripe"));
assert!(
has_stripe_now,
"--no-suppress-test-fixtures MUST surface the Stripe demo key; \
got findings: {optout_arr:?}"
);
}
#[test]
fn demo_secret_aws_example_summary_distinguishes_suppression_from_clean() {
let fixture = "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE\n";
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("demo-secret.env");
std::fs::write(&path, fixture).expect("write fixture");
let out = Command::new(binary())
.arg("scan")
.arg("--no-daemon")
.arg("--format")
.arg("text")
.arg(&path)
.output()
.expect("spawn keyhog scan demo-secret.env");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("example/test key") && stdout.contains("suppressed"),
"demo-secret.env summary must distinguish suppressed-example from a \
clean repo. Got stdout: {stdout}"
);
assert!(
!stdout.contains("Your code is clean."),
"the clean-repo summary must NOT fire when an example credential was \
suppressed. Got stdout: {stdout}"
);
}
#[test]
fn explicit_format_text_does_not_emit_json() {
let fixture = concat!("AWS_ACCESS_KEY_ID = \"AKIA", "QYLPMN5HFIQR7XYA\"\n");
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("planted.txt");
std::fs::write(&path, fixture).expect("write fixture");
let output = Command::new(binary())
.arg("scan")
.arg("--format")
.arg("text")
.arg(&path)
.output()
.expect("spawn keyhog scan --format text");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!("{stdout}\n{stderr}");
assert!(
!stdout.trim_start().starts_with('['),
"text format must not start with JSON `[`; got: {stdout}",
);
assert!(
combined.to_lowercase().contains("aws") || combined.contains("AKIA"),
"text format should mention the finding somewhere; \
stdout={stdout:?}, stderr={stderr:?}, exit={:?}",
output.status.code(),
);
}
#[test]
fn scan_comments_flag_surfaces_credentials_in_comments() {
let aws_key = concat!("AKIA", "ROTATIONNEEDED7777Q");
let fixture = format!("// TODO: rotate this - {aws_key}\n");
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("comment_planted.go");
std::fs::write(&path, &fixture).expect("write fixture");
let default_out = Command::new(binary())
.arg("scan")
.arg("--format")
.arg("json")
.arg(&path)
.output()
.expect("spawn keyhog scan (default)");
let default_json = String::from_utf8_lossy(&default_out.stdout);
let default_findings: serde_json::Value =
serde_json::from_str(&default_json).expect("default-mode stdout is JSON");
let default_count = default_findings.as_array().map(|a| a.len()).unwrap_or(0);
let opt_in_out = Command::new(binary())
.arg("scan")
.arg("--scan-comments")
.arg("--format")
.arg("json")
.arg(&path)
.output()
.expect("spawn keyhog scan --scan-comments");
let opt_in_json = String::from_utf8_lossy(&opt_in_out.stdout);
let opt_in_findings: serde_json::Value =
serde_json::from_str(&opt_in_json).expect("opt-in stdout is JSON");
let opt_in_count = opt_in_findings.as_array().map(|a| a.len()).unwrap_or(0);
assert!(
opt_in_count >= default_count,
"--scan-comments must not LOSE findings vs default; \
default={default_count}, --scan-comments={opt_in_count}, \
default_json={default_json}, opt_in_json={opt_in_json}"
);
assert!(
opt_in_count >= 1,
"--scan-comments MUST surface the AKIA-prefixed key in the \
comment; got {opt_in_count} findings: {opt_in_json}"
);
}
fn workspace_detectors() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../detectors")
.canonicalize()
.expect("workspace detectors dir")
}
#[cfg(feature = "git")]
fn init_git_repo(repo_path: &std::path::Path) {
use std::process::Command;
for args in [
["init", "-b", "main"],
["config", "user.email", "test@example.com"],
["config", "user.name", "Test User"],
] {
let output = Command::new("git")
.args(args)
.current_dir(repo_path)
.output()
.expect("git setup");
assert!(output.status.success(), "git setup failed: {output:?}");
}
}
#[cfg(feature = "git")]
#[test]
fn git_staged_scan_finds_only_staged_secret() {
use std::process::Command;
let repo = TempDir::new().expect("tempdir");
let repo_path = repo.path();
init_git_repo(repo_path);
std::fs::write(
repo_path.join("staged.env"),
concat!("AWS_ACCESS_KEY_ID = \"AKIA", "QYLPMN5HFIQR7XYA\"\n"),
)
.unwrap();
std::fs::write(
repo_path.join("unstaged.env"),
"AWS_ACCESS_KEY_ID = \"AKIAQYLPMN5HUNSTAGEDKEY000000000000\"\n",
)
.unwrap();
Command::new("git")
.args(["add", "staged.env"])
.current_dir(repo_path)
.output()
.unwrap();
let output = Command::new(binary())
.current_dir(repo_path)
.args([
"scan",
"--git-staged",
"--no-daemon",
"--format",
"json",
"--path",
".",
])
.output()
.expect("git-staged scan");
assert_eq!(
output.status.code(),
Some(1),
"staged secret must exit 1; stderr={}",
String::from_utf8_lossy(&output.stderr)
);
let findings: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("stdout is JSON");
let arr = findings.as_array().expect("array");
assert!(
arr.iter().any(|f| {
f.get("location")
.and_then(|l| l.get("file_path"))
.and_then(|p| p.as_str())
.is_some_and(|p| p.ends_with("staged.env"))
}),
"must find staged file secret; got {arr:?}"
);
assert!(
!arr.iter().any(|f| {
f.get("location")
.and_then(|l| l.get("file_path"))
.and_then(|p| p.as_str())
.is_some_and(|p| p.contains("unstaged.env"))
}),
"unstaged file must not be scanned; got {arr:?}"
);
}
#[test]
fn baseline_suppresses_acknowledged_findings_on_rescan() {
let dir = TempDir::new().expect("tempdir");
let fixture = dir.path().join("planted.txt");
std::fs::write(
&fixture,
concat!("AWS_ACCESS_KEY_ID = \"AKIA", "QYLPMN5HFIQR7XYA\"\n"),
)
.unwrap();
let baseline_path = dir.path().join("baseline.json");
let create = Command::new(binary())
.args([
"scan",
"--no-daemon",
"--create-baseline",
baseline_path.to_str().unwrap(),
"--format",
"json",
])
.arg(&fixture)
.output()
.expect("create baseline");
assert_eq!(
create.status.code(),
Some(0),
"create-baseline must exit 0; stderr={}",
String::from_utf8_lossy(&create.stderr)
);
assert!(baseline_path.exists(), "baseline file must be written");
let filtered = Command::new(binary())
.args([
"scan",
"--no-daemon",
"--baseline",
baseline_path.to_str().unwrap(),
"--format",
"json",
])
.arg(&fixture)
.output()
.expect("baseline-filter scan");
assert_eq!(
filtered.status.code(),
Some(0),
"baseline-filtered rescan must exit 0; stderr={}",
String::from_utf8_lossy(&filtered.stderr)
);
let findings: serde_json::Value =
serde_json::from_slice(&filtered.stdout).expect("filtered stdout is JSON");
assert!(
findings.as_array().is_some_and(|a| a.is_empty()),
"baseline must suppress known findings; got {findings:?}"
);
}
#[test]
fn lockdown_bails_on_verify_flag() {
let dir = TempDir::new().expect("tempdir");
let fixture = dir.path().join("planted.txt");
std::fs::write(
&fixture,
concat!("AWS_ACCESS_KEY_ID = \"AKIA", "QYLPMN5HFIQR7XYA\"\n"),
)
.unwrap();
let mut cmd = Command::new("prlimit");
cmd.args(["--core=0"])
.arg(binary())
.args([
"scan",
"--no-daemon",
"--lockdown",
"--verify",
"--format",
"json",
])
.arg(&fixture);
let output = match cmd.output() {
Ok(out) => out,
Err(_) => Command::new(binary())
.args([
"scan",
"--no-daemon",
"--lockdown",
"--verify",
"--format",
"json",
])
.arg(&fixture)
.output()
.expect("lockdown+verify scan"),
};
assert_eq!(
output.status.code(),
Some(2),
"lockdown+verify must exit 2 (runtime error); got {:?}",
output.status.code()
);
let combined = format!(
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
combined.contains("lockdown mode forbids --verify")
|| combined.contains("protections failed to apply"),
"must refuse outbound verification in lockdown (or fail closed on \
hardening); got: {combined}"
);
if !combined.contains("protections failed to apply") {
assert!(
combined.contains("lockdown mode forbids --verify"),
"when lockdown protections apply, --verify must be refused; got: {combined}"
);
}
}
#[cfg(unix)]
#[test]
fn daemon_wire_scan_path_finds_planted_secret() {
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
let runtime = TempDir::new().expect("runtime dir");
let dir = TempDir::new().expect("fixture dir");
let fixture = dir.path().join("daemon_planted.txt");
std::fs::write(
&fixture,
concat!("AWS_ACCESS_KEY_ID = \"AKIA", "QYLPMN5HFIQR7XYA\"\n"),
)
.unwrap();
let detectors = workspace_detectors();
let mut daemon: Child = Command::new(binary())
.env("XDG_RUNTIME_DIR", runtime.path())
.args([
"daemon",
"start",
"--detectors",
detectors.to_str().unwrap(),
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn daemon");
let socket = runtime.path().join("keyhog.sock");
let deadline = Instant::now() + Duration::from_secs(30);
while !socket.exists() {
assert!(
Instant::now() < deadline,
"daemon socket did not appear in time"
);
std::thread::sleep(Duration::from_millis(50));
}
let scan = Command::new(binary())
.env("XDG_RUNTIME_DIR", runtime.path())
.args(["scan", "--daemon", "--format", "json"])
.arg(&fixture)
.output()
.expect("daemon scan");
let _ = Command::new(binary())
.env("XDG_RUNTIME_DIR", runtime.path())
.args(["daemon", "stop"])
.output();
let _ = daemon.kill();
let _ = daemon.wait();
assert_eq!(
scan.status.code(),
Some(1),
"daemon scan must find secret (exit 1); stderr={}",
String::from_utf8_lossy(&scan.stderr)
);
let findings: serde_json::Value =
serde_json::from_slice(&scan.stdout).expect("daemon stdout is JSON");
let arr = findings.as_array().expect("array");
assert!(
arr.iter().any(|f| matches!(
f.get("detector_id").and_then(|v| v.as_str()),
Some("aws-access-key" | "hot-aws_key")
)),
"daemon wire path must return an AWS finding; got {arr:?}"
);
}
#[test]
fn doctor_reports_corpus_and_passes_scan_self_test() {
let output = Command::new(binary())
.arg("doctor")
.env("KEYHOG_NO_GPU", "1")
.output()
.expect("run keyhog doctor");
let stdout = String::from_utf8_lossy(&output.stdout);
assert_eq!(
output.status.code(),
Some(0),
"doctor must exit 0 on a healthy host (PATH warning is non-fatal); stdout:\n{stdout}"
);
assert!(
stdout.contains("self-test"),
"doctor must run a self-test section; got:\n{stdout}"
);
assert!(
stdout.contains("PASS"),
"the scan-engine self-test must PASS; got:\n{stdout}"
);
let corpus = keyhog_core::embedded_detector_count();
assert!(corpus > 0, "binary must embed a detector corpus");
assert!(
stdout.contains(&corpus.to_string()),
"doctor must display the real embedded corpus count ({corpus}); got:\n{stdout}"
);
}
#[test]
fn update_subcommand_is_wired_with_its_flags() {
let output = Command::new(binary())
.arg("update")
.arg("--help")
.output()
.expect("run keyhog update --help");
assert!(
output.status.success(),
"`keyhog update --help` must succeed; stderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
let help = String::from_utf8_lossy(&output.stdout);
for flag in ["--check", "--version", "--variant"] {
assert!(
help.contains(flag),
"`keyhog update --help` must document {flag}; got:\n{help}"
);
}
}
#[test]
fn repair_subcommand_is_wired_with_its_flags() {
let output = Command::new(binary())
.arg("repair")
.arg("--help")
.output()
.expect("run keyhog repair --help");
assert!(
output.status.success(),
"`keyhog repair --help` must succeed; stderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
let help = String::from_utf8_lossy(&output.stdout);
for flag in ["--force", "--version", "--variant"] {
assert!(
help.contains(flag),
"`keyhog repair --help` must document {flag}; got:\n{help}"
);
}
}
#[test]
fn uninstall_dry_run_does_not_remove_the_binary() {
let bin = binary();
let output = Command::new(&bin)
.arg("uninstall")
.output()
.expect("run keyhog uninstall");
assert!(
output.status.success(),
"dry-run uninstall must exit 0; stderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
let out = String::from_utf8_lossy(&output.stdout).to_lowercase();
assert!(
out.contains("dry run"),
"uninstall without --yes must announce it's a dry run; got:\n{out}"
);
assert!(
bin.exists(),
"dry-run uninstall MUST NOT delete the binary at {}",
bin.display()
);
}
fn scan_dir_with_config(
content: &str,
config: &str,
extra: &[&str],
) -> (String, String, Option<i32>) {
let dir = TempDir::new().expect("tempdir");
std::fs::write(dir.path().join("planted.txt"), content).expect("write fixture");
std::fs::write(dir.path().join(".keyhog.toml"), config).expect("write config");
let output = Command::new(binary())
.args(["scan", "--no-daemon", "--format", "json"])
.args(extra)
.arg(dir.path())
.output()
.expect("spawn keyhog scan");
(
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
output.status.code(),
)
}
#[test]
fn config_detector_disable_drops_findings() {
let aws = concat!("AWS_ACCESS_KEY_ID = \"AKIA", "QYLPMN5HFIQR7XYA\"\n");
let (_o, _e, before) = scan_dir_with_config(aws, "", &[]);
assert_eq!(before, Some(1), "baseline: the AWS key must be found");
let (out, _e, code) = scan_dir_with_config(
aws,
"[detector.hot-aws_key]\nenabled = false\n[detector.aws-access-key]\nenabled = false\n",
&[],
);
assert_eq!(
code,
Some(0),
"disabling the AWS detectors via .keyhog.toml must yield zero findings; stdout={out}"
);
}
#[test]
fn config_lockdown_require_refuses_without_flag() {
let (_o, err, code) =
scan_dir_with_config("ordinary content\n", "[lockdown]\nrequire = true\n", &[]);
assert_ne!(
code,
Some(0),
"a repo whose .keyhog.toml requires lockdown must NOT run without --lockdown"
);
assert!(
err.to_lowercase().contains("lockdown"),
"the refusal must name lockdown so the operator knows why; stderr={err}"
);
}