use keyhog_core::{
hex_encode, write_report, CredentialHash, MatchLocation, ReportFormat, Severity,
VerificationResult, VerifiedFinding,
};
use std::borrow::Cow;
use std::collections::HashMap;
const FINGERPRINT_KEY: &str = "keyhog/credentialHash/v1";
const EXPECTED_CWE: &str = "CWE-798";
const EXPECTED_OWASP: &str = "A07:2021";
fn finding_with(
detector_id: &'static str,
detector_name: &'static str,
service: &'static str,
severity: Severity,
redacted: &'static str,
verification: VerificationResult,
hash_byte: u8,
) -> VerifiedFinding {
VerifiedFinding {
detector_id: detector_id.into(),
detector_name: detector_name.into(),
service: service.into(),
severity,
credential_redacted: Cow::Borrowed(redacted),
credential_hash: CredentialHash::from_bytes([hash_byte; 32]),
companions_redacted: std::collections::HashMap::new(),
location: MatchLocation {
source: "filesystem".into(),
file_path: Some("config/app.env".into()),
line: Some(7),
offset: 0,
commit: None,
author: None,
date: None,
},
verification,
metadata: HashMap::new(),
additional_locations: vec![],
entropy: None,
confidence: Some(0.9),
}
}
fn aws_high() -> VerifiedFinding {
finding_with(
"aws-access-key",
"AWS Access Key",
"aws",
Severity::High,
"AKIA****",
VerificationResult::Unverifiable,
0xAB,
)
}
fn render(format: ReportFormat, findings: &[VerifiedFinding]) -> Vec<u8> {
let mut buf = Vec::new();
write_report(&mut buf, format, findings).expect("write_report must succeed");
buf
}
fn render_sarif(findings: &[VerifiedFinding]) -> serde_json::Value {
let buf = render(
ReportFormat::Sarif {
skip_summary: Vec::new(),
},
findings,
);
serde_json::from_slice(&buf).expect("SARIF output must parse as JSON")
}
fn render_text(findings: &[VerifiedFinding]) -> String {
let buf = render(
ReportFormat::Text {
color: false,
example_suppressions: 0,
dogfood_active: false,
},
findings,
);
String::from_utf8(buf).expect("text output must be valid UTF-8")
}
#[test]
fn sarif_result_ruleid_and_error_level_for_high() {
let json = render_sarif(&[aws_high()]);
let result = &json["runs"][0]["results"][0];
assert_eq!(
result["ruleId"].as_str(),
Some("aws-access-key"),
"ruleId must be the detector id"
);
assert_eq!(
result["level"].as_str(),
Some("error"),
"High severity maps to SARIF level `error`"
);
}
#[test]
fn sarif_level_mapping_across_severities() {
let cases = [
(Severity::Critical, "error"),
(Severity::High, "error"),
(Severity::Medium, "warning"),
(Severity::Low, "note"),
(Severity::ClientSafe, "note"),
(Severity::Info, "note"),
];
for (severity, expected_level) in cases {
let finding = finding_with(
"generic-token",
"Generic Token",
"generic",
severity,
"tok_****",
VerificationResult::Unverifiable,
0x11,
);
let json = render_sarif(&[finding]);
assert_eq!(
json["runs"][0]["results"][0]["level"].as_str(),
Some(expected_level),
"severity {severity:?} must render SARIF level {expected_level:?}"
);
}
}
#[test]
fn sarif_partial_fingerprints_hex_hash() {
let json = render_sarif(&[aws_high()]);
let expected_hex = "ab".repeat(32);
assert_eq!(expected_hex.len(), 64, "SHA-256 hex is 64 chars");
let fp = &json["runs"][0]["results"][0]["partialFingerprints"];
assert_eq!(
fp[FINGERPRINT_KEY].as_str(),
Some(expected_hex.as_str()),
"partialFingerprints[{FINGERPRINT_KEY}] must be the lower-hex credential hash"
);
assert_eq!(
expected_hex,
hex_encode([0xAB_u8; 32]),
"expected hex must match hex_encode of the same bytes"
);
}
#[test]
fn sarif_zero_hash_omits_partial_fingerprints() {
let finding = finding_with(
"aws-access-key",
"AWS Access Key",
"aws",
Severity::High,
"AKIA****",
VerificationResult::Unverifiable,
0x00,
);
let json = render_sarif(&[finding]);
let result = &json["runs"][0]["results"][0];
assert!(
result.get("partialFingerprints").is_none(),
"zero-hash finding must not emit partialFingerprints, got {:?}",
result.get("partialFingerprints")
);
}
#[test]
fn sarif_result_message_text_exact() {
let json = render_sarif(&[aws_high()]);
assert_eq!(
json["runs"][0]["results"][0]["message"]["text"].as_str(),
Some("aws secret detected: AKIA****"),
"message.text must be the '{{service}} secret detected: {{redacted}}' form"
);
}
#[test]
fn sarif_result_properties_cwe_and_owasp() {
let json = render_sarif(&[aws_high()]);
let props = &json["runs"][0]["results"][0]["properties"];
assert_eq!(
props["cwe"].as_str(),
Some(EXPECTED_CWE),
"properties.cwe must be {EXPECTED_CWE}"
);
assert_eq!(
props["owasp"].as_str(),
Some(EXPECTED_OWASP),
"properties.owasp must be {EXPECTED_OWASP}"
);
}
#[test]
fn sarif_rule_security_severity_band_for_high() {
let json = render_sarif(&[aws_high()]);
let rule = &json["runs"][0]["tool"]["driver"]["rules"][0];
assert_eq!(
rule["id"].as_str(),
Some("aws-access-key"),
"the single accumulated rule carries the detector id"
);
assert_eq!(
rule["properties"]["security-severity"].as_str(),
Some("8.0"),
"High -> security-severity band 8.0"
);
assert_eq!(
rule["properties"]["severity"].as_str(),
Some("high"),
"rule severity text is the kebab-case token"
);
}
#[test]
fn sarif_verification_token_revoked() {
let finding = finding_with(
"aws-access-key",
"AWS Access Key",
"aws",
Severity::High,
"AKIA****",
VerificationResult::Revoked,
0xAB,
);
let json = render_sarif(&[finding]);
assert_eq!(
json["runs"][0]["results"][0]["properties"]["verification"].as_str(),
Some("revoked"),
"verification token for Revoked must be exactly `revoked`"
);
}
#[test]
fn json_array_field_names_and_values() {
let buf = render(ReportFormat::Json, &[aws_high()]);
let json: serde_json::Value =
serde_json::from_slice(&buf).expect("JSON array output must parse");
let obj = &json[0];
assert_eq!(obj["detector_id"].as_str(), Some("aws-access-key"));
assert_eq!(obj["detector_name"].as_str(), Some("AWS Access Key"));
assert_eq!(obj["service"].as_str(), Some("aws"));
assert_eq!(
obj["severity"].as_str(),
Some("high"),
"severity serializes as the kebab-case token"
);
assert_eq!(obj["credential_redacted"].as_str(), Some("AKIA****"));
assert_eq!(
obj["credential_hash"].as_str(),
Some("ab".repeat(32).as_str()),
"credential_hash serializes as 64-char lower hex"
);
assert_eq!(
obj["verification"].as_str(),
Some("unverifiable"),
"verification serializes snake_case"
);
assert_eq!(obj["confidence"].as_f64(), Some(0.9));
}
#[test]
fn json_array_omits_unmeasured_optional_scores() {
let mut finding = aws_high();
finding.entropy = None;
finding.confidence = None;
let buf = render(ReportFormat::Json, &[finding]);
let json: serde_json::Value = serde_json::from_slice(&buf).expect("JSON array parses");
let object = json[0].as_object().expect("finding is a JSON object");
assert!(
!object.contains_key("entropy"),
"unmeasured entropy must be omitted, not fabricated"
);
assert!(
!object.contains_key("confidence"),
"unavailable confidence must be omitted as documented"
);
}
#[test]
fn json_client_safe_severity_is_hyphenated() {
let finding = finding_with(
"mixpanel-token",
"Mixpanel Token",
"mixpanel",
Severity::ClientSafe,
"mp_****",
VerificationResult::Unverifiable,
0x22,
);
let buf = render(ReportFormat::Json, &[finding]);
let json: serde_json::Value = serde_json::from_slice(&buf).expect("parse");
assert_eq!(
json[0]["severity"].as_str(),
Some("client-safe"),
"ClientSafe must serialize as `client-safe`, never `clientsafe`"
);
}
#[test]
fn jsonl_one_object_per_line() {
let second = finding_with(
"github-pat",
"GitHub PAT",
"github",
Severity::Critical,
"ghp_****",
VerificationResult::Live,
0xCD,
);
let buf = render(ReportFormat::Jsonl, &[aws_high(), second]);
let text = String::from_utf8(buf).expect("utf8");
let lines: Vec<&str> = text.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(lines.len(), 2, "JSONL must emit one line per finding");
let first: serde_json::Value = serde_json::from_str(lines[0]).expect("line 0 parses");
let secondv: serde_json::Value = serde_json::from_str(lines[1]).expect("line 1 parses");
assert_eq!(first["detector_id"].as_str(), Some("aws-access-key"));
assert_eq!(secondv["detector_id"].as_str(), Some("github-pat"));
assert_eq!(secondv["severity"].as_str(), Some("critical"));
}
#[test]
fn json_empty_run_is_bracket_pair() {
let buf = render(ReportFormat::Json, &[]);
assert_eq!(buf, b"[]", "empty JSON run must be exactly `[]`");
}
#[test]
fn json_metadata_object_values() {
let mut finding = aws_high();
finding
.metadata
.insert("account_id".to_string(), "123456789012".to_string());
finding
.metadata
.insert("region".to_string(), "us-east-1".to_string());
let buf = render(ReportFormat::Json, &[finding]);
let json: serde_json::Value = serde_json::from_slice(&buf).expect("parse");
let meta = &json[0]["metadata"];
assert_eq!(meta["account_id"].as_str(), Some("123456789012"));
assert_eq!(meta["region"].as_str(), Some("us-east-1"));
}
#[test]
fn text_finding_block_labels_and_redaction() {
let text = render_text(&[aws_high()]);
assert!(
text.contains("HIGH"),
"text block must carry the HIGH severity label, got:\n{text}"
);
assert!(
text.contains("AWS Access Key"),
"text block must name the detector, got:\n{text}"
);
assert!(
text.contains("Secret:"),
"text block must have a Secret: field, got:\n{text}"
);
assert!(
text.contains("AKIA****"),
"text block must show the redacted credential, got:\n{text}"
);
assert!(
text.contains("config/app.env:7"),
"text block must show file:line location, got:\n{text}"
);
}
#[test]
fn text_summary_counts_two_secrets() {
let mut second = aws_high();
second.detector_id = "github-pat".into();
let text = render_text(&[aws_high(), second]);
assert!(
text.contains("2 secrets found"),
"summary must read '2 secrets found', got:\n{text}"
);
assert!(
text.contains("2 unverified"),
"two Unverifiable findings roll up as '2 unverified', got:\n{text}"
);
}
#[test]
fn text_revoked_counts_as_dead_not_unverified() {
let finding = finding_with(
"aws-access-key",
"AWS Access Key",
"aws",
Severity::High,
"AKIA****",
VerificationResult::Revoked,
0xAB,
);
let text = render_text(&[finding]);
assert!(
text.contains("1 secret found"),
"singular count phrasing, got:\n{text}"
);
assert!(
text.contains("1 dead"),
"a revoked secret must count toward the inactive/dead tally, got:\n{text}"
);
assert!(
!text.contains("unverified"),
"a verified-revoked secret must not appear as unverified, got:\n{text}"
);
}
#[test]
fn text_empty_scan_honest_line() {
let text = render_text(&[]);
assert!(
text.contains("No secrets detected in the scanned files."),
"empty scan must print the honest scanned-files line, got:\n{text}"
);
assert!(
!text.to_lowercase().contains("clean"),
"empty scan must never claim the tree is clean, got:\n{text}"
);
}