use std::path::PathBuf;
use std::process::Command;
use tempfile::TempDir;
#[path = "support/json_report.rs"]
mod json_report_support;
use json_report_support::parse_json_array;
const FUNCTIONAL_E2E_BACKEND: &str = "cpu";
fn binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_keyhog"))
}
fn repo_root() -> PathBuf {
let mut root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
root.pop();
root.pop();
root
}
fn detector_dir() -> PathBuf {
repo_root().join("detectors")
}
fn doc_text(rel: &str) -> String {
std::fs::read_to_string(repo_root().join(rel))
.unwrap_or_else(|error| panic!("read {rel} for doc/banner coherence contract: {error}"))
}
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")
.arg("--daemon=off")
.args(["--backend", FUNCTIONAL_E2E_BACKEND])
.args(extra_args)
.arg("--format")
.arg("json")
.arg(&path)
.env_remove("KEYHOG_BACKEND")
.output()
.expect("spawn keyhog scan");
(
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
output.status.code(),
)
}
fn forced_simd_progress_banner() -> String {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("clean.txt");
std::fs::write(&path, "hello world\n").expect("write fixture");
let home = dirs::home_dir().expect("home directory for validated Hyperscan cache fixture");
let cache_root = TempDir::new_in(home).expect("home-scoped Hyperscan cache root");
let cache_dir = cache_root.path().join("hyperscan-cache");
let output = Command::new(binary())
.args([
"scan",
"--daemon=off",
"--progress",
"--format",
"json",
"--backend",
"simd",
])
.arg("--cache-dir")
.arg(&cache_dir)
.arg(&path)
.output()
.expect("spawn keyhog scan --progress");
assert_eq!(
output.status.code(),
Some(0),
"clean progress scan should exit 0; stderr={}",
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
stderr
.lines()
.find(|line| {
line.contains("detectors (") && line.contains("patterns)") && line.contains("backend=")
})
.unwrap_or_else(|| panic!("progress banner missing from stderr:\n{stderr}"))
.to_owned()
}
fn parse_banner_counts(line: &str) -> (usize, usize) {
let marker = " detectors (";
let detector_end = line
.find(marker)
.unwrap_or_else(|| panic!("progress banner missing detector marker: {line}"));
let detector_count = line[..detector_end]
.split_whitespace()
.last()
.unwrap_or_else(|| panic!("progress banner missing detector count: {line}"))
.parse()
.unwrap_or_else(|error| panic!("progress banner detector count is not numeric: {error}"));
let pattern_count = line[detector_end + marker.len()..]
.split_whitespace()
.next()
.unwrap_or_else(|| panic!("progress banner missing pattern count: {line}"))
.parse()
.unwrap_or_else(|error| panic!("progress banner pattern count is not numeric: {error}"));
(detector_count, pattern_count)
}
#[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");
let aws = arr.iter().find(|f| {
matches!(
f.get("detector_id").and_then(|v| v.as_str()),
Some("aws-access-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_finds_planted_bedrock_key_and_returns_exit_1() {
let fixture = concat!(
"AWS_BEARER_TOKEN_BEDROCK=\"ABSKQmVkcm9ja0FQSUtleS",
"y2J0fajDUXD1efoRCtqKODGGBi8UWr7UJsq2tkhFhx8ZEDEd9hnKHivse0YHShMdeCAbPEOXOxyhkg5cqNGHA1grwAyKC3Y8HDD62wLdl37iKN\"\n",
);
let (stdout, _stderr, code) = scan_text_file(fixture, &[]);
assert_eq!(
code,
Some(1),
"planted Bedrock key should exit 1; got {code:?}"
);
let arr: Vec<serde_json::Value> = serde_json::from_str(&stdout).expect("stdout is valid JSON");
let bedrock = arr
.iter()
.find(|f| f.get("detector_id").and_then(|v| v.as_str()) == Some("aws-bedrock-api-key"));
assert!(
bedrock.is_some(),
"expected an aws-bedrock-api-key finding; got: {arr:?}",
);
assert_eq!(
bedrock.unwrap().get("severity").and_then(|v| v.as_str()),
Some("critical"),
"Bedrock key must be critical severity",
);
}
#[test]
fn scan_unknown_flag_exits_2() {
let dir = TempDir::new().expect("tempdir");
let output = Command::new(binary())
.arg("scan")
.arg("--this-flag-does-not-exist")
.arg(dir.path())
.output()
.expect("spawn keyhog scan");
assert_eq!(
output.status.code(),
Some(2),
"unknown flag must exit 2 (user error); stderr={}",
String::from_utf8_lossy(&output.stderr),
);
}
#[test]
fn scan_git_history_on_non_repo_exits_13() {
let dir = TempDir::new().expect("tempdir");
std::fs::write(dir.path().join("a.txt"), "nothing here\n").expect("write");
let output = Command::new(binary())
.arg("scan")
.arg("--git-history")
.arg(dir.path())
.output()
.expect("spawn keyhog scan --git-history");
assert_eq!(
output.status.code(),
Some(13),
"--git-history on a non-git dir must exit 13 (source failed), not 2/3; stderr={}",
String::from_utf8_lossy(&output.stderr),
);
}
#[test]
fn diff_missing_baseline_exits_2() {
let dir = TempDir::new().expect("tempdir");
let output = Command::new(binary())
.arg("diff")
.arg(dir.path().join("before.json"))
.arg(dir.path().join("after.json"))
.output()
.expect("spawn keyhog diff");
assert_eq!(
output.status.code(),
Some(2),
"diff with a missing baseline must exit 2 (user error); stderr={}",
String::from_utf8_lossy(&output.stderr),
);
}
#[test]
fn scan_json_schema_carries_required_fields() {
let fixture = "GH_TOKEN = \"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\"\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");
let gh = arr.iter().find(|f| {
let det = f.get("detector_id").and_then(|v| v.as_str()).unwrap_or("");
let svc = f.get("service").and_then(|v| v.as_str()).unwrap_or("");
(det.contains("github") || svc.contains("github"))
&& f.pointer("/location/line").and_then(|v| v.as_u64()) == Some(1)
});
assert!(
gh.is_some(),
"expected the planted ghp_ token to fire a GitHub detector on line 1; got {arr:?}"
);
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() {
let detector_dir = detector_dir();
let specs = keyhog_core::load_detectors(&detector_dir).expect("load detectors/ corpus");
let expected_detectors = specs.len();
let expected_patterns: usize = specs.iter().map(|d| d.patterns.len()).sum();
let output = Command::new(binary())
.arg("detectors")
.args(["--format", "json"])
.output()
.expect("spawn keyhog detectors --format 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(),
expected_detectors,
"binary advertises {} detectors but the on-disk corpus has {expected_detectors}. \
The shipped binary embeds a stale set, rebuild, or a detector silently failed \
to embed.",
arr.len(),
);
assert_eq!(
actual_patterns, expected_patterns,
"binary advertises {actual_patterns} patterns but the on-disk corpus has \
{expected_patterns}. Binary/corpus pattern drift.",
);
}
#[test]
fn docs_scan_banners_match_live_binary_banner_contract() {
let detector_dir = detector_dir();
let specs = keyhog_core::load_detectors(&detector_dir).expect("load detectors/ corpus");
let expected_detectors = specs.len();
let version_output = Command::new(binary())
.arg("--version")
.output()
.expect("spawn keyhog --version");
assert_eq!(
version_output.status.code(),
Some(0),
"--version must exit 0; stderr={}",
String::from_utf8_lossy(&version_output.stderr)
);
let version_stdout = String::from_utf8_lossy(&version_output.stdout);
assert!(
version_stdout.contains(env!("CARGO_PKG_VERSION")),
"--version output must expose the workspace version {}; got {version_stdout}",
env!("CARGO_PKG_VERSION")
);
let progress_banner = forced_simd_progress_banner();
let (banner_detectors, banner_patterns) = parse_banner_counts(&progress_banner);
assert_eq!(
banner_detectors, expected_detectors,
"live progress banner detector count drifted from loaded corpus; banner={progress_banner}"
);
assert!(
banner_patterns >= banner_detectors,
"compiled pattern count must not be smaller than detector count; banner={progress_banner}"
);
let version_fragment = format!(
"v{} · secret scanner · {expected_detectors} detectors",
env!("CARGO_PKG_VERSION")
);
let compiled_count_fragment =
format!("{expected_detectors} detectors ({banner_patterns} patterns)");
for rel in ["docs/src/introduction.md", "docs/src/first-scan.md"] {
let doc = doc_text(rel);
assert!(
doc.contains("K E Y H O G") && doc.contains("by santh"),
"{rel} must show the real multi-line KeyHog banner"
);
assert!(
doc.contains(&version_fragment),
"{rel} must use the live --version/detector banner `{version_fragment}`"
);
assert!(
doc.contains(&compiled_count_fragment),
"{rel} must pin the live compiled scanner pattern count `{compiled_count_fragment}`"
);
assert!(
doc.contains("backend=") && doc.contains("gpu="),
"{rel} must show operator-visible backend/gpu decision fields"
);
assert!(
!doc.contains("AVX-512 + Hyperscan + CUDA") && !doc.contains("1666 patterns"),
"{rel} still contains the stale one-line fabricated banner"
);
}
let readme = doc_text("README.md");
assert!(
readme.contains(&version_fragment),
"README.md must use the live --version/detector banner `{version_fragment}`"
);
assert!(
readme.contains(&compiled_count_fragment),
"README.md must pin the live compiled scanner pattern count `{compiled_count_fragment}`"
);
assert!(
readme.contains("backend="),
"README.md must show the operator-visible backend decision field"
);
assert!(
!readme.contains("AVX-512 + Hyperscan + CUDA") && !readme.contains("1666 patterns"),
"README.md still contains the stale one-line fabricated banner"
);
}
#[test]
fn detectors_subcommand_emits_json_array() {
let output = Command::new(binary())
.arg("detectors")
.args(["--format", "json"])
.output()
.expect("spawn keyhog detectors --format json");
assert_eq!(
output.status.code(),
Some(0),
"detectors --format 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 --format json stdout is valid JSON");
let arr = parsed
.as_array()
.expect("--format 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 --format 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",
);
let allowed = ["info", "client-safe", "low", "medium", "high", "critical"];
for detector in arr {
let severity = detector
.get("severity")
.and_then(|value| value.as_str())
.expect("detector severity must be a string");
assert!(
allowed.contains(&severity),
"detector JSON emitted noncanonical severity {severity:?}: {detector}"
);
}
}
#[test]
fn detectors_format_json_is_canonical_and_json_alias_is_retired() {
let retired = Command::new(binary())
.args(["detectors", "--json"])
.output()
.expect("spawn retired detector json flag");
let canonical = Command::new(binary())
.args(["detectors", "--format", "json"])
.output()
.expect("spawn keyhog detectors --format json");
assert_eq!(
retired.status.code(),
Some(2),
"retired detectors --json must exit 2; stderr={}",
String::from_utf8_lossy(&retired.stderr)
);
assert_eq!(
canonical.status.code(),
Some(0),
"detectors --format json should exit 0; stderr={}",
String::from_utf8_lossy(&canonical.stderr)
);
assert!(String::from_utf8_lossy(&retired.stderr).contains("unexpected argument '--json'"));
let parsed: serde_json::Value = serde_json::from_slice(&canonical.stdout)
.expect("detectors --format json stdout is valid JSON");
assert!(
parsed.as_array().is_some_and(|items| items.len() > 100),
"detectors --format json must emit the detector array, got {parsed}"
);
}
#[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("--daemon=off")
.arg("--backend")
.arg(FUNCTIONAL_E2E_BACKEND)
.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("--daemon=off")
.arg("--backend")
.arg(FUNCTIONAL_E2E_BACKEND)
.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 no_suppress_test_fixtures_surfaces_test_path_findings() {
let fixture = "DATABASE_URL=postgres://admin:S3cr3tP4ssw0rd@db.example.com:5432/prod\n";
let dir = TempDir::new().expect("tempdir");
let fixture_dir = dir.path().join("tests").join("fixtures");
std::fs::create_dir_all(&fixture_dir).expect("create fixture dir");
let path = fixture_dir.join("planted.env");
std::fs::write(&path, fixture).expect("write fixture");
let default_out = Command::new(binary())
.arg("scan")
.arg("--daemon=off")
.arg("--backend")
.arg(FUNCTIONAL_E2E_BACKEND)
.arg("--format")
.arg("json")
.arg("--min-confidence")
.arg("0.0")
.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");
assert_eq!(
default_findings.as_array().map(Vec::len),
Some(0),
"default mode should suppress low-confidence test-path findings; got {default_json}"
);
let optout_out = Command::new(binary())
.arg("scan")
.arg("--daemon=off")
.arg("--backend")
.arg(FUNCTIONAL_E2E_BACKEND)
.arg("--no-suppress-test-fixtures")
.arg("--format")
.arg("json")
.arg("--min-confidence")
.arg("0.0")
.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 surfaced = optout_arr.iter().find(|f| {
f.get("detector_id").and_then(|v| v.as_str()) == Some("generic-password")
&& f.pointer("/location/line").and_then(|v| v.as_u64()) == Some(1)
&& f.pointer("/location/file_path")
.and_then(|v| v.as_str())
.is_some_and(|p| p.contains("/tests/fixtures/"))
});
assert!(
surfaced.is_some(),
"--no-suppress-test-fixtures must surface test-path findings; got {optout_json}"
);
let confidence = surfaced
.and_then(|f| f.get("confidence"))
.and_then(|v| v.as_f64())
.unwrap_or_default();
assert!(
confidence >= 0.69,
"fixture opt-out must bypass pre-ML test-path down-weighting; got {confidence}"
);
}
#[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("--daemon=off")
.arg("--backend")
.arg(FUNCTIONAL_E2E_BACKEND)
.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("--daemon=off")
.arg("--backend")
.arg(FUNCTIONAL_E2E_BACKEND)
.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", "ROTATIONNEEDED77");
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("--daemon=off")
.arg("--backend")
.arg(FUNCTIONAL_E2E_BACKEND)
.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("--daemon=off")
.arg("--backend")
.arg(FUNCTIONAL_E2E_BACKEND)
.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",
"--daemon=off",
"--backend",
FUNCTIONAL_E2E_BACKEND,
"--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",
"--daemon=off",
"--backend",
FUNCTIONAL_E2E_BACKEND,
"--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",
"--daemon=off",
"--backend",
FUNCTIONAL_E2E_BACKEND,
"--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",
"--daemon=off",
"--backend",
FUNCTIONAL_E2E_BACKEND,
"--lockdown",
"--verify",
"--format",
"json",
])
.arg(&fixture);
let output = match cmd.output() {
Ok(out) => out,
Err(_) => Command::new(binary())
.args([
"scan",
"--daemon=off",
"--backend",
FUNCTIONAL_E2E_BACKEND,
"--lockdown",
"--verify",
"--format",
"json",
])
.arg(&fixture)
.output()
.expect("lockdown+verify scan"),
};
assert_eq!(
output.status.code(),
Some(2),
"lockdown+verify must exit 2 (user 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)]
fn start_daemon() -> (TempDir, std::process::Child) {
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
let runtime = TempDir::new().expect("runtime dir");
let detectors = workspace_detectors();
let daemon = Command::new(binary())
.env("XDG_RUNTIME_DIR", runtime.path())
.args([
"daemon",
"start",
"--backend",
FUNCTIONAL_E2E_BACKEND,
"--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));
}
(runtime, daemon)
}
#[cfg(unix)]
fn stop_daemon(runtime: &TempDir, daemon: &mut std::process::Child) {
use std::process::Command;
let _ = Command::new(binary())
.env("XDG_RUNTIME_DIR", runtime.path())
.args(["daemon", "stop"])
.output();
let _ = daemon.kill();
let _ = daemon.wait();
}
#[cfg(unix)]
#[test]
fn daemon_wire_scan_path_finds_planted_secret() {
use std::process::Command;
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 (runtime, mut daemon) = start_daemon();
let scan = Command::new(binary())
.env("XDG_RUNTIME_DIR", runtime.path())
.args(["scan", "--daemon", "--format", "json"])
.arg(&fixture)
.output()
.expect("daemon scan");
stop_daemon(&runtime, &mut daemon);
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")
)),
"daemon wire path must return an AWS finding; got {arr:?}"
);
}
#[cfg(unix)]
#[test]
fn daemon_wire_scan_stdin_finds_planted_secret() {
use std::io::Write;
use std::process::{Command, Stdio};
let (runtime, mut daemon) = start_daemon();
let fixture = concat!("AWS_ACCESS_KEY_ID = \"AKIA", "QYLPMN5HFIQR7XYA\"\n");
let mut child = Command::new(binary())
.env("XDG_RUNTIME_DIR", runtime.path())
.args(["scan", "--daemon", "--stdin", "--format", "json"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn daemon stdin scan");
child
.stdin
.take()
.expect("child stdin")
.write_all(fixture.as_bytes())
.expect("pipe fixture to stdin");
let scan = child.wait_with_output().expect("daemon stdin scan output");
stop_daemon(&runtime, &mut daemon);
assert_eq!(
scan.status.code(),
Some(1),
"daemon --stdin 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 stdin stdout is JSON");
let arr = findings.as_array().expect("array");
assert_eq!(
arr.len(),
1,
"daemon ScanText/stdin must resolve the planted AWS key to one finding; got {arr:?}"
);
assert!(
matches!(
arr[0].get("detector_id").and_then(|v| v.as_str()),
Some("aws-access-key")
),
"daemon ScanText/stdin wire path must return the named AWS finding, not a generic entropy duplicate; got {arr:?}"
);
}
#[cfg(unix)]
#[test]
fn daemon_wire_stdin_example_suppression_summary_propagates() {
use std::io::Write;
use std::process::{Command, Stdio};
let (runtime, mut daemon) = start_daemon();
let fixture = "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE\n";
let mut child = Command::new(binary())
.env("XDG_RUNTIME_DIR", runtime.path())
.args(["scan", "--daemon", "--stdin", "--format", "text"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn daemon stdin example scan");
child
.stdin
.take()
.expect("child stdin")
.write_all(fixture.as_bytes())
.expect("pipe example fixture to stdin");
let scan = child
.wait_with_output()
.expect("daemon stdin example scan output");
stop_daemon(&runtime, &mut daemon);
let stdout = String::from_utf8_lossy(&scan.stdout);
assert!(
stdout.contains("example/test key") && stdout.contains("suppressed"),
"engine_example_suppressions must propagate over the real daemon \
socket so the daemon client distinguishes suppressed-example from a \
clean repo. Got stdout: {stdout}"
);
assert!(
!stdout.contains("Your code is clean."),
"the clean-repo summary must NOT fire when the daemon suppressed an \
example credential and reported a non-zero daemon count. \
Got stdout: {stdout}"
);
}
#[cfg(unix)]
#[test]
fn daemon_status_reports_payload_after_live_scan() {
use std::process::Command;
let dir = TempDir::new().expect("fixture dir");
let fixture = dir.path().join("daemon_status_planted.txt");
std::fs::write(
&fixture,
concat!("AWS_ACCESS_KEY_ID = \"AKIA", "QYLPMN5HFIQR7XYA\"\n"),
)
.unwrap();
let (runtime, mut daemon) = start_daemon();
let scan = Command::new(binary())
.env("XDG_RUNTIME_DIR", runtime.path())
.args(["scan", "--daemon", "--format", "json"])
.arg(&fixture)
.output()
.expect("daemon scan before status");
assert_eq!(
scan.status.code(),
Some(1),
"pre-status daemon scan must find the planted key; stderr={}",
String::from_utf8_lossy(&scan.stderr)
);
let status = Command::new(binary())
.env("XDG_RUNTIME_DIR", runtime.path())
.args(["daemon", "status"])
.output()
.expect("daemon status");
stop_daemon(&runtime, &mut daemon);
assert_eq!(
status.status.code(),
Some(0),
"`daemon status` against a live daemon must exit 0; stderr={}",
String::from_utf8_lossy(&status.stderr)
);
let out = String::from_utf8_lossy(&status.stdout);
assert!(
out.contains("scans served"),
"status payload must report scans-served; got: {out}"
);
assert!(
out.contains("detectors"),
"status payload must report the detector count; got: {out}"
);
assert!(
!out.contains("0 scans served"),
"status must report the scan we issued (non-zero scans-served); got: {out}"
);
}
#[test]
fn doctor_reports_corpus_and_passes_scan_self_test() {
let output = Command::new(binary())
.arg("doctor")
.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}"
);
assert!(
stdout.contains("autoroute") && stdout.contains("calibration"),
"doctor must report autoroute calibration coverage; 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"] {
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"] {
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",
"--daemon=off",
"--backend",
FUNCTIONAL_E2E_BACKEND,
"--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.aws-access-key]\nenabled = false\n[detector.entropy-api-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_detector_disable_all_loaded_detectors_fails_closed() {
let dir = TempDir::new().expect("tempdir");
let detectors_dir = dir.path().join("detectors");
std::fs::create_dir_all(&detectors_dir).expect("mkdir detectors");
std::fs::write(
detectors_dir.join("demo-only.toml"),
r#"
[detector]
id = "demo-only"
name = "Demo Only"
service = "demo"
severity = "high"
ml = { match_mode = "disabled", entropy_mode = "disabled", weight = 0.0, context_radius_lines = 0 }
keywords = ["demo_secret_"]
[[detector.patterns]]
regex = "demo_secret_[A-Z0-9]{8}"
"#,
)
.expect("write detector");
std::fs::write(
dir.path().join("planted.txt"),
"token = demo_secret_ABCD1234\n",
)
.expect("write fixture");
std::fs::write(
dir.path().join(".keyhog.toml"),
"[detector.demo-only]\nenabled = false\n",
)
.expect("write config");
let output = Command::new(binary())
.args([
"scan",
"--daemon=off",
"--backend",
FUNCTIONAL_E2E_BACKEND,
"--format",
"json",
"--detectors",
])
.arg(&detectors_dir)
.arg(dir.path())
.output()
.expect("spawn keyhog scan");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(
output.status.code(),
Some(2),
"disabling the entire loaded detector corpus must be a user-visible scan error, not a clean no-findings scan.\n--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}"
);
assert!(
stderr.contains("all 1 loaded detector(s) were disabled")
&& stderr.contains("demo-only")
&& stderr.contains("Refusing to scan with no detectors loaded"),
"stderr must explain the zero-detector corpus and the disabled id.\n--- stderr ---\n{stderr}"
);
assert!(
!stdout.contains("demo_secret_ABCD1234"),
"failed setup must not emit a misleading finding payload after refusing the empty detector corpus"
);
}
#[test]
fn config_detector_min_confidence_floor_drops_findings() {
let dir = TempDir::new().expect("tempdir");
let detectors_dir = dir.path().join("detectors");
std::fs::create_dir_all(&detectors_dir).expect("mkdir detectors");
std::fs::write(
detectors_dir.join("demo-only.toml"),
r#"
[detector]
id = "demo-only"
name = "Demo Only"
service = "demo"
severity = "high"
ml = { match_mode = "disabled", entropy_mode = "disabled", weight = 0.0, context_radius_lines = 0 }
keywords = ["demo_secret_"]
[[detector.patterns]]
regex = "demo_secret_[A-Z0-9]{8}"
"#,
)
.expect("write detector");
std::fs::write(
dir.path().join("planted.txt"),
"token = demo_secret_ABCD1234\n",
)
.expect("write fixture");
let run = |config: &str| {
std::fs::write(dir.path().join(".keyhog.toml"), config).expect("write config");
let output = Command::new(binary())
.args([
"scan",
"--daemon=off",
"--backend",
FUNCTIONAL_E2E_BACKEND,
"--format",
"json",
"--detectors",
])
.arg(&detectors_dir)
.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(),
)
};
let (out_base, _e, before) = run("");
assert_eq!(
before,
Some(1),
"baseline finding must fire; stdout={out_base}"
);
assert!(
out_base.contains("\"confidence\":0.5"),
"fixture must stay below the high floor so this test proves filtering; stdout={out_base}"
);
let (out_hi, _e, code_hi) = run("[detector.demo-only]\nmin_confidence = 0.6\n");
assert_eq!(
code_hi,
Some(0),
"a per-detector min_confidence floor above the finding confidence must suppress it; stdout={out_hi}"
);
let (_out_lo, _e, code_lo) = run("[detector.demo-only]\nmin_confidence = 0.4\n");
assert_eq!(
code_lo,
Some(1),
"a per-detector min_confidence floor below the finding confidence must keep it"
);
std::fs::write(
detectors_dir.join("demo-only.toml"),
r#"
[detector]
id = "demo-only"
name = "Demo Only"
service = "demo"
severity = "high"
ml = { match_mode = "disabled", entropy_mode = "disabled", weight = 0.0, context_radius_lines = 0 }
min_confidence = 0.8
keywords = ["demo_secret_"]
[[detector.patterns]]
regex = "demo_secret_[A-Z0-9]{8}"
"#,
)
.expect("rewrite detector with a self-declared floor");
let (out_self, err_self, code_self) = run("");
assert_eq!(
code_self,
Some(0),
"the detector's own 0.8 floor must suppress its 0.5 finding; stdout={out_self}\nstderr={err_self}"
);
let (out_lowered, err_lowered, code_lowered) =
run("[detector.demo-only]\nmin_confidence = 0.4\n");
assert_eq!(
code_lowered,
Some(1),
"an operator floor below the detector default must preserve the 0.5 finding before engine adjudication; stdout={out_lowered}\nstderr={err_lowered}"
);
assert!(
out_lowered.contains("\"detector_id\":\"demo-only\""),
"the lowered-floor run must emit a finding attributed to the custom detector; stdout={out_lowered}"
);
for invalid in ["5.0", "-1.0", "nan", "inf"] {
let config = format!("[detector.demo-only]\nmin_confidence = {invalid}\n");
let (stdout, stderr, code) = run(&config);
assert_eq!(
code,
Some(2),
"invalid per-detector floor {invalid} must fail closed; stdout={stdout}\nstderr={stderr}"
);
assert!(
stderr.contains("min_confidence must be between 0.0 and 1.0"),
"invalid per-detector floor must state the accepted range; stderr={stderr}"
);
}
}
#[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}"
);
}
#[test]
fn precision_mode_keeps_strong_drops_weak() {
let fixture = concat!(
"aws_secret_access_key = \"kP8xQ2mNvR7tZ4wL9bYsH3jD6fG1cA0eXuViK5oT\"\n",
"DATABASE_PASSWORD = \"admin123\"\n",
);
let (def_out, _e, _c) = scan_text_file(fixture, &[]);
let (prec_out, _e2, _c2) = scan_text_file(fixture, &["--precision"]);
let def: Vec<String> = parse_json_array(&def_out, "default precision-mode scan")
.iter()
.filter_map(|finding| {
finding
.get("detector_id")
.and_then(|value| value.as_str())
.map(String::from)
})
.collect();
let prec: Vec<String> = parse_json_array(&prec_out, "explicit precision-mode scan")
.iter()
.filter_map(|finding| {
finding
.get("detector_id")
.and_then(|value| value.as_str())
.map(String::from)
})
.collect();
assert!(
def.len() >= 2,
"default mode should surface both the weak generic secret and the AWS secret; got {def:?}"
);
assert!(
def.iter().any(|d| d == "aws-secret-access-key"),
"default must find the secret key; got {def:?}"
);
assert!(
def.iter().any(|d| d == "generic-secret"),
"default must find the weak generic secret; got {def:?}"
);
assert!(
prec.iter().any(|d| d == "aws-secret-access-key"),
"precision must KEEP the high-confidence secret key; got {prec:?}"
);
assert!(
prec.len() < def.len(),
"precision must be strictly tighter than default; default={def:?} precision={prec:?}"
);
assert!(
!prec.iter().any(|d| d == "generic-secret"),
"precision must drop the weaker generic-secret finding (below the 0.85 bar); got {prec:?}"
);
}
#[test]
fn precision_mode_conflicts_with_fast() {
let (_o, err, code) = scan_text_file("ordinary content\n", &["--precision", "--fast"]);
assert_eq!(
code,
Some(2),
"clap usage error (exit 2) expected for conflicting --precision --fast; got {code:?}"
);
assert!(
err.contains("cannot be used with") || err.to_lowercase().contains("precision"),
"the usage error must name the conflict; stderr={err}"
);
}