use std::path::PathBuf;
use std::process::Command;
use tempfile::TempDir;
const PLANTED: &str = "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";
const DETECTOR_ID: &str = "github-classic-pat";
const DETECTOR_NAME: &str = "GitHub Classic PAT";
const SERVICE: &str = "github";
const SEVERITY: &str = "critical";
const REDACTED: &str = "ghp_...DSiF";
const CRED_HASH: &str = "7b85310a29300230c865bc48ca1836f15b81bd50ac85e8c0785e8145e98ff175";
const REQUIRED_KEYS: [&str; 12] = [
"detector_id",
"detector_name",
"service",
"severity",
"credential_redacted",
"credential_hash",
"companions_redacted",
"location",
"verification",
"metadata",
"additional_locations",
"remediation",
];
const OPTIONAL_KEYS: [&str; 2] = ["confidence", "entropy"];
fn binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_keyhog"))
}
fn leak_fixture() -> (TempDir, PathBuf) {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("dump.txt");
std::fs::write(&path, format!("{PLANTED}\n")).expect("write leak fixture");
(dir, path)
}
fn clean_fixture() -> (TempDir, PathBuf) {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("notes.txt");
std::fs::write(
&path,
"just ordinary prose with plain everyday words here\n",
)
.expect("write clean fixture");
(dir, path)
}
fn run_json(path: &PathBuf) -> (Option<i32>, String, String) {
let output = Command::new(binary())
.args([
"scan",
"--daemon=off",
"--backend",
"cpu",
"--no-suppress-test-fixtures",
"--format",
"json-envelope",
])
.arg(path)
.output()
.expect("spawn keyhog scan");
(
output.status.code(),
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
)
}
fn findings_array(out: &str) -> Vec<serde_json::Value> {
let value: serde_json::Value = serde_json::from_str(out).expect("json stdout must parse");
assert_eq!(value["schema_version"]["major"], 1);
value["findings"]
.as_array()
.expect("findings must be an array")
.clone()
}
fn single_finding(out: &str) -> serde_json::Value {
let arr = findings_array(out);
assert_eq!(arr.len(), 1, "exactly one secret planted -> one element");
arr[0].clone()
}
#[test]
fn top_level_is_json_array_of_one_and_exits_one() {
let (_dir, path) = leak_fixture();
let (code, out, err) = run_json(&path);
assert_eq!(code, Some(1), "a finding must exit 1; stderr={err}");
let v: serde_json::Value = serde_json::from_str(&out).expect("json must parse");
let arr = findings_array(&out);
assert_eq!(arr.len(), 1, "one planted secret -> one array element");
assert!(
v["metadata"].is_object(),
"CLI envelope carries scan metadata"
);
}
#[test]
fn finding_object_has_exact_top_level_key_set() {
let (_dir, path) = leak_fixture();
let (_c, out, _e) = run_json(&path);
let obj = single_finding(&out);
let map = obj.as_object().expect("finding must be a json object");
for key in REQUIRED_KEYS {
assert!(
map.contains_key(key),
"finding object missing required key `{key}`; keys present: {:?}",
map.keys().collect::<Vec<_>>()
);
}
for key in map.keys() {
let allowed =
REQUIRED_KEYS.contains(&key.as_str()) || OPTIONAL_KEYS.contains(&key.as_str());
assert!(
allowed,
"unexpected top-level key `{key}` in finding object"
);
}
}
#[test]
fn detector_identity_fields_exact() {
let (_dir, path) = leak_fixture();
let (_c, out, _e) = run_json(&path);
let obj = single_finding(&out);
assert_eq!(
obj.get("detector_id").and_then(|x| x.as_str()),
Some(DETECTOR_ID),
"detector_id"
);
assert_eq!(
obj.get("detector_name").and_then(|x| x.as_str()),
Some(DETECTOR_NAME),
"detector_name"
);
assert_eq!(
obj.get("service").and_then(|x| x.as_str()),
Some(SERVICE),
"service"
);
}
#[test]
fn severity_field_is_kebab_critical_string() {
let (_dir, path) = leak_fixture();
let (_c, out, _e) = run_json(&path);
let obj = single_finding(&out);
let sev = obj.get("severity").expect("severity key present");
assert!(
sev.is_string(),
"severity must be a JSON string, got {sev:?}"
);
assert_eq!(sev.as_str(), Some(SEVERITY), "severity must be `critical`");
}
#[test]
fn credential_redacted_is_masked_form() {
let (_dir, path) = leak_fixture();
let (_c, out, _e) = run_json(&path);
let obj = single_finding(&out);
let redacted = obj
.get("credential_redacted")
.and_then(|x| x.as_str())
.expect("credential_redacted must be a string");
assert_eq!(redacted, REDACTED, "exact masked form");
assert_ne!(redacted, PLANTED, "must not be the raw token");
}
#[test]
fn credential_hash_is_deterministic_sha256_hex() {
let (_dir, path) = leak_fixture();
let (_c, out, _e) = run_json(&path);
let obj = single_finding(&out);
let hash = obj
.get("credential_hash")
.and_then(|x| x.as_str())
.expect("credential_hash must be a string");
assert_eq!(hash.len(), 64, "sha256 hex is 64 chars, got {}", hash.len());
assert!(
hash.bytes()
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()),
"hash must be lowercase hex, got {hash}"
);
assert_eq!(hash, CRED_HASH, "exact sha256 of the planted token");
}
#[test]
fn raw_plaintext_token_absent_from_json_output() {
let (_dir, path) = leak_fixture();
let (_c, out, _e) = run_json(&path);
assert!(
!out.contains(PLANTED),
"the raw credential must not appear in the JSON report"
);
assert!(
!out.contains("1234567890123456789012345678902"),
"the token body must be redacted out of the JSON report"
);
}
#[test]
fn location_object_fields_exact() {
let (_dir, path) = leak_fixture();
let (_c, out, _e) = run_json(&path);
let obj = single_finding(&out);
let loc = obj
.get("location")
.and_then(|l| l.as_object())
.expect("location must be a json object");
assert_eq!(
loc.get("source").and_then(|x| x.as_str()),
Some("filesystem"),
"location.source"
);
assert_eq!(
loc.get("line").and_then(|x| x.as_u64()),
Some(1),
"location.line must be 1 (token on line 1)"
);
assert_eq!(
loc.get("offset").and_then(|x| x.as_u64()),
Some(0),
"location.offset must be 0"
);
let file_path = loc
.get("file_path")
.and_then(|x| x.as_str())
.expect("location.file_path must be a string");
assert!(
file_path.ends_with("dump.txt"),
"file_path must be the planted file, got {file_path}"
);
for git_field in ["commit", "author", "date"] {
assert!(
loc.get(git_field).map(|v| v.is_null()).unwrap_or(false),
"location.{git_field} must be null for a filesystem scan"
);
}
}
#[test]
fn verification_field_is_skipped() {
let (_dir, path) = leak_fixture();
let (_c, out, _e) = run_json(&path);
let obj = single_finding(&out);
assert_eq!(
obj.get("verification").and_then(|x| x.as_str()),
Some("skipped"),
"verification must be `skipped` (no --verify)"
);
}
#[test]
fn metadata_empty_object_and_additional_locations_empty_array() {
let (_dir, path) = leak_fixture();
let (_c, out, _e) = run_json(&path);
let obj = single_finding(&out);
let meta = obj
.get("metadata")
.and_then(|m| m.as_object())
.expect("metadata must be a json object");
assert_eq!(meta.len(), 0, "metadata must be empty, got {meta:?}");
let extra = obj
.get("additional_locations")
.and_then(|a| a.as_array())
.expect("additional_locations must be a json array");
assert_eq!(extra.len(), 0, "additional_locations must be empty");
}
#[test]
fn confidence_when_present_is_in_unit_interval() {
let (_dir, path) = leak_fixture();
let (_c, out, _e) = run_json(&path);
let obj = single_finding(&out);
if let Some(conf) = obj.get("confidence") {
let c = conf
.as_f64()
.expect("confidence must be a JSON number when present");
assert!(
c > 0.0 && c <= 1.0,
"confidence must lie in (0, 1], got {c}"
);
}
}
#[test]
fn remediation_object_carries_revoke_action() {
let (_dir, path) = leak_fixture();
let (_c, out, _e) = run_json(&path);
let obj = single_finding(&out);
let rem = obj
.get("remediation")
.and_then(|r| r.as_object())
.expect("remediation must be a json object");
let action = rem
.get("action")
.and_then(|x| x.as_str())
.expect("remediation.action must be a string");
assert!(
action.contains("Revoke"),
"remediation.action must instruct a revoke, got {action}"
);
}
#[test]
fn clean_scan_is_exactly_empty_array_exit_zero() {
let (_dir, path) = clean_fixture();
let (code, out, err) = run_json(&path);
assert_eq!(code, Some(0), "clean scan must exit 0; stderr={err}");
let v: serde_json::Value = serde_json::from_str(&out).expect("empty json parses");
assert_eq!(v["schema_version"]["major"], 1);
assert!(v["findings"].as_array().is_some_and(Vec::is_empty));
}