keyhog-core 0.5.73

keyhog-core: shared data model and detector specifications for the KeyHog secret scanner
Documentation
use keyhog_core::{dedup_matches, DedupScope, MatchLocation, RawMatch, Severity};
use std::collections::HashMap;

fn sample_match(id: &str, cred: &str, path: &str) -> RawMatch {
    RawMatch {
        detector_id: id.into(),
        detector_name: id.into(),
        service: "test".into(),
        severity: Severity::High,
        credential: cred.into(),
        credential_hash: [0; 32].into(),
        companions: HashMap::new(),
        location: MatchLocation {
            source: "fs".into(),
            file_path: Some(path.into()),
            line: Some(1),
            offset: 0,
            commit: None,
            author: None,
            date: None,
        },
        entropy: None,
        confidence: Some(0.9),
    }
}

#[test]
fn dedup_reuses_precomputed_hash_for_none_scope() {
    let mut raw = sample_match("det1", "secret1", "file1.txt");
    raw.credential_hash = [0x41; 32].into();

    let deduped = dedup_matches(vec![raw], &DedupScope::None);
    assert_eq!(deduped.len(), 1);
    assert_eq!(
        deduped[0].credential_hash,
        [0x41; 32].into(),
        "dedup none must reuse RawMatch::credential_hash instead of recomputing SHA-256"
    );
}

#[test]
fn dedup_reuses_precomputed_hash_for_group_insert() {
    let mut raw = sample_match("det1", "secret1", "file1.txt");
    raw.credential_hash = [0x42; 32].into();

    let deduped = dedup_matches(vec![raw], &DedupScope::Credential);
    assert_eq!(deduped.len(), 1);
    assert_eq!(
        deduped[0].credential_hash,
        [0x42; 32].into(),
        "dedup grouped insert must reuse RawMatch::credential_hash instead of recomputing SHA-256"
    );
}

#[test]
fn dedup_credential_scope() {
    let matches = vec![
        sample_match("det1", "secret1", "file1.txt"),
        sample_match("det1", "secret1", "file2.txt"),
        sample_match("det1", "secret2", "file1.txt"),
    ];

    let deduped = dedup_matches(matches, &DedupScope::Credential);
    assert_eq!(deduped.len(), 2);

    let secret1_group = deduped
        .iter()
        .find(|m| m.credential.as_ref() == "secret1")
        .unwrap();
    assert_eq!(secret1_group.additional_locations.len(), 1);
}

#[test]
fn dedup_file_scope() {
    let matches = vec![
        sample_match("det1", "secret1", "file1.txt"),
        sample_match("det1", "secret1", "file1.txt"),
        sample_match("det1", "secret1", "file2.txt"),
    ];

    let deduped = dedup_matches(matches, &DedupScope::File);
    assert_eq!(deduped.len(), 2);
}

#[test]
fn dedup_file_scope_keeps_commits_separate() {
    let mut first = sample_match("det1", "secret1", "file1.txt");
    first.location.commit = Some("abc123".into());
    let mut second = sample_match("det1", "secret1", "file1.txt");
    second.location.commit = Some("def456".into());

    let deduped = dedup_matches(vec![first, second], &DedupScope::File);
    assert_eq!(deduped.len(), 2);
}

#[test]
fn dedup_merges_distinct_companion_values() {
    let mut first = sample_match("det1", "secret1", "file1.txt");
    first.companions.insert("client_id".into(), "one".into());
    let mut second = sample_match("det1", "secret1", "file2.txt");
    second.companions.insert("client_id".into(), "two".into());

    let deduped = dedup_matches(vec![first, second], &DedupScope::Credential);
    assert_eq!(deduped.len(), 1);
    assert_eq!(
        deduped[0].companions.get("client_id").map(String::as_str),
        Some("one | two")
    );
}

#[test]
fn dedup_same_offset_uses_total_location_tiebreak() {
    let mut low_line = sample_match("det1", "secret1", "file1.txt");
    low_line.location.offset = 4096;
    low_line.location.line = Some(17);

    let mut high_line = low_line.clone();
    high_line.location.line = Some(42);

    let forward = dedup_matches(
        vec![high_line.clone(), low_line.clone()],
        &DedupScope::Credential,
    );
    let reverse = dedup_matches(vec![low_line, high_line], &DedupScope::Credential);

    for deduped in [forward, reverse] {
        assert_eq!(deduped.len(), 1);
        assert_eq!(
            deduped[0].primary_location.line,
            Some(17),
            "same-offset overlapping-window aliases need a deterministic primary"
        );
        assert_eq!(deduped[0].additional_locations.len(), 1);
        assert_eq!(deduped[0].additional_locations[0].line, Some(42));
    }
}

#[test]
fn dedup_none_scope() {
    let matches = vec![
        sample_match("det1", "secret1", "file1.txt"),
        sample_match("det1", "secret1", "file1.txt"),
    ];

    let deduped = dedup_matches(matches, &DedupScope::None);
    assert_eq!(deduped.len(), 2);
}

#[test]
fn dedup_prefers_original_location_over_nearby_decoder_alias() {
    let mut original = sample_match(
        "devcycle-api-credentials",
        "DVC_CTRL_REG3_SW_PROG",
        "drivers/i2c/busses/i2c-tegra.c",
    );
    original.location.source = "filesystem".into();
    original.location.line = Some(612);
    original.location.offset = 19000;

    let mut decoded = original.clone();
    decoded.location.source = "filesystem/json".into();
    decoded.location.line = Some(613);
    decoded.location.offset = 18999;

    let deduped = dedup_matches(vec![decoded, original], &DedupScope::Credential);
    assert_eq!(deduped.len(), 1);
    assert_eq!(deduped[0].primary_location.source.as_ref(), "filesystem");
    assert_eq!(deduped[0].primary_location.line, Some(612));
    assert_eq!(deduped[0].primary_location.offset, 19000);
    assert!(
        deduped[0].additional_locations.is_empty(),
        "nearby decoded aliases should not report as extra locations"
    );
}

#[test]
fn dedup_many_same_group_matches_use_borrowed_lookup() {
    // Regression for dedup.rs hot path: duplicate matches must collapse via
    // borrowed DedupKeyRef lookup without cloning detector_id/credential per hit.
    let cred = "AKIA0000000000000000";
    let matches: Vec<_> = (0..500)
        .map(|line| {
            let mut m = sample_match("aws-access-key", cred, "config.env");
            m.location.line = Some(line + 1);
            m.location.offset = line * 64;
            m
        })
        .collect();

    let deduped = dedup_matches(matches, &DedupScope::Credential);
    assert_eq!(deduped.len(), 1);
    assert_eq!(deduped[0].credential.as_ref(), cred);
    assert_eq!(
        deduped[0].additional_locations.len(),
        499,
        "each distinct line should become one additional location"
    );
}