keyhog-core 0.5.4

keyhog-core — shared data model and detector specifications for the KeyHog secret scanner
Documentation
use keyhog_core::{JsonArrayReporter, JsonlReporter, Reporter, SarifReporter, TextReporter};
use keyhog_core::{MatchLocation, Severity, VerificationResult, VerifiedFinding};
use std::borrow::Cow;
use std::collections::HashMap;
use std::io::{self, Write};

fn sample_finding() -> VerifiedFinding {
    VerifiedFinding {
        detector_id: "slack-bot-token".into(),
        detector_name: "Slack Bot Token".into(),
        service: "slack".into(),
        severity: Severity::Critical,
        credential_redacted: Cow::Borrowed("xoxb***************"),
        credential_hash: "".into(),
        location: MatchLocation {
            source: "filesystem".into(),
            file_path: Some("config.py".into()),
            line: Some(42),
            offset: 0,
            commit: None,
            author: None,
            date: None,
        },
        verification: VerificationResult::Live,
        metadata: HashMap::from([("team".into(), "acme".into())]),
        additional_locations: vec![],
        confidence: Some(0.85),
    }
}

#[test]
fn text_reporter_output() {
    let mut buf = Vec::new();
    let mut reporter = TextReporter::new(&mut buf);
    reporter.report(&sample_finding()).unwrap();
    reporter.finish().unwrap();
    let output = String::from_utf8(buf).unwrap();
    assert!(output.contains("LIVE"));
    assert!(output.contains("Slack Bot Token"));
    assert!(output.contains("config.py:42"));
}

#[test]
fn jsonl_reporter_output() {
    let mut buf = Vec::new();
    let mut reporter = JsonlReporter::new(&mut buf);
    reporter.report(&sample_finding()).unwrap();
    reporter.finish().unwrap();
    let output = String::from_utf8(buf).unwrap();
    let parsed: serde_json::Value = serde_json::from_str(output.trim()).unwrap();
    assert_eq!(parsed["service"], "slack");
}

#[test]
fn sarif_reporter_basic_structure() {
    let mut buf = Vec::new();
    let mut reporter = SarifReporter::new(&mut buf);
    reporter.report(&sample_finding()).unwrap();
    reporter.finish().unwrap();
    let output = String::from_utf8(buf).unwrap();
    let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();

    assert_eq!(parsed["version"], "2.1.0");
    assert!(parsed["$schema"]
        .as_str()
        .unwrap()
        .contains("sarif-schema-2.1.0.json"));

    let runs = parsed["runs"].as_array().unwrap();
    assert_eq!(runs.len(), 1);

    let tool = &runs[0]["tool"]["driver"];
    assert_eq!(tool["name"], "keyhog");
    assert!(tool["version"].is_string());

    let rules = tool["rules"].as_array().unwrap();
    assert_eq!(rules.len(), 1);
    assert_eq!(rules[0]["id"], "slack-bot-token");
    assert_eq!(rules[0]["name"], "Slack Bot Token");
    assert!(rules[0]["properties"]["service"].is_string());

    let results = runs[0]["results"].as_array().unwrap();
    assert_eq!(results.len(), 1);
    assert_eq!(results[0]["ruleId"], "slack-bot-token");
    assert_eq!(results[0]["level"], "error");
    assert!(results[0]["message"]["text"]
        .as_str()
        .unwrap()
        .contains("slack"));

    let location = &results[0]["locations"][0];
    assert_eq!(
        location["physicalLocation"]["artifactLocation"]["uri"],
        "config.py"
    );
    assert_eq!(location["physicalLocation"]["region"]["startLine"], 42);

    let props = &results[0]["properties"];
    assert_eq!(props["verification"], "live");
    assert_eq!(props["confidence"], 0.85);
    assert_eq!(props["metadata.team"], "acme");
}

#[test]
fn sarif_reporter_severity_mapping() {
    let severities = vec![
        (Severity::Critical, "error"),
        (Severity::High, "error"),
        (Severity::Medium, "warning"),
        (Severity::Low, "note"),
        (Severity::Info, "note"),
    ];

    for (sev, expected_level) in severities {
        let mut finding = sample_finding();
        finding.severity = sev;
        finding.detector_id = format!("test-{}", expected_level).into();

        let mut buf = Vec::new();
        let mut reporter = SarifReporter::new(&mut buf);
        reporter.report(&finding).unwrap();
        reporter.finish().unwrap();

        let output = String::from_utf8(buf).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
        let results = parsed["runs"][0]["results"].as_array().unwrap();
        assert_eq!(
            results[0]["level"], expected_level,
            "severity {:?} should map to level {}",
            sev, expected_level
        );
    }
}

#[test]
fn sarif_reporter_multiple_findings() {
    let mut buf = Vec::new();
    let mut reporter = SarifReporter::new(&mut buf);

    let finding1 = sample_finding();
    let mut finding2 = sample_finding();
    finding2.detector_id = "github-token".into();
    finding2.detector_name = "GitHub Token".into();
    finding2.service = "github".into();
    finding2.location.file_path = Some(".env".into());
    finding2.location.line = Some(10);

    reporter.report(&finding1).unwrap();
    reporter.report(&finding2).unwrap();
    reporter.finish().unwrap();

    let output = String::from_utf8(buf).unwrap();
    let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();

    let rules = parsed["runs"][0]["tool"]["driver"]["rules"]
        .as_array()
        .unwrap();
    assert_eq!(rules.len(), 2);

    let results = parsed["runs"][0]["results"].as_array().unwrap();
    assert_eq!(results.len(), 2);
}

#[test]
fn sarif_reporter_deduplicates_rules_for_same_detector() {
    let mut buf = Vec::new();
    let mut reporter = SarifReporter::new(&mut buf);

    let finding1 = sample_finding();
    let mut finding2 = sample_finding();
    finding2.location.line = Some(99);
    finding2.location.file_path = Some("other.py".into());

    reporter.report(&finding1).unwrap();
    reporter.report(&finding2).unwrap();
    reporter.finish().unwrap();

    let output = String::from_utf8(buf).unwrap();
    let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
    let rules = parsed["runs"][0]["tool"]["driver"]["rules"]
        .as_array()
        .unwrap();
    assert_eq!(rules.len(), 1);
}

#[test]
fn sarif_reporter_git_location() {
    let mut finding = sample_finding();
    finding.location.commit = Some("abc123".into());
    finding.location.author = Some("developer".into());
    finding.location.date = Some("2026-03-20T12:00:00Z".into());

    let mut buf = Vec::new();
    let mut reporter = SarifReporter::new(&mut buf);
    reporter.report(&finding).unwrap();
    reporter.finish().unwrap();

    let output = String::from_utf8(buf).unwrap();
    let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();

    let location = &parsed["runs"][0]["results"][0]["locations"][0];
    let logical_locs = location["logicalLocations"].as_array().unwrap();

    assert_eq!(logical_locs.len(), 3);
    assert_eq!(logical_locs[0]["kind"], "commit");
    assert_eq!(logical_locs[0]["name"], "abc123");
    assert_eq!(logical_locs[1]["kind"], "author");
    assert_eq!(logical_locs[1]["name"], "developer");
    assert_eq!(logical_locs[2]["kind"], "date");
    assert_eq!(logical_locs[2]["name"], "2026-03-20T12:00:00Z");
}

#[test]
fn sarif_reporter_related_locations() {
    let mut finding = sample_finding();
    finding.additional_locations = vec![MatchLocation {
        source: "filesystem".into(),
        file_path: Some("backup.py".into()),
        line: Some(100),
        offset: 0,
        commit: None,
        author: None,
        date: None,
    }];

    let mut buf = Vec::new();
    let mut reporter = SarifReporter::new(&mut buf);
    reporter.report(&finding).unwrap();
    reporter.finish().unwrap();

    let output = String::from_utf8(buf).unwrap();
    let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();

    let related = parsed["runs"][0]["results"][0]["relatedLocations"]
        .as_array()
        .unwrap();
    assert_eq!(related.len(), 1);
    assert_eq!(
        related[0]["physicalLocation"]["artifactLocation"]["uri"],
        "backup.py"
    );
    assert_eq!(related[0]["physicalLocation"]["region"]["startLine"], 100);
}

#[derive(Default)]
struct FailingWriter {
    fail_on_write: bool,
    fail_on_flush: bool,
}

impl Write for FailingWriter {
    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
        if self.fail_on_write {
            Err(io::Error::other("write failed"))
        } else {
            Ok(buf.len())
        }
    }

    fn flush(&mut self) -> io::Result<()> {
        if self.fail_on_flush {
            Err(io::Error::other("flush failed"))
        } else {
            Ok(())
        }
    }
}

#[test]
fn json_array_reporter_new_propagates_opening_write_errors() {
    let result = JsonArrayReporter::new(FailingWriter {
        fail_on_write: true,
        fail_on_flush: false,
    });
    match result {
        Ok(_) => panic!("expected JsonArrayReporter::new to fail"),
        Err(error) => assert!(error.to_string().contains("write failed")),
    }
}

#[test]
fn jsonl_reporter_finish_flushes_writer() {
    let mut reporter = JsonlReporter::new(FailingWriter {
        fail_on_write: false,
        fail_on_flush: true,
    });
    let error = reporter.finish().unwrap_err();
    assert!(error.to_string().contains("flush failed"));
}