From 15813c307afc5245ae60d04d3b4e0498409aa5f5 Mon Sep 17 00:00:00 2001 From: PiX <69745008+pixincreate@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:10:12 +0530 Subject: [PATCH] test: exhaustive coverage for the #91-#96 behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Verhoeff: canonical examples, separators, embedded id runs, degenerate inputs (the p-permutation breaks the all-zero identity chain). - Luhn: known cards, separators, sub-13-digit rejection. - shannon_entropy: information-theory identities, hex near-ceiling, multi-byte input. - is_multiline: (?s), grouped (?s:...), combined (?is), negative flags, plain patterns — and a real fix the test caught: combined flags like (?is) missed the substring check, so such patterns were scanned line-by-line and silently missed multiline secrets. is_multiline now parses flag groups. - Every FP allowlist entry exercised in both directions through the real detector set (AWS example key, placeholders, example-domain emails, 555 numbers, checksum prefixes). - The false-positive corpus is committed under tests/fixtures and scanned end to end: exactly the two documented residuals may appear. - Finding derives Deserialize with a wire-format round-trip test: the historical plugin_name key and the detector_name alias both parse. - git-history mode gets a hostile-git-config test mirroring the staged one (pins GIT_DIFF_FRAMING_ARGS coverage for log). - stdin scans through NUL bytes; the secret after a NUL line is found. --- .keywatch-baseline.json | 133 ++++++++++++++++++++ src/detector.rs | 109 +++++++++++++++- src/report.rs | 4 +- tests/detector_tests.rs | 101 +++++++++++++++ tests/fixtures/fp_corpus/.env.example | 6 + tests/fixtures/fp_corpus/README.md | 7 ++ tests/fixtures/fp_corpus/checksums.txt | 1 + tests/fixtures/fp_corpus/docker-compose.yml | 5 + tests/fixtures/fp_corpus/package.json | 5 + tests/fixtures/fp_corpus/phones.txt | 1 + tests/fixtures/fp_corpus/uuids.txt | 2 + tests/fp_corpus_tests.rs | 70 +++++++++++ tests/report_tests.rs | 38 ++++++ tests/scanner_tests.rs | 83 ++++++++++++ 14 files changed, 559 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/fp_corpus/.env.example create mode 100644 tests/fixtures/fp_corpus/README.md create mode 100644 tests/fixtures/fp_corpus/checksums.txt create mode 100644 tests/fixtures/fp_corpus/docker-compose.yml create mode 100644 tests/fixtures/fp_corpus/package.json create mode 100644 tests/fixtures/fp_corpus/phones.txt create mode 100644 tests/fixtures/fp_corpus/uuids.txt create mode 100644 tests/fp_corpus_tests.rs diff --git a/.keywatch-baseline.json b/.keywatch-baseline.json index d630b93..df17618 100644 --- a/.keywatch-baseline.json +++ b/.keywatch-baseline.json @@ -917,6 +917,139 @@ "finding_type": "AWS Access Key", "matched_content_hash": "3f733150de7916d4778298d7f90493889c38b76876b80c058e439851ce60cb2b", "plugin_name": "AWSKeyDetector" + }, + { + "file_path": "./src/detector.rs", + "line_number": 147, + "finding_type": "Aadhaar Card Number", + "matched_content_hash": "0476e6cf99db1c000b7e0a433ff83591ffe0472f3156c9ebac7b11d0862f3429", + "plugin_name": "AadhaarCardDetector" + }, + { + "file_path": "./src/detector.rs", + "line_number": 148, + "finding_type": "Aadhaar Card Number", + "matched_content_hash": "a1de1619345526170197aa72dd7bcb7e805e4c694270d74c752c317afd4a625e", + "plugin_name": "AadhaarCardDetector" + }, + { + "file_path": "./src/detector.rs", + "line_number": 520, + "finding_type": "Credit Card Number", + "matched_content_hash": "4541206d542811878a9374508fe296fa321a8b56c2902736a02f388836f6e108", + "plugin_name": "CreditCardDetector" + }, + { + "file_path": "./src/detector.rs", + "line_number": 521, + "finding_type": "Credit Card Number", + "matched_content_hash": "13ae894eedbfba2dbd06400ba5b215ffd661885646ab86e050fb1a0d192c1c5b", + "plugin_name": "CreditCardDetector" + }, + { + "file_path": "./src/detector.rs", + "line_number": 522, + "finding_type": "Credit Card Number", + "matched_content_hash": "0d30829f4cbd240de78f8dc72d0a5ed0a77887656aa572ee8fb1392cf9ae34a1", + "plugin_name": "CreditCardDetector" + }, + { + "file_path": "./src/detector.rs", + "line_number": 537, + "finding_type": "Random String", + "matched_content_hash": "e51298df0e431de2bfdf6180e3a7b9f3f092c3e9a912facd350e8c7179936e75", + "plugin_name": "RandomString" + }, + { + "file_path": "./tests/detector_tests.rs", + "line_number": 389, + "finding_type": "Generic Key/Secret", + "matched_content_hash": "6daabe7b8b2ca9ca4362fee06c2d25e32e59004c5834d23a755f774b02a054c1", + "plugin_name": "GenericKeyValueDetector" + }, + { + "file_path": "./tests/detector_tests.rs", + "line_number": 394, + "finding_type": "AWS Access Key", + "matched_content_hash": "0cbae582394e61dc81d9946ed87ec44f4c83cf1167181f62cd1454eb5e2e5469", + "plugin_name": "AWSKeyDetector" + }, + { + "file_path": "./tests/detector_tests.rs", + "line_number": 421, + "finding_type": "Password", + "matched_content_hash": "f6bd37622d846ac435a7b7dcbde2347d59494dfd3c3a787604e8b91491a39c95", + "plugin_name": "PasswordDetector" + }, + { + "file_path": "./tests/detector_tests.rs", + "line_number": 421, + "finding_type": "Generic Key/Secret", + "matched_content_hash": "f6bd37622d846ac435a7b7dcbde2347d59494dfd3c3a787604e8b91491a39c95", + "plugin_name": "GenericKeyValueDetector" + }, + { + "file_path": "./tests/detector_tests.rs", + "line_number": 422, + "finding_type": "Generic Key/Secret", + "matched_content_hash": "c98bc65cd589366ec37fedb07e874646469d675fc56078e5b8a3e18d2a70e51e", + "plugin_name": "GenericKeyValueDetector" + }, + { + "file_path": "./tests/detector_tests.rs", + "line_number": 423, + "finding_type": "Password", + "matched_content_hash": "af29a321067a1bbd4a7d3f56ba583751c3f0496bd204f642e1e5764766fe9a8c", + "plugin_name": "PasswordDetector" + }, + { + "file_path": "./tests/detector_tests.rs", + "line_number": 449, + "finding_type": "Email Address", + "matched_content_hash": "ebe162e5d3cc06b42201b0bfe39fde3379a3fe97c9836631f1750f21db142808", + "plugin_name": "EmailDetector" + }, + { + "file_path": "./tests/detector_tests.rs", + "line_number": 470, + "finding_type": "Base64 Encoded String", + "matched_content_hash": "097eda6585aa25d794c938beab55a00e7f2e7471a39375a3356535aa2e76efa6", + "plugin_name": "Base64Detector" + }, + { + "file_path": "./tests/detector_tests.rs", + "line_number": 476, + "finding_type": "Generic Key/Secret", + "matched_content_hash": "c9bff1b4953e132d00a1d95477c81e7e73f15847b0e7d538c5a3a9c022858899", + "plugin_name": "GenericKeyValueDetector" + }, + { + "file_path": "./tests/detector_tests.rs", + "line_number": 476, + "finding_type": "Base64 Encoded String", + "matched_content_hash": "a011d6c6ecfe7ed080c7c4bfcc08efc5a3744a7be7a117c466d2536c2ee30a9b", + "plugin_name": "Base64Detector" + }, + { + "file_path": "./tests/detector_tests.rs", + "line_number": 476, + "finding_type": "Random String", + "matched_content_hash": "f56884431ad6ea3a4fb741ca53cae314b4d3202d6f14bf69aafe86440f799403", + "plugin_name": "RandomString" + }, + { + "file_path": "./tests/fixtures/fp_corpus/README.md", + "line_number": 3, + "finding_type": "Email Address", + "matched_content_hash": "ebe162e5d3cc06b42201b0bfe39fde3379a3fe97c9836631f1750f21db142808", + "plugin_name": "EmailDetector" + }, + { + "file_path": "./tests/fixtures/fp_corpus/package.json", + "line_number": 3, + "finding_type": "Base64 Encoded String", + "matched_content_hash": "097eda6585aa25d794c938beab55a00e7f2e7471a39375a3356535aa2e76efa6", + "plugin_name": "Base64Detector" } ] } diff --git a/src/detector.rs b/src/detector.rs index c661e84..41e5609 100644 --- a/src/detector.rs +++ b/src/detector.rs @@ -140,6 +140,28 @@ mod verhoeff_tests { assert!(!passes_verhoeff("446655440000")); assert!(passes_verhoeff("100000000004")); } + + #[test] + fn verhoeff_handles_separators_and_partial_digits() { + // Separators are ignored: a real-world spelled number passes. + assert!(passes_verhoeff("2341 2341 2346")); + assert!(passes_verhoeff("2341-2341-2346")); + // Non-digit characters are filtered, so the digit suffix decides. + assert!(passes_verhoeff("id: 2363!")); + assert!(!passes_verhoeff("id: 2362!")); + } + + #[test] + fn verhoeff_rejects_degenerate_inputs() { + assert!(!passes_verhoeff("")); + assert!(!passes_verhoeff("no digits here")); + // The p-permutation breaks the all-zeros identity chain, so the + // classic dummy number does NOT validate: good for a secret scanner. + assert!(!passes_verhoeff("000000000000")); + // Single digit: 0 is its own inverse, so "0" validates. + assert!(passes_verhoeff("0")); + assert!(!passes_verhoeff("1")); + } } /// Luhn checksum, ignoring embedded separators. @@ -259,11 +281,35 @@ impl Detector { } /// Whether the pattern carries the dot-matches-newline flag anywhere — - /// `(?s)` or the grouped `(?s:...)` form — and must therefore run per - /// chunk instead of per line, or multiline secrets slip past it. Single - /// source for the production partition and the tests. + /// `(?s)`, a combined group like `(?is)`, or the scoped `(?s:...)` form — + /// and must therefore run per chunk instead of per line, or multiline + /// secrets slip past it. Single source for the production partition and + /// the tests. pub fn is_multiline(&self) -> bool { - self.regex.as_str().contains("(?s") + let pattern = self.regex.as_str(); + let bytes = pattern.as_bytes(); + let mut i = 0; + while i + 1 < bytes.len() { + if bytes[i] == b'(' && bytes[i + 1] == b'?' && (i == 0 || bytes[i - 1] != b'\\') { + // A flag group: scan its flag characters up to the closing + // paren or the group separator. + let mut j = i + 2; + let mut saw_s = false; + while j < bytes.len() && bytes[j] != b')' && bytes[j] != b':' { + if bytes[j] == b's' { + saw_s = true; + } + j += 1; + } + if saw_s { + return true; + } + i = j; + } else { + i += 1; + } + } + false } pub fn has_sufficient_entropy(&self, matched: &str) -> bool { @@ -459,3 +505,58 @@ fn initialize_detectors_from_config( .collect::, _>>() .map_err(|source| DetectorInitError::InvalidDetector { source }) } + +#[cfg(test)] +mod accept_unit_tests { + use super::{Detector, passes_luhn, shannon_entropy}; + + fn detector(pattern: &str, keywords: &[&str]) -> Detector { + let keywords: Vec = keywords.iter().map(|k| k.to_string()).collect(); + Detector::new("T", pattern, "T", "LOW", &[], &keywords, None).expect("valid detector") + } + + #[test] + fn luhn_accepts_known_cards_and_separators() { + assert!(passes_luhn("4111111111111111")); + assert!(passes_luhn("4111-1111-1111-1111")); + assert!(passes_luhn("4111 1111 1111 1111")); + assert!(!passes_luhn("4111111111111112")); + assert!(!passes_luhn("")); + assert!(!passes_luhn("no digits")); + // Below the 13-digit floor. + assert!(!passes_luhn("41111111111")); + } + + #[test] + fn shannon_entropy_matches_information_theory() { + assert_eq!(shannon_entropy(""), 0.0); + assert_eq!(shannon_entropy("aaaa"), 0.0); + assert!((shannon_entropy("ab") - 1.0).abs() < 1e-9); + assert!((shannon_entropy("abab") - 1.0).abs() < 1e-9); + // 64-char hex: near but under the 4.0 ceiling. + let hex = "8b0e7153bf7c3706d85c524e440066559a6656c90bd5482a90a29b9fa5ff5180"; + let entropy = shannon_entropy(hex); + assert!(entropy > 3.5 && entropy < 4.0, "hex entropy was {entropy}"); + // Multi-byte UTF-8 counts bytes. + assert!(shannon_entropy("\u{1F600}") > 0.0); + } + + #[test] + fn is_multiline_matches_only_the_dotall_flag() { + assert!(detector(r"(?s)BEGIN.*END", &[]).is_multiline()); + assert!(detector(r"(?s:BEGIN.*END)", &[]).is_multiline()); + assert!(detector(r"(?is)BEGIN.*END", &[]).is_multiline()); + assert!(!detector(r"SECRET_\w+", &[]).is_multiline()); + assert!(!detector(r"(?i)secret", &[]).is_multiline()); + } + + #[test] + fn keywords_are_lowercased_for_the_prefilter() { + // Contract: callers lowercase content once per line; stored keywords + // are lowercased at construction. + let detector = detector(r"SECRET_\w+", &["ApiKey", "SECRET"]); + assert!(detector.has_keywords("set the apikey value")); + assert!(detector.has_keywords(&"THE SECRET VALUE".to_lowercase())); + assert!(!detector.has_keywords("nothing relevant")); + } +} diff --git a/src/report.rs b/src/report.rs index 8b4355d..f8f24d8 100644 --- a/src/report.rs +++ b/src/report.rs @@ -107,7 +107,7 @@ pub enum ScanStatus { Fail, } -#[derive(Serialize, Clone)] +#[derive(Serialize, Deserialize, Clone, Debug)] pub struct Finding { pub file_path: String, pub line_number: usize, @@ -118,7 +118,7 @@ pub struct Finding { /// detectors; the wire format keeps the historical `plugin_name` key /// (with an alias on deserialize) so existing report consumers are /// unaffected. - #[serde(rename = "plugin_name", alias = "plugin_name")] + #[serde(rename = "plugin_name", alias = "detector_name")] pub detector_name: String, } diff --git a/tests/detector_tests.rs b/tests/detector_tests.rs index 8309ef6..bedb275 100644 --- a/tests/detector_tests.rs +++ b/tests/detector_tests.rs @@ -377,3 +377,104 @@ fn test_high_entropy_hex_needs_credential_context() { "a bare hex digest is indistinguishable from a hash and must not fire" ); } + +#[test] +fn test_aws_example_key_is_allowlisted_but_real_keys_report() { + // The AWS documentation example key/secret appear in READMEs everywhere. + assert!( + !reported_by("aws_access_key_id = AKIAIOSFODNN7EXAMPLE") + .contains(&"AWSKeyDetector".to_string()) + ); + assert!( + !reported_by("secret = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY") + .iter() + .any(|name| name == "Base64Detector" || name == "AWSKeyDetector") + ); + assert!( + reported_by("aws_access_key_id = AKIA1234567890ABCDEF") + .contains(&"AWSKeyDetector".to_string()), + "any other AKIA key must still be reported" + ); +} + +#[test] +fn test_placeholder_values_are_allowlisted_in_both_detectors() { + let placeholders = [ + "API_KEY=your-api-key-here", + "DATABASE_PASSWORD=changeme", + "SECRET_KEY=replace-me-please", + "PASSWORD: changeme", + "TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxx", + ]; + for line in &placeholders { + let names = reported_by(line); + assert!( + !names.contains(&"GenericKeyValueDetector".to_string()) + && !names.contains(&"PasswordDetector".to_string()), + "{line} must not report, got {names:?}" + ); + } + + // Mixed-case or digit-carrying values do not match the placeholder + // shapes and stay reported. + for line in [ + "password = 'mySecretPassword'", + "token = YourSpecialToken123", + "pwd: ReplaceThisRealSecret123", + "api_key = \"sk_live_51abcdefghij\"", + ] { + let names = reported_by(line); + assert!( + names.contains(&"GenericKeyValueDetector".to_string()) + || names.contains(&"PasswordDetector".to_string()), + "{line} must still be reported, got {names:?}" + ); + } +} + +#[test] +fn test_email_allowlists_documentation_domains_but_not_real_ones() { + for email in [ + "contact: support@example.com", + "alice@example.org", + "reply-to: noreply@github.com", + "author: 12345+user@users.noreply.github.com", + ] { + assert!( + !reported_by(email).contains(&"EmailDetector".to_string()), + "{email} must not report" + ); + } + assert!( + reported_by("owner: bob.smith@company.io").contains(&"EmailDetector".to_string()), + "a real-looking address is still reported" + ); +} + +#[test] +fn test_fictional_555_numbers_are_allowlisted() { + for phone in ["call 555-123-4567", "(212) 555-0123", "fax 555 123 4567"] { + assert!( + !reported_by(phone).contains(&"PhoneNumberDetector".to_string()), + "{phone} must not report" + ); + } + assert!( + reported_by("call 415-123-4567").contains(&"PhoneNumberDetector".to_string()), + "a real-shaped number is still reported" + ); +} + +#[test] +fn test_checksum_prefix_is_allowlisted_in_random_string() { + let line = r#"integrity = "sha512-abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG""#; + assert!( + !reported_by(line).contains(&"RandomString".to_string()), + "sha-prefixed checksums must not report" + ); + assert!( + reported_by(r#"token = "AbCdEfGhIjKlMnOpQrStUvWxYz0123456789abcd""#) + .contains(&"RandomString".to_string()), + "a quoted random string without a checksum prefix still reports" + ); +} diff --git a/tests/fixtures/fp_corpus/.env.example b/tests/fixtures/fp_corpus/.env.example new file mode 100644 index 0000000..a455d65 --- /dev/null +++ b/tests/fixtures/fp_corpus/.env.example @@ -0,0 +1,6 @@ +API_KEY=your-api-key-here +DATABASE_PASSWORD=changeme +SECRET_KEY=replace-me-please +JWT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxx +AWS_SECRET_ACCESS_KEY= +TOKEN=$INTERPOLATED_VALUE diff --git a/tests/fixtures/fp_corpus/README.md b/tests/fixtures/fp_corpus/README.md new file mode 100644 index 0000000..79dc2cf --- /dev/null +++ b/tests/fixtures/fp_corpus/README.md @@ -0,0 +1,7 @@ +# Example project +Contact: support@example.com, alice@example.org +Author: Bob +Run `cargo install --version 1.2.3` then call 555-123-4567 for help. +AWS docs reference the example key AKIAIOSFODNN7EXAMPLE and secret +wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY throughout. +Build 2024-01-15, see https://example.org/docs?v=2.3.4 diff --git a/tests/fixtures/fp_corpus/checksums.txt b/tests/fixtures/fp_corpus/checksums.txt new file mode 100644 index 0000000..be180f6 --- /dev/null +++ b/tests/fixtures/fp_corpus/checksums.txt @@ -0,0 +1 @@ +h1:8b0e7153bf7c3706d85c524e440066559a6656c90bd5482a90a29b9fa5ff5180= diff --git a/tests/fixtures/fp_corpus/docker-compose.yml b/tests/fixtures/fp_corpus/docker-compose.yml new file mode 100644 index 0000000..7bf0e41 --- /dev/null +++ b/tests/fixtures/fp_corpus/docker-compose.yml @@ -0,0 +1,5 @@ +services: + db: + environment: + POSTGRES_PASSWORD: changeme + API_TOKEN: ${STACK_TOKEN} diff --git a/tests/fixtures/fp_corpus/package.json b/tests/fixtures/fp_corpus/package.json new file mode 100644 index 0000000..43ab095 --- /dev/null +++ b/tests/fixtures/fp_corpus/package.json @@ -0,0 +1,5 @@ +{ + "name": "sample-lib", + "integrity": "sha512-abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG", + "dependencies": {"left-pad": "^1.3.0"} +} diff --git a/tests/fixtures/fp_corpus/phones.txt b/tests/fixtures/fp_corpus/phones.txt new file mode 100644 index 0000000..c6a06b7 --- /dev/null +++ b/tests/fixtures/fp_corpus/phones.txt @@ -0,0 +1 @@ +Call 555-867-5309 or (212) 555-0123, fax 555 123 4567. diff --git a/tests/fixtures/fp_corpus/uuids.txt b/tests/fixtures/fp_corpus/uuids.txt new file mode 100644 index 0000000..cafa9b6 --- /dev/null +++ b/tests/fixtures/fp_corpus/uuids.txt @@ -0,0 +1,2 @@ +id: 550e8400-e29b-41d4-a716-446655440000 +sha: 8b0e7153bf7c3706d85c524e440066559a6656c90bd5482a90a29b9fa5ff5180 diff --git a/tests/fp_corpus_tests.rs b/tests/fp_corpus_tests.rs new file mode 100644 index 0000000..442dbc0 --- /dev/null +++ b/tests/fp_corpus_tests.rs @@ -0,0 +1,70 @@ +//! The false-positive corpus: common content shapes that secret scanners +//! are traditionally noisy on. Scanned end to end; the expected findings +//! are exactly the two residuals that cannot be separated from real +//! secrets by shape (a company-domain email and a bare npm integrity +//! value). Any new finding here is a regression in detector precision. + +use key_watch::cli::ScanArgs; +use key_watch::scanner::run_scan; +use std::path::PathBuf; + +fn corpus_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/fp_corpus") +} + +fn scan_corpus() -> Vec { + let options = ScanArgs { + paths: vec![corpus_path().to_string_lossy().into_owned()], + no_baseline_discovery: true, + ..Default::default() + }; + let (findings, _) = run_scan(&options, None).expect("run_scan should succeed"); + findings +} + +#[test] +fn test_fp_corpus_produces_exactly_the_documented_residuals() { + let findings = scan_corpus(); + + assert_eq!(findings.len(), 2, "unexpected findings: {findings:#?}"); + + let email = findings + .iter() + .find(|f| f.finding_type == "Email Address") + .expect("the company-domain email is the documented residual"); + assert!( + email.file_path.ends_with("README.md"), + "unexpected email location: {}", + email.file_path + ); + + let base64 = findings + .iter() + .find(|f| f.finding_type == "Base64 Encoded String") + .expect("the npm integrity value is the documented residual"); + assert!( + base64.file_path.ends_with("package.json"), + "unexpected base64 location: {}", + base64.file_path + ); +} + +#[test] +fn test_fp_corpus_documentation_examples_are_never_reported() { + let findings = scan_corpus(); + let reported_types: Vec<_> = findings.iter().map(|f| f.finding_type.as_str()).collect(); + + for finding_type in [ + "AWS Access Key", + "Password", + "Generic Key/Secret", + "Phone Number", + "Aadhaar Card Number", + "Random String", + ] { + assert!( + !reported_types.contains(&finding_type), + "{finding_type} must never fire on the FP corpus, got {reported_types:?}" + ); + } +} diff --git a/tests/report_tests.rs b/tests/report_tests.rs index af156d2..5dc411c 100644 --- a/tests/report_tests.rs +++ b/tests/report_tests.rs @@ -292,3 +292,41 @@ fn test_redact_shows_no_prefix_for_short_matches() { "AKIA... (20 chars, redacted)" ); } + +#[test] +fn test_finding_wire_format_keeps_plugin_name_and_round_trips() { + use key_watch::report::{Finding, Severity}; + + let finding = Finding { + file_path: "a.txt".to_string(), + line_number: 7, + finding_type: "AWS Key".to_string(), + severity: Severity::High, + matched_content: "AKIAIOSFODNN7EXAMPLE".to_string(), + detector_name: "AWSKeyDetector".to_string(), + }; + + // The JSON wire format keeps the historical plugin_name key. + let json = serde_json::to_value(&finding).expect("serialize"); + assert_eq!(json["plugin_name"], "AWSKeyDetector"); + assert!(json.get("detector_name").is_none(), "no duplicate key"); + + // Deserialization accepts the historical key and the new one (alias). + let from_historical: Finding = + serde_json::from_value(json.clone()).expect("plugin_name must deserialize"); + assert_eq!(from_historical.detector_name, "AWSKeyDetector"); + + let aliased = json; + let mut with_new_key = serde_json::Map::new(); + for (key, value) in aliased.as_object().expect("object") { + let key = if key == "plugin_name" { + "detector_name".to_string() + } else { + key.clone() + }; + with_new_key.insert(key, value.clone()); + } + let from_new: Finding = + serde_json::from_value(with_new_key.into()).expect("alias must deserialize"); + assert_eq!(from_new.detector_name, "AWSKeyDetector"); +} diff --git a/tests/scanner_tests.rs b/tests/scanner_tests.rs index 6b0ac56..8945782 100644 --- a/tests/scanner_tests.rs +++ b/tests/scanner_tests.rs @@ -2228,3 +2228,86 @@ fn test_non_utf8_file_with_secret_is_still_scanned() -> Result<(), String> { let _ = fs::remove_dir_all(&dir); Ok(()) } + +#[test] +fn test_git_history_survives_hostile_git_config() -> Result<(), String> { + require_git(); + + // The history invocation shares GIT_DIFF_FRAMING_ARGS with --staged; + // this pins that the shared constant actually covers the log command. + let repo_dir = unique_temp_dir("history_hostile_git_config"); + let _ = fs::remove_dir_all(&repo_dir); + init_git_repo(&repo_dir)?; + for (key, value) in [ + ("color.ui", "always"), + ("diff.mnemonicPrefix", "true"), + ("diff.noprefix", "true"), + ("core.quotePath", "true"), + ("diff.relative", "true"), + ] { + let status = Command::new("git") + .args(["config", key, value]) + .current_dir(&repo_dir) + .status() + .map_err(|e| e.to_string())?; + assert!(status.success(), "git config {key} failed"); + } + commit_file( + &repo_dir, + "config.txt", + "one\ntwo\naws_access_key_id = AKIAABCDEFGHIJKLMNOP\n", + "add secret", + )?; + + let output = run_git_history_scan(&repo_dir, &["--verbose", "--no-baseline-discovery"])?; + let stdout = String::from_utf8_lossy(&output.stdout); + + assert_eq!(output.status.code(), Some(1), "stdout:\n{stdout}"); + assert!( + stdout.contains("\"file_path\": \"config.txt\""), + "path attribution must survive hostile config, got:\n{stdout}" + ); + assert!( + stdout.contains("\"line_number\": 3"), + "line attribution must survive hostile config, got:\n{stdout}" + ); + + let _ = fs::remove_dir_all(&repo_dir); + Ok(()) +} + +#[test] +fn test_stdin_with_nul_bytes_scans_through() -> Result<(), String> { + // stdin scans through NUL bytes (no binary bail-out): a secret after a + // NUL-containing line is still found. + use std::io::Write; + + let dir = unique_temp_dir("stdin_nul"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + + let mut child = Command::new(env!("CARGO_BIN_EXE_key-watch")) + .args(["scan", "--stdin", "--no-baseline-discovery"]) + .env("KEYWATCH_CONFIG_PATH", detectors_config_path()) + .stdin(std::process::Stdio::piped()) + .current_dir(&dir) + .spawn() + .expect("run key-watch"); + { + // A failed write surfaces as a missing finding below; the child is + // always waited on regardless. + let stdin = child.stdin.as_mut().expect("piped stdin"); + let _ = stdin.write_all(b"binary\x00line\naws_access_key_id = AKIA1234567890ABCDEF\n"); + } // stdin dropped: EOF sent before waiting. + let output = child.wait_with_output().expect("wait for scan"); + + assert_eq!( + output.status.code(), + Some(1), + "the secret after the NUL line must be found, got:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + + let _ = fs::remove_dir_all(&dir); + Ok(()) +}