keyhog-scanner 0.5.50

keyhog-scanner: high-performance SIMD-accelerated secret detection engine
Documentation
use keyhog_core::{CompanionMap, MatchLocation, RawMatch, Severity};
#[cfg(feature = "ml")]
use keyhog_scanner::scan_state::PendingRawMatch;
use keyhog_scanner::scan_state::{RawMatchPriority, ScanState};
#[cfg(feature = "ml")]
use keyhog_scanner::testing::ml_context_for_candidate;
use std::sync::Arc;

#[cfg(feature = "ml")]
fn confidence_policy() -> keyhog_core::DetectorMatchConfidenceSpec {
    keyhog_core::detector_spec_by_id("datadog-api-key")
        .and_then(|detector| detector.match_confidence)
        .expect("embedded Datadog confidence policy")
}
#[cfg(feature = "ml")]
fn push_pattern_pending(
    state: &mut ScanState,
    raw: RawMatch,
    confidence: f64,
    features: [f32; keyhog_scanner::ml_scorer::NUM_FEATURES],
) -> bool {
    let policy = confidence_policy();
    state.push_detector_ml_pending(
        pending_raw_match(raw),
        confidence,
        keyhog_scanner::context::CodeContext::Assignment,
        policy.assignment_context_multiplier,
        Some(policy.soft_context_suppression_threshold),
        policy.post_match,
        features,
        0.35,
        0.2,
        true,
        false,
        false,
        false,
        keyhog_scanner::checksum::ChecksumConfidenceDecision::not_applicable(),
        keyhog_scanner::detector_ml_policy::ActiveMlMode::Blend,
    )
}

#[cfg(feature = "ml")]
fn pending_raw_match(raw: RawMatch) -> PendingRawMatch {
    PendingRawMatch {
        detector_id: raw.detector_id,
        detector_name: raw.detector_name,
        service: raw.service,
        severity: raw.severity,
        credential: raw.credential,
        companions: raw.companions,
        location: raw.location,
        entropy: raw.entropy,
    }
}

fn raw_match(confidence: f64, credential: &'static str, offset: usize) -> RawMatch {
    RawMatch {
        detector_id: Arc::from("gate"),
        detector_name: Arc::from("Gate"),
        service: Arc::from("test"),
        severity: Severity::High,
        credential: credential.into(),
        credential_hash: [0u8; 32].into(),
        companions: CompanionMap::new(),
        location: MatchLocation {
            source: Arc::from("unit"),
            file_path: Some(Arc::from("unit.env")),
            line: Some(offset + 1),
            offset,
            commit: None,
            author: None,
            date: None,
        },
        entropy: None,
        confidence: Some(confidence),
    }
}

#[test]
fn push_match_keeps_highest_confidence_when_capped() {
    let mut state = ScanState::default();
    state.push_match(raw_match(0.10, "low", 1), 2);
    state.push_match(raw_match(0.90, "high", 2), 2);
    state.push_match(raw_match(0.50, "mid", 3), 2);

    let kept: Vec<_> = state
        .into_matches()
        .into_iter()
        .map(|m| m.credential.as_str().to_string())
        .collect();
    assert_eq!(kept, ["high", "mid"]);
}

#[test]
fn push_match_lazy_builds_only_for_admitted_candidates() {
    let mut state = ScanState::default();
    state.push_match(raw_match(0.90, "retained", 1), 1);

    let mut rejected_built = false;
    state.push_match_lazy(
        RawMatchPriority {
            confidence: Some(0.10),
            severity: Severity::High,
            detector_id: "gate",
            credential: "rejected",
            offset: 2,
            line: Some(2),
        },
        1,
        |_| {
            rejected_built = true;
            raw_match(0.10, "rejected", 2)
        },
    );
    assert!(
        !rejected_built,
        "lazy admission must not build a RawMatch for rejected candidates"
    );

    let mut admitted_built = false;
    state.push_match_lazy(
        RawMatchPriority {
            confidence: Some(0.99),
            severity: Severity::High,
            detector_id: "gate",
            credential: "admitted",
            offset: 3,
            line: Some(3),
        },
        1,
        |_| {
            admitted_built = true;
            raw_match(0.99, "admitted", 3)
        },
    );
    assert!(
        admitted_built,
        "lazy admission must build exactly when the candidate enters the heap"
    );
    let kept: Vec<_> = state
        .into_matches()
        .into_iter()
        .map(|m| m.credential.as_str().to_string())
        .collect();
    assert_eq!(kept, ["admitted"]);
}

#[test]
fn push_match_lazy_zero_limit_never_builds_or_retains() {
    let mut state = ScanState::default();
    let mut built = false;

    state.push_match_lazy(
        RawMatchPriority {
            confidence: Some(1.0),
            severity: Severity::Critical,
            detector_id: "gate",
            credential: "must-not-build",
            offset: 0,
            line: Some(1),
        },
        0,
        |_| {
            built = true;
            raw_match(1.0, "must-not-build", 0)
        },
    );

    assert!(
        !built,
        "a zero-capacity heap must reject before construction"
    );
    assert_eq!(state.into_matches(), Vec::<RawMatch>::new());
}

#[test]
#[cfg(feature = "ml")]
fn ml_context_for_candidate_has_one_path_prefix_owner() {
    let text = "zero\none\ntwo\nthree\nfour";

    assert_eq!(
        ml_context_for_candidate(text, 3, Some("src/lib.rs"), 5),
        "file:src/lib.rs\nzero\none\ntwo\nthree\nfour"
    );
    assert_eq!(
        ml_context_for_candidate(text, 3, None, 5),
        "zero\none\ntwo\nthree\nfour"
    );
}

#[test]
#[cfg(feature = "ml")]
fn pending_ml_queue_deduplicates_only_execution_equivalent_candidates() {
    let mut state = ScanState::default();
    let features = [0.25; keyhog_scanner::ml_scorer::NUM_FEATURES];

    assert!(push_pattern_pending(
        &mut state,
        raw_match(0.5, "candidate", 7),
        0.5,
        features,
    ));
    assert!(!push_pattern_pending(
        &mut state,
        raw_match(0.5, "candidate", 7),
        0.5,
        features,
    ));
    assert_eq!(state.ml_pending.len(), 1);

    assert!(push_pattern_pending(
        &mut state,
        raw_match(0.9, "candidate", 7),
        0.9,
        features,
    ));
    assert_eq!(state.ml_pending.len(), 1);
    assert_eq!(state.ml_pending[0].heuristic_conf, 0.9);

    let mut distinct_features = features;
    distinct_features[0] = 0.75;
    assert!(push_pattern_pending(
        &mut state,
        raw_match(0.9, "candidate", 7),
        0.9,
        distinct_features,
    ));
    assert_eq!(state.ml_pending.len(), 2);
}

#[test]
#[cfg(all(feature = "ml", feature = "entropy"))]
fn pending_ml_queue_keeps_pattern_and_entropy_evidence_separate() {
    let mut state = ScanState::default();
    let features = [0.5; keyhog_scanner::ml_scorer::NUM_FEATURES];
    let raw = raw_match(0.7, "candidate", 11);

    assert!(push_pattern_pending(&mut state, raw.clone(), 0.7, features));
    let policy = confidence_policy();
    assert!(state.push_entropy_ml_pending(
        pending_raw_match(raw),
        0.7,
        policy.unknown_context_multiplier,
        Some(policy.soft_context_suppression_threshold),
        policy.post_match,
        features,
        0.35,
        0.2,
        false,
        keyhog_scanner::checksum::ChecksumConfidenceDecision::not_applicable(),
        keyhog_scanner::detector_ml_policy::ActiveMlMode::Blend,
    ));
    assert_eq!(state.ml_pending.len(), 2);
}

/// Regression KH-1229: confirmed extraction must observe a finalized positive
/// and its still-pending ML-rejected twin in the same finalized-then-pending
/// order, without forcing the pending candidate into a durable `RawMatch`.
#[test]
#[cfg(feature = "ml")]
fn produced_match_view_preserves_final_and_pending_twin_order() {
    let mut state = ScanState::default();
    assert!(state.push_match(raw_match(0.95, "emitted", 3), 8));
    assert!(push_pattern_pending(
        &mut state,
        raw_match(0.05, "pending-reject", 7),
        0.05,
        [0.0; keyhog_scanner::ml_scorer::NUM_FEATURES],
    ));

    let mut produced = Vec::new();
    state.for_each_produced_match(|view| {
        produced.push((view.detector_id.to_string(), view.offset));
    });
    assert_eq!(produced, [("gate".to_string(), 3), ("gate".to_string(), 7)]);
}