use keyhog_core::{
correlate_findings, correlation_composite_part_ids, detector_spec_by_id,
validate_correlation_policy, CorrelationKind, CorrelationRole, CredentialHash, MatchLocation,
Severity, VerificationResult, VerifiedFinding,
};
use std::collections::HashMap;
use std::sync::Arc;
fn sha256(value: &str) -> CredentialHash {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
CredentialHash::from_bytes(hasher.finalize().into())
}
fn finding(
detector_id: &str,
service: &str,
credential: &str,
file: &str,
confidence: f64,
) -> VerifiedFinding {
VerifiedFinding {
detector_id: Arc::from(detector_id),
detector_name: Arc::from(detector_id),
service: Arc::from(service),
severity: Severity::High,
credential_redacted: "AK...YA".into(),
credential_hash: sha256(credential),
companions_redacted: HashMap::new(),
location: MatchLocation {
source: Arc::from("filesystem"),
file_path: Some(Arc::from(file)),
line: Some(7),
offset: 0,
commit: None,
author: None,
date: None,
},
verification: VerificationResult::Unverifiable,
metadata: HashMap::new(),
additional_locations: Vec::new(),
entropy: None,
confidence: Some(confidence),
}
}
fn with_extra_location(mut finding: VerifiedFinding, file: &str) -> VerifiedFinding {
finding.additional_locations.push(MatchLocation {
source: Arc::from("filesystem"),
file_path: Some(Arc::from(file)),
line: Some(19),
offset: 0,
commit: None,
author: None,
date: None,
});
finding
}
fn assert_confidence(actual: Option<f64>, expected: f64) {
let actual = actual.expect("correlated confidence must be present");
assert!(
(actual - expected).abs() < 1e-9,
"expected confidence {expected}, got {actual}"
);
}
const VALID_POLICY: &str = r#"
[settings]
reuse_min_files = 2
reuse_confidence_bonus = 0.15
max_confidence = 0.99
reuse_impact = "reused"
[[composite]]
id = "pair"
service = "svc"
name = "Pair"
severity = "critical"
required = ["a", "b"]
confidence_bonus = 0.30
impact = "both halves"
"#;
#[test]
fn every_tier_b_composite_part_names_a_shipped_detector() {
let parts = correlation_composite_part_ids();
assert!(
parts.len() >= 10,
"the shipped policy must name real composite parts, got {parts:?}"
);
let unknown: Vec<&str> = parts
.iter()
.copied()
.filter(|id| detector_spec_by_id(id).is_none())
.collect();
assert_eq!(
unknown,
Vec::<&str>::new(),
"credential-correlation.toml names detectors that do not ship; \
those composites can never be satisfied"
);
}
#[test]
fn policy_validation_fails_closed_on_degenerate_shapes() {
validate_correlation_policy(VALID_POLICY, "<valid>").expect("baseline policy must load");
let cases = [
(
"reuse_min_files = 2",
"reuse_min_files = 1",
"reuse_min_files",
),
(
"reuse_confidence_bonus = 0.15",
"reuse_confidence_bonus = 0.0",
"reuse_confidence_bonus",
),
(
"max_confidence = 0.99",
"max_confidence = 1.5",
"max_confidence",
),
(
"required = [\"a\", \"b\"]",
"required = [\"a\"]",
"required parts",
),
(
"required = [\"a\", \"b\"]",
"required = [\"a\", \"a\"]",
"more than once",
),
("impact = \"both halves\"", "impact = \"\"", "empty impact"),
];
for (from, to, expected) in cases {
let broken = VALID_POLICY.replace(from, to);
assert_ne!(
broken, VALID_POLICY,
"test case {to:?} did not patch anything"
);
let error = validate_correlation_policy(&broken, "<broken>")
.expect_err(&format!("{to:?} must be rejected"));
assert!(
error.contains(expected),
"rejecting {to:?} must explain {expected:?}, got {error}"
);
}
let duplicated = format!(
"{VALID_POLICY}\n\
[[composite]]\n\
id = \"pair\"\n\
service = \"svc\"\n\
name = \"Pair again\"\n\
severity = \"high\"\n\
required = [\"c\", \"d\"]\n\
confidence_bonus = 0.10\n\
impact = \"shadowed row\"\n"
);
let error = validate_correlation_policy(&duplicated, "<duplicate>")
.expect_err("a duplicate composite id must be rejected");
assert!(
error.contains("duplicate id"),
"duplicate composite id must be named, got {error}"
);
}
#[test]
fn value_reuse_groups_one_secret_seen_in_several_files() {
let findings = vec![
finding("adobe-api-key", "adobe", "SHARED", "a/one.rs", 0.50),
finding(
"spotify-client-credentials",
"spotify",
"SHARED",
"b/two.rs",
0.45,
),
finding("adobe-api-key", "adobe", "OTHER", "a/three.rs", 0.90),
];
let correlations = correlate_findings(&findings);
assert_eq!(correlations.len(), 1, "only the shared value correlates");
let group = &correlations[0];
assert_eq!(group.kind, CorrelationKind::ValueReuse);
assert_eq!(group.file_count, 2);
assert_eq!(group.service, "multiple");
assert_eq!(group.strongest_member_confidence, Some(0.50));
assert_eq!(group.confidence, Some(0.65));
assert_eq!(group.members.len(), 2);
assert!(group
.members
.iter()
.all(|member| member.role == CorrelationRole::SameValue));
assert_eq!(group.scope, None);
}
#[test]
fn value_reuse_never_exceeds_the_configured_ceiling() {
let findings = vec![
finding("adobe-api-key", "adobe", "SHARED", "a/one.rs", 1.0),
finding("adobe-api-key", "adobe", "SHARED", "b/two.rs", 1.0),
];
let correlations = correlate_findings(&findings);
assert_eq!(correlations.len(), 1);
assert_eq!(correlations[0].confidence, Some(1.0));
}
#[test]
fn one_finding_spanning_files_is_reuse_but_one_file_is_not() {
let spread = with_extra_location(
finding("aws-access-key", "aws", "AKIA0", "dir/a.tf", 0.60),
"dir/b.env",
);
assert_eq!(correlate_findings(&[spread]).len(), 1);
let single = finding("aws-access-key", "aws", "AKIA0", "dir/a.tf", 0.60);
assert_eq!(correlate_findings(&[single]).len(), 0);
}
#[test]
fn composite_reports_a_split_pair_and_lifts_severity_from_tier_b() {
let findings = vec![
finding("aws-access-key", "aws", "AKIA0", "infra/main.tf", 0.60),
finding(
"aws-secret-access-key",
"aws",
"SECRET0",
"infra/.env",
0.55,
),
];
let correlations = correlate_findings(&findings);
let composites: Vec<_> = correlations
.iter()
.filter(|group| group.kind == CorrelationKind::SplitComposite)
.collect();
assert_eq!(composites.len(), 1);
let group = composites[0];
assert_eq!(group.id, "composite:aws-iam-user@infra");
assert_eq!(group.scope.as_deref(), Some("infra"));
assert_eq!(group.service, "aws");
assert_eq!(group.file_count, 2);
assert_eq!(group.severity, Severity::Critical);
assert_eq!(group.strongest_member_confidence, Some(0.60));
assert_confidence(group.confidence, 0.90);
assert_eq!(group.members.len(), 2);
assert!(group
.members
.iter()
.all(|member| member.role == CorrelationRole::RequiredPart));
assert_eq!(group.locations.len(), 2);
}
#[test]
fn composite_is_silent_when_one_file_already_holds_both_parts() {
let findings = vec![
finding("aws-access-key", "aws", "AKIA0", "infra/all.env", 0.60),
finding(
"aws-secret-access-key",
"aws",
"SECRET0",
"infra/all.env",
0.55,
),
];
let composites = correlate_findings(&findings)
.into_iter()
.filter(|group| group.kind == CorrelationKind::SplitComposite)
.count();
assert_eq!(
composites, 0,
"a same-file pair is already covered by the detector's companion regex"
);
}
#[test]
fn composite_is_silent_when_the_directory_pairing_is_ambiguous() {
let findings = vec![
finding("aws-access-key", "aws", "AKIA0", "infra/a.tf", 0.60),
finding("aws-access-key", "aws", "AKIA1", "infra/b.tf", 0.60),
finding(
"aws-secret-access-key",
"aws",
"SECRET0",
"infra/.env",
0.55,
),
];
let composites = correlate_findings(&findings)
.into_iter()
.filter(|group| group.kind == CorrelationKind::SplitComposite)
.count();
assert_eq!(
composites, 0,
"two candidate access keys make the pairing a guess, so nothing is claimed"
);
}
#[test]
fn composite_admits_an_optional_part_without_requiring_it() {
let findings = vec![
finding("aws-access-key", "aws", "AKIA0", "infra/main.tf", 0.60),
finding(
"aws-secret-access-key",
"aws",
"SECRET0",
"infra/.env",
0.55,
),
finding(
"aws-session-token",
"aws",
"TOKEN0",
"infra/session.sh",
0.50,
),
];
let group = correlate_findings(&findings)
.into_iter()
.find(|group| group.kind == CorrelationKind::SplitComposite)
.expect("the required pair still forms the composite");
assert_eq!(group.members.len(), 3);
assert_eq!(
group
.members
.iter()
.filter(|member| member.role == CorrelationRole::OptionalPart)
.count(),
1
);
}
#[test]
fn correlation_output_is_order_independent() {
let mut findings = vec![
finding("aws-access-key", "aws", "AKIA0", "infra/main.tf", 0.60),
finding(
"aws-secret-access-key",
"aws",
"SECRET0",
"infra/.env",
0.55,
),
finding("adobe-api-key", "adobe", "SHARED", "a/one.rs", 0.50),
finding(
"spotify-client-credentials",
"spotify",
"SHARED",
"b/two.rs",
0.45,
),
];
let forward = serde_json::to_string(&correlate_findings(&findings)).expect("serialize");
findings.reverse();
let reversed = serde_json::to_string(&correlate_findings(&findings)).expect("serialize");
assert_eq!(forward, reversed);
findings.rotate_left(2);
let rotated = serde_json::to_string(&correlate_findings(&findings)).expect("serialize");
assert_eq!(forward, rotated);
}
#[test]
fn empty_and_uncorrelated_finding_sets_produce_nothing() {
assert!(correlate_findings(&[]).is_empty());
let isolated = vec![
finding("aws-access-key", "aws", "AKIA0", "one/a.tf", 0.60),
finding("stripe-secret-key", "stripe", "SK0", "two/b.rs", 0.90),
];
assert!(correlate_findings(&isolated).is_empty());
}