use keyhog_core::{Allowlist, MatchLocation, Severity, VerificationResult, VerifiedFinding};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tracing_subscriber::fmt::MakeWriter;
#[derive(Clone, Default)]
struct SharedWriter(Arc<Mutex<Vec<u8>>>);
struct GuardedWriter(Arc<Mutex<Vec<u8>>>);
impl std::io::Write for GuardedWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> MakeWriter<'a> for SharedWriter {
type Writer = GuardedWriter;
fn make_writer(&'a self) -> Self::Writer {
GuardedWriter(self.0.clone())
}
}
fn capture_logs<F: FnOnce()>(f: F) -> String {
let writer = SharedWriter::default();
let subscriber = tracing_subscriber::fmt()
.with_ansi(false)
.with_writer(writer.clone())
.without_time()
.finish();
tracing::subscriber::with_default(subscriber, f);
let bytes = writer.0.lock().unwrap().clone();
String::from_utf8(bytes).unwrap()
}
#[test]
fn parse_allowlist() {
let content = "
# Known false positives
hash:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
detector:entropy
path:tests/**
path:*.example
";
let al = Allowlist::parse(content);
assert_eq!(al.credential_hashes.len(), 1);
assert!(al.ignored_detectors.contains("entropy"));
assert_eq!(al.ignored_paths.len(), 2);
}
#[test]
fn empty_allowlist_allows_nothing() {
let al = Allowlist::empty();
assert!(!al.is_hash_allowed("anything"));
}
#[test]
fn normalized_paths_still_match_globs() {
let mut al = Allowlist::empty();
al.ignored_paths.push("tests/**".into());
assert!(al.is_path_ignored("./tests/fixtures/../fixtures/config.env"));
}
#[test]
fn is_allowed_checks_detector_and_path_rules_consistently() {
let mut al = Allowlist::empty();
al.ignored_detectors.insert("aws".into());
al.ignored_paths.push("tests/**".into());
let finding = VerifiedFinding {
detector_id: "aws".into(),
detector_name: "AWS".into(),
service: "aws".into(),
severity: Severity::High,
credential_redacted: "***".into(),
credential_hash: "".into(),
location: MatchLocation {
source: "filesystem".into(),
file_path: Some("src/main.rs".into()),
line: Some(1),
offset: 0,
commit: None,
author: None,
date: None,
},
verification: VerificationResult::Unverifiable,
metadata: HashMap::new(),
additional_locations: Vec::new(),
confidence: None,
};
assert!(al.is_allowed(&finding));
let finding = VerifiedFinding {
detector_id: "other".into(),
location: MatchLocation {
source: "filesystem".into(),
file_path: Some("tests/fixture.env".into()),
line: Some(1),
offset: 0,
commit: None,
author: None,
date: None,
},
..finding
};
assert!(al.is_allowed(&finding));
}
#[test]
fn gitleaks_format_parse_compatibility() {
let content = "hash:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef12345678\ndetector:aws-access-key\npath:**/*.test\n";
let al = Allowlist::parse(content);
assert_eq!(al.credential_hashes.len(), 1);
assert!(al.ignored_detectors.contains("aws-access-key"));
assert_eq!(al.ignored_paths.len(), 1);
}
#[test]
fn gitleaks_hash_suppression_behavior() {
let entry_hash = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
let other_hash = "0000000000000000000000000000000000000000000000000000000000000000";
let content = format!("hash:{entry_hash}");
let al = Allowlist::parse(&content);
assert!(al.is_hash_allowed(entry_hash));
assert!(!al.is_hash_allowed(other_hash));
}
#[test]
fn gitleaks_detector_ignore_by_id() {
let content = "detector:generic-api-key";
let al = Allowlist::parse(content);
let finding = VerifiedFinding {
detector_id: "generic-api-key".into(),
detector_name: "Generic API Key".into(),
service: "generic".into(),
severity: Severity::High,
credential_redacted: "***".into(),
credential_hash: "".into(),
location: MatchLocation {
source: "filesystem".into(),
file_path: Some("any/path/file.rs".into()),
line: Some(1),
offset: 0,
commit: None,
author: None,
date: None,
},
verification: VerificationResult::Unverifiable,
metadata: HashMap::new(),
additional_locations: Vec::new(),
confidence: None,
};
assert!(al.is_allowed(&finding));
let other_finding = VerifiedFinding {
detector_id: "different-detector".into(),
..finding
};
assert!(!al.is_allowed(&other_finding));
}
#[test]
fn gitleaks_comment_lines_ignored() {
let content = "
# This is a comment
hash:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
# Another comment
detector:test
";
let al = Allowlist::parse(content);
assert_eq!(al.credential_hashes.len(), 1);
assert!(al.ignored_detectors.contains("test"));
}
#[test]
fn gitleaks_malformed_lines_warning_not_crash() {
let logs = capture_logs(|| {
let content = "
hash:invalid_hash
not_a_valid_line
random_text_here
detector:
hash:
path:
hash:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
";
let al = Allowlist::parse(content);
assert_eq!(al.credential_hashes.len(), 1);
});
assert!(logs.contains("invalid allowlist entry"));
assert!(logs.contains("invalid hash allowlist entry"));
}
#[test]
fn gitleaks_windows_backslash_normalized() {
let mut al = Allowlist::empty();
al.ignored_paths.push("tests/**".into());
assert!(al.is_path_ignored("tests\\fixtures\\config.env"));
assert!(al.is_path_ignored(".\\tests\\fixtures\\test.txt"));
assert!(!al.is_path_ignored("src\\main.rs"));
}
#[test]
fn gitleaks_hash_case_insensitive() {
let lower = "hash:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
let upper = "hash:9F86D081884C7D659A2FEAA0C55AD015A3BF4F1B2B0B822CD15D6C15B0F00A08";
let mixed = "hash:9F86D081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
let al_lower = Allowlist::parse(lower);
let al_upper = Allowlist::parse(upper);
let al_mixed = Allowlist::parse(mixed);
let lookup_lower = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
let lookup_upper = "9F86D081884C7D659A2FEAA0C55AD015A3BF4F1B2B0B822CD15D6C15B0F00A08";
assert!(al_lower.is_hash_allowed(lookup_lower));
assert!(al_upper.is_hash_allowed(lookup_lower));
assert!(al_mixed.is_hash_allowed(lookup_lower));
assert!(al_lower.is_hash_allowed(lookup_upper));
}
#[test]
fn raw_hash_lookup_accepts_hex_hashes_directly() {
let content = "hash:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
let al = Allowlist::parse(content);
assert!(al.is_hash_allowed("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"));
assert!(
al.is_raw_hash_ignored("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")
);
}