use std::borrow::Cow;
use std::collections::HashMap;
use keyhog_core::testing::{CoreTestApi, TestApi};
use keyhog_core::{
hex_encode, sha256_hash, CredentialHash, MatchLocation, Severity, VerificationResult,
VerifiedFinding,
};
const PAST: &str = "1970-01-01";
const FUTURE: &str = "9999-12-31";
fn zero_hex() -> String {
"0".repeat(64)
}
fn finding(
detector: &str,
file_path: Option<&str>,
credential_hash: CredentialHash,
) -> VerifiedFinding {
VerifiedFinding {
detector_id: detector.into(),
detector_name: "n".into(),
service: "s".into(),
severity: Severity::default(),
credential_redacted: Cow::Borrowed("****"),
credential_hash,
companions_redacted: std::collections::HashMap::new(),
location: MatchLocation {
source: "filesystem".into(),
file_path: file_path.map(|p| p.into()),
line: Some(1),
offset: 0,
commit: None,
author: None,
date: None,
},
verification: VerificationResult::Skipped,
metadata: HashMap::new(),
additional_locations: Vec::new(),
entropy: None,
confidence: None,
}
}
#[test]
fn allowlisted_path_value_is_suppressed() {
let al = TestApi.allowlist_parse("path:secrets/*.env\n");
assert_eq!(al.ignored_paths, vec!["secrets/*.env".to_string()]);
assert_eq!(al.is_path_ignored("secrets/prod.env"), true);
}
#[test]
fn non_listed_path_passes_through() {
let al = TestApi.allowlist_parse("path:secrets/*.env\n");
assert_eq!(al.is_path_ignored("config/app.yaml"), false);
assert_eq!(al.is_path_ignored("secrets/prod.txt"), false);
}
#[test]
fn clearing_allowlist_set_invalidates_cache() {
let mut al = TestApi.allowlist_parse("path:secrets/*.env\n");
assert_eq!(al.is_path_ignored("secrets/prod.env"), true);
al.ignored_paths.clear();
assert_eq!(al.ignored_paths.len(), 0);
assert_eq!(al.is_path_ignored("secrets/prod.env"), false);
}
#[test]
fn in_place_same_length_replacement_invalidates_cache() {
let mut al = TestApi.allowlist_parse("path:secrets/*.env\n");
assert_eq!(al.is_path_ignored("secrets/prod.env"), true);
assert_eq!(al.is_path_ignored("logs/app.txt"), false);
assert_eq!(al.ignored_paths.len(), 1);
al.ignored_paths[0] = "logs/*.txt".to_string();
assert_eq!(al.ignored_paths.len(), 1);
assert_eq!(al.is_path_ignored("secrets/prod.env"), false);
assert_eq!(al.is_path_ignored("logs/app.txt"), true);
}
#[test]
fn pushing_new_pattern_invalidates_cache() {
let mut al = TestApi.allowlist_parse("path:secrets/*.env\n");
assert_eq!(al.is_path_ignored("vault/keys.json"), false);
al.ignored_paths.push("vault/**".to_string());
assert_eq!(al.ignored_paths.len(), 2);
assert_eq!(al.is_path_ignored("vault/keys.json"), true);
assert_eq!(al.is_path_ignored("secrets/prod.env"), true);
}
#[test]
fn expired_path_entry_is_not_in_the_compiled_index() {
let content =
format!("path:stale/*.env ; expires={PAST}\npath:live/*.key ; expires={FUTURE}\n");
let al = TestApi.allowlist_parse(&content);
assert_eq!(al.ignored_paths, vec!["live/*.key".to_string()]);
assert_eq!(al.is_path_ignored("stale/prod.env"), false);
assert_eq!(al.is_path_ignored("live/prod.key"), true);
}
#[test]
fn allowlisted_credential_hash_value_is_suppressed() {
let credential = "AKIAIOSFODNN7EXAMPLE";
let hex = hex_encode(sha256_hash(credential));
let content = format!("hash:{hex}\n");
let al = TestApi.allowlist_parse(&content);
assert_eq!(al.credential_hashes.len(), 1);
assert_eq!(TestApi.allowlist_is_hash_allowed(&al, &hex), true);
assert_eq!(TestApi.allowlist_is_raw_hash_ignored(&al, &hex), true);
}
#[test]
fn non_listed_credential_hash_passes_through() {
let hex = hex_encode(sha256_hash("AKIAIOSFODNN7EXAMPLE"));
let al = TestApi.allowlist_parse(&format!("hash:{hex}\n"));
let other = zero_hex();
assert_eq!(TestApi.allowlist_is_hash_allowed(&al, &other), false);
assert_eq!(TestApi.allowlist_is_raw_hash_ignored(&al, &other), false);
}
#[test]
fn changing_hash_set_changes_decision() {
let listed_hex = "a".repeat(64);
let mut al = TestApi.allowlist_parse(&format!("hash:{listed_hex}\n"));
let zeros = zero_hex();
assert_eq!(TestApi.allowlist_is_hash_allowed(&al, &zeros), false);
al.credential_hashes.insert(CredentialHash::from([0u8; 32]));
assert_eq!(al.credential_hashes.len(), 2);
assert_eq!(TestApi.allowlist_is_hash_allowed(&al, &zeros), true);
assert_eq!(TestApi.allowlist_is_hash_allowed(&al, &listed_hex), true);
}
#[test]
fn expired_hash_entry_no_longer_suppresses() {
let hex = zero_hex();
let al = TestApi.allowlist_parse(&format!("hash:{hex} ; expires={PAST}\n"));
assert_eq!(al.credential_hashes.len(), 0);
assert_eq!(TestApi.allowlist_is_hash_allowed(&al, &hex), false);
}
#[test]
fn allowlisted_detector_suppresses_and_removal_flips() {
let mut al = TestApi.allowlist_parse("detector:leaked-aws\n");
assert_eq!(al.ignored_detectors.contains("leaked-aws"), true);
assert_eq!(al.ignored_detectors.contains("other-detector"), false);
al.ignored_detectors.remove("leaked-aws");
assert_eq!(al.ignored_detectors.contains("leaked-aws"), false);
assert_eq!(al.ignored_detectors.len(), 0);
}
#[test]
fn is_allowed_finding_suppressed_by_hash_then_flips_when_set_cleared() {
let hex = zero_hex();
let mut al = TestApi.allowlist_parse(&format!("hash:{hex}\n"));
let vf = finding("det-x", Some("notes.txt"), CredentialHash::from([0u8; 32]));
assert_eq!(TestApi.allowlist_is_allowed(&al, &vf), true);
al.credential_hashes.clear();
assert_eq!(TestApi.allowlist_is_allowed(&al, &vf), false);
}
#[test]
fn is_allowed_finding_suppressed_by_path_and_offlist_finding_passes() {
let al = TestApi.allowlist_parse("path:vault/**\n");
let inside = finding(
"det-y",
Some("vault/keys.json"),
CredentialHash::from([7u8; 32]),
);
assert_eq!(TestApi.allowlist_is_allowed(&al, &inside), true);
let outside = finding(
"det-y",
Some("src/main.rs"),
CredentialHash::from([7u8; 32]),
);
assert_eq!(TestApi.allowlist_is_allowed(&al, &outside), false);
}
#[test]
fn directory_glob_matches_nested_but_not_sibling_prefix() {
let al = TestApi.allowlist_parse("path:node_modules/\n");
assert_eq!(al.is_path_ignored("node_modules/pkg/index.js"), true);
assert_eq!(al.is_path_ignored("node_modules_notreal.js"), false);
}