keyhog-core 0.5.85

keyhog-core: shared data model and detector specifications for the KeyHog secret scanner
Documentation
//! Regression: internal scanner confidence and public `evidence_score` fields.
//!
//! `RawMatch.confidence` remains an internal scoring signal. Public redacted
//! and verified findings expose that optional value as `evidence_score`, while
//! the required `EvidenceVerdict` carries the operator-facing tier and reason.
//!
//! This suite pins these boundaries:
//!
//!   * `RawMatch::to_redacted()` maps internal confidence to `evidence_score`;
//!   * optional scores are omitted only for `None`, never for `Some(0.0)`;
//!   * `VerifiedFinding` emits 13 base fields and a fourteenth field when an
//!     evidence score is present;
//!   * raw-match ordering still uses internal confidence, with `None` treated
//!     as `0.0` for ordering only.
//!
//! Scanner scoring math and `min_confidence` behavior remain covered by their
//! owning scanner tests.
//!
//! Every assertion is a concrete expected value; no bare `is_empty()` /
//! `is_some()` sole assertions.

use std::collections::HashMap;

use keyhog_core::{
    dedup_matches, sha256_hash, DedupScope, MatchLocation, RawMatch, RedactedFinding, Severity,
    VerificationResult, VerifiedFinding,
};

const AKIA_PLAINTEXT: &str = "AKIAIOSFODNN7EXAMPLE";

/// Canonical `RawMatch` with a supplied confidence.
fn make_raw(confidence: Option<f64>) -> RawMatch {
    RawMatch {
        detector_id: "aws-access-key-id".into(),
        detector_name: "AWS Access Key ID".into(),
        service: "aws".into(),
        severity: Severity::Critical,
        credential: AKIA_PLAINTEXT.into(),
        credential_hash: sha256_hash(AKIA_PLAINTEXT),
        companions: HashMap::new(),
        location: MatchLocation {
            source: "filesystem".into(),
            file_path: Some("config/prod.env".into()),
            line: Some(42),
            offset: 100,
            commit: None,
            author: None,
            date: None,
        },
        entropy: Some(4.5),
        confidence,
        evidence: keyhog_core::EvidenceVerdict::review_unattributed(),
    }
}

fn make_verified(confidence: Option<f64>) -> VerifiedFinding {
    VerifiedFinding {
        detector_id: "aws-access-key-id".into(),
        detector_name: "AWS Access Key ID".into(),
        service: "aws".into(),
        severity: Severity::Critical,
        credential_redacted: "AK****LE".into(),
        credential_hash: sha256_hash(AKIA_PLAINTEXT),
        companions_redacted: std::collections::HashMap::new(),
        location: MatchLocation {
            source: "filesystem".into(),
            file_path: None,
            line: None,
            offset: 0,
            commit: None,
            author: None,
            date: None,
        },
        verification: VerificationResult::Unverifiable,
        metadata: HashMap::new(),
        additional_locations: Vec::new(),
        entropy: None,
        evidence_score: confidence,
        evidence: keyhog_core::EvidenceVerdict::review_unattributed(),
    }
}

#[test]
fn measured_entropy_survives_redaction_dedup_and_verified_serialization() {
    let raw = make_raw(Some(0.91));
    let redacted = raw.to_redacted();
    assert_eq!(redacted.entropy, Some(4.5));

    let group = dedup_matches(vec![raw], &DedupScope::Credential)
        .into_iter()
        .next()
        .expect("one raw match produces one group");
    assert_eq!(group.entropy, Some(4.5));

    let finding = VerifiedFinding::from_deduped(
        group,
        Severity::Critical,
        VerificationResult::Skipped,
        HashMap::new(),
    );
    assert_eq!(finding.entropy, Some(4.5));
    let value = serde_json::to_value(&finding).expect("verified finding serializes");
    assert_eq!(value["entropy"].as_f64(), Some(4.5));
}

// ---------------------------------------------------------------------------
// RedactedFinding.evidence_score serde, reached through RawMatch::to_redacted
// ---------------------------------------------------------------------------

#[test]
fn redacted_match_evidence_score_some_serializes_exact_number() {
    let redacted = make_raw(Some(0.4)).to_redacted();
    let value = serde_json::to_value(&redacted).expect("serialize RedactedFinding");
    // Present as a bare JSON number equal to 0.4.
    assert_eq!(value["evidence_score"].as_f64(), Some(0.4));
    assert!(value["evidence_score"].is_number());
    assert!(!value["evidence_score"].is_string());
}

#[test]
fn redacted_match_evidence_score_none_is_omitted_from_object() {
    let redacted = make_raw(None).to_redacted();
    let value = serde_json::to_value(&redacted).expect("serialize RedactedFinding");
    let obj = value.as_object().expect("object");
    // skip_serializing_if drops the key entirely.
    assert!(!obj.contains_key("evidence_score"));
    // But entropy (Some) is still present, proving only the score was dropped.
    assert_eq!(obj.get("entropy").and_then(|v| v.as_f64()), Some(4.5));
}

#[test]
fn redacted_match_evidence_score_zero_boundary_is_present_not_omitted() {
    // BOUNDARY: 0.0 is `Some(0.0)`, NOT `None`, so it must be serialized.
    let redacted = make_raw(Some(0.0)).to_redacted();
    let value = serde_json::to_value(&redacted).expect("serialize RedactedFinding");
    let obj = value.as_object().expect("object");
    assert!(obj.contains_key("evidence_score"));
    assert_eq!(value["evidence_score"].as_f64(), Some(0.0));
}

#[test]
fn redacted_match_evidence_score_one_boundary_serializes_one() {
    // BOUNDARY: top of the documented [0.0, 1.0] closed interval.
    let redacted = make_raw(Some(1.0)).to_redacted();
    let value = serde_json::to_value(&redacted).expect("serialize RedactedFinding");
    assert_eq!(value["evidence_score"].as_f64(), Some(1.0));
}

#[test]
fn redacted_match_evidence_score_roundtrips_exact() {
    let redacted = make_raw(Some(0.9)).to_redacted();
    let json = serde_json::to_string(&redacted).expect("serialize");
    let back: RedactedFinding = serde_json::from_str(&json).expect("deserialize");
    assert_eq!(back.evidence_score, Some(0.9));
    assert_eq!(back.detector_id, redacted.detector_id);
    assert_eq!(back.credential_hash, redacted.credential_hash);
}

#[test]
fn redacted_match_evidence_score_preserves_full_f64_precision() {
    // A value that needs many significant digits: serde_json uses a
    // round-trippable shortest repr, so the exact bits must survive.
    let precise = 0.123_456_789_012_345_67_f64;
    let redacted = make_raw(Some(precise)).to_redacted();
    let json = serde_json::to_string(&redacted).expect("serialize");
    let back: RedactedFinding = serde_json::from_str(&json).expect("deserialize");
    assert_eq!(back.evidence_score, Some(precise));
    // And within eps for good measure.
    let got = back.evidence_score.expect("some");
    assert!((got - precise).abs() < 1e-15, "got {got}");
}

#[test]
fn raw_match_confidence_deserializes_from_explicit_field() {
    // Deserialize a hand-authored JSON object; confidence must land verbatim.
    let json = r#"{
        "detector_id":"aws-access-key-id",
        "detector_name":"AWS Access Key ID",
        "service":"aws",
        "severity":"critical",
        "credential":"AKIAIOSFODNN7EXAMPLE",
        "credential_hash":"1a5d44a2dca19669d72edf4c4f1c27c4c1ca4b4408fbb17f6ce4ad452d78ddb3",
        "companions":{},
        "location":{"source":"filesystem","file_path":null,"line":null,"offset":0,"commit":null,"author":null,"date":null},
        "confidence":0.72,
        "evidence":{"tier":"review","reason_code":"unattributed","provenance":{"schema_version":1,"detector_digest":null,"pattern_index":null,"candidate_channel":"unattributed","source_role":"unknown","context_class":"unattributed"}}
    }"#;
    let raw: RawMatch = serde_json::from_str(json).expect("deserialize hand JSON");
    assert_eq!(raw.confidence, Some(0.72));
    // entropy field was absent -> None (independent optional float).
    assert_eq!(raw.entropy, None);
}

// ---------------------------------------------------------------------------
// VerifiedFinding.evidence_score serde (hand-written Serialize, field_count)
// ---------------------------------------------------------------------------

#[test]
fn verified_finding_evidence_score_some_included_and_field_count_is_14() {
    let finding = make_verified(Some(0.9));
    let value = serde_json::to_value(&finding).expect("serialize VerifiedFinding");
    let obj = value.as_object().expect("object");
    // 13 base fields + evidence_score => 14.
    assert_eq!(obj.len(), 14);
    assert!(obj.contains_key("evidence_score"));
    assert_eq!(value["evidence_score"].as_f64(), Some(0.9));
}

#[test]
fn verified_finding_evidence_score_none_omitted_and_field_count_is_13() {
    let finding = make_verified(None);
    let value = serde_json::to_value(&finding).expect("serialize VerifiedFinding");
    let obj = value.as_object().expect("object");
    assert_eq!(obj.len(), 13);
    assert!(!obj.contains_key("evidence_score"));
    // remediation is always injected by the custom Serialize regardless.
    assert!(obj.contains_key("remediation"));
}

#[test]
fn verified_finding_evidence_score_zero_is_present_field_count_14() {
    // BOUNDARY on the custom Serialize: `Some(0.0)` bumps the count to 14
    // because the branch keys on `is_some()`, not truthiness.
    let finding = make_verified(Some(0.0));
    let value = serde_json::to_value(&finding).expect("serialize VerifiedFinding");
    let obj = value.as_object().expect("object");
    assert_eq!(obj.len(), 14);
    assert_eq!(value["evidence_score"].as_f64(), Some(0.0));
}

// ---------------------------------------------------------------------------
// RedactedFinding.evidence_score via to_redacted()
// ---------------------------------------------------------------------------

#[test]
fn redacted_finding_evidence_score_propagates_from_raw_match() {
    let raw = make_raw(Some(0.55));
    let redacted: RedactedFinding = raw.to_redacted();
    // Field copied byte-for-byte.
    assert_eq!(redacted.evidence_score, Some(0.55));
    let value = serde_json::to_value(&redacted).expect("serialize RedactedFinding");
    assert_eq!(value["evidence_score"].as_f64(), Some(0.55));
}

#[test]
fn redacted_finding_evidence_score_none_is_omitted() {
    let raw = make_raw(None);
    let redacted = raw.to_redacted();
    assert_eq!(redacted.evidence_score, None);
    let value = serde_json::to_value(&redacted).expect("serialize RedactedFinding");
    let obj = value.as_object().expect("object");
    assert!(!obj.contains_key("evidence_score"));
}

#[test]
fn redacted_finding_evidence_score_roundtrips_exact() {
    let raw = make_raw(Some(0.33));
    let redacted = raw.to_redacted();
    let json = serde_json::to_string(&redacted).expect("serialize");
    let back: RedactedFinding = serde_json::from_str(&json).expect("deserialize");
    assert_eq!(back.evidence_score, Some(0.33));
}

// ---------------------------------------------------------------------------
// RawMatch::Ord uses confidence (higher first; None == 0.0 lowest)
// ---------------------------------------------------------------------------

#[test]
fn raw_match_ord_sorts_higher_confidence_first() {
    let low = make_raw(Some(0.30));
    let high = make_raw(Some(0.90));
    // Everything else identical, so confidence is the sole discriminator.
    let mut v = vec![low, high];
    v.sort();
    assert_eq!(v[0].confidence, Some(0.90));
    assert_eq!(v[1].confidence, Some(0.30));
    // Direct comparator check too.
    let a = make_raw(Some(0.90));
    let b = make_raw(Some(0.30));
    assert_eq!(a.cmp(&b), std::cmp::Ordering::Less); // higher conf => "less" => first
}

#[test]
fn raw_match_ord_none_confidence_sorts_below_some() {
    // None is treated as 0.0 for ordering only; Some(0.5) must come first.
    let none_conf = make_raw(None);
    let some_conf = make_raw(Some(0.5));
    let mut v = vec![none_conf, some_conf];
    v.sort();
    assert_eq!(v[0].confidence, Some(0.5));
    assert_eq!(v[1].confidence, None);
}

#[test]
fn raw_match_ord_some_zero_and_none_have_distinct_identity_order() {
    // Some(0.0) and None both map to 0.0 for the confidence sort key, so they
    // tie on the priority confidence key, but the final identity tiebreaker
    // keeps them distinct so `cmp == Equal` remains equivalent to `Eq`.
    let some_zero = make_raw(Some(0.0));
    let none_conf = make_raw(None);
    assert_ne!(some_zero.cmp(&none_conf), std::cmp::Ordering::Equal);
    assert_ne!(some_zero, none_conf);
}