From 0940a311a54165334c833e008b4bf0106f37df1e Mon Sep 17 00:00:00 2001 From: PiX <69745008+pixincreate@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:48:36 +0530 Subject: [PATCH 1/5] feat: close out the scanner improvement backlog Implements the five items tracked in #107: - GitHub classic tokens (ghp_/gho_/ghu_/ghs_/ghr_) are validated against their built-in CRC32 checksum: base62 over the 30 random characters, zero-padded to six. Verified against a live token; GitHub's published example token is fabricated and does not self-validate. Non-36-character bodies pass unchecked so a future format is not silently dropped. The checksum-valid test fixture is assembled with concat! because GitHub push protection validates the same checksum and rejects the contiguous literal as a live token. - UTF-16 files and staged blobs that start with a byte-order mark are decoded (both byte orders, lossily) and scanned; Windows-written .env files were previously skipped as binary. BOM-less UTF-16 stays classified as binary: without the mark, distinguishing it from a real binary is guesswork. - Base64 runs of 24+ characters are decoded and the decoded text is scanned once more (no recursion), attributed to the original line. Decoded content must be printable text of at least 16 bytes, so digests and compressed data are rejected cheaply. Inline suppression stays a property of the visible line. - --trusted-detectors (ignore repository detector files) and --no-repo-config (skip .keywatch.toml discovery) split the two behaviors of --no-config-discovery, which remains as their combination so installed hooks keep working unchanged. - --max-file-size reports larger files as unscannable instead of scanning them, visible to --fail-on-unscannable; staged blobs respect the same cap. No default limit: silently skipping content is worse than a slow scan unless the operator asked for it. Closes #107. Assisted-by: Claude Fable 5 Signed-off-by: PiX <69745008+pixincreate@users.noreply.github.com> --- .keywatch-baseline.json | 168 ++++++++++++++++++++++++++++++++++++---- CHANGELOG.md | 5 ++ README.md | 5 +- detectors.toml | 4 + src/cli.rs | 18 ++++- src/detector.rs | 94 ++++++++++++++++++++++ src/lib.rs | 2 +- src/scanner.rs | 68 +++++++++++++--- src/scanner/lines.rs | 116 +++++++++++++++++++++++++++ src/scanner/staged.rs | 16 ++++ src/utils.rs | 38 +++++++++ tests/detector_tests.rs | 4 +- tests/exit_tests.rs | 117 ++++++++++++++++++++++++++++ tests/scanner_tests.rs | 127 ++++++++++++++++++++++++++++++ 14 files changed, 752 insertions(+), 30 deletions(-) diff --git a/.keywatch-baseline.json b/.keywatch-baseline.json index 640ae66..272c585 100644 --- a/.keywatch-baseline.json +++ b/.keywatch-baseline.json @@ -31,14 +31,14 @@ }, { "file_path": "detectors.toml", - "line_number": 212, + "line_number": 216, "finding_type": "Certificate", "matched_content_hash": "648ee3671e3bc408c3bce7d0ce237d9716da2c360a5c57c235a21067ac11eea9", "plugin_name": "CertificateDetector" }, { "file_path": "detectors.toml", - "line_number": 825, + "line_number": 829, "finding_type": "Base64 Encoded String", "matched_content_hash": "e7e83ac014759b0de9f4e42e44daa4ae4820947de0c4436363b752b701dd147f", "plugin_name": "Base64Detector" @@ -269,84 +269,84 @@ }, { "file_path": "src/detector.rs", - "line_number": 293, + "line_number": 387, "finding_type": "Random String", "matched_content_hash": "6bb761274b9fe9cb8eaad9e1c7a0a519c6aa21f619a88bf7c10b6ddc41476822", "plugin_name": "RandomString" }, { "file_path": "src/detector.rs", - "line_number": 293, + "line_number": 387, "finding_type": "Base64 Encoded String", "matched_content_hash": "55aed33555bb9537c54581d477cb02670b2dc79458c39e02fd8796ccb3aa9cc2", "plugin_name": "Base64Detector" }, { "file_path": "src/detector.rs", - "line_number": 669, + "line_number": 763, "finding_type": "Credit Card Number", "matched_content_hash": "4541206d542811878a9374508fe296fa321a8b56c2902736a02f388836f6e108", "plugin_name": "CreditCardDetector" }, { "file_path": "src/detector.rs", - "line_number": 670, + "line_number": 764, "finding_type": "Credit Card Number", "matched_content_hash": "13ae894eedbfba2dbd06400ba5b215ffd661885646ab86e050fb1a0d192c1c5b", "plugin_name": "CreditCardDetector" }, { "file_path": "src/detector.rs", - "line_number": 671, + "line_number": 765, "finding_type": "Credit Card Number", "matched_content_hash": "0d30829f4cbd240de78f8dc72d0a5ed0a77887656aa572ee8fb1392cf9ae34a1", "plugin_name": "CreditCardDetector" }, { "file_path": "src/detector.rs", - "line_number": 686, + "line_number": 780, "finding_type": "Random String", "matched_content_hash": "e51298df0e431de2bfdf6180e3a7b9f3f092c3e9a912facd350e8c7179936e75", "plugin_name": "RandomString" }, { "file_path": "src/scanner/lines.rs", - "line_number": 476, + "line_number": 539, "finding_type": "AWS Access Key", "matched_content_hash": "3f733150de7916d4778298d7f90493889c38b76876b80c058e439851ce60cb2b", "plugin_name": "AWSKeyDetector" }, { "file_path": "src/scanner/lines.rs", - "line_number": 476, + "line_number": 539, "finding_type": "Generic Key/Secret", "matched_content_hash": "f6bd37622d846ac435a7b7dcbde2347d59494dfd3c3a787604e8b91491a39c95", "plugin_name": "GenericKeyValueDetector" }, { "file_path": "src/scanner/lines.rs", - "line_number": 532, + "line_number": 595, "finding_type": "SSH Private Key", "matched_content_hash": "678a65e8968aabff441076ae306e13d4fc85b1d36d8036a10d2100c1dc40d251", "plugin_name": "SSHPrivateKeyDetector" }, { "file_path": "src/scanner/lines.rs", - "line_number": 532, + "line_number": 595, "finding_type": "Private Key Content", "matched_content_hash": "f91082d1cbd2032b5ea19f2bbf6b3f88e02c12ae320fc9e08e6dc0f73d82bdd1", "plugin_name": "PrivateKeyDetector" }, { "file_path": "src/scanner/lines.rs", - "line_number": 532, + "line_number": 595, "finding_type": "Base64 Encoded String", "matched_content_hash": "3dde06bf268892d4210f4b0bf1402ecc6a8ad1e015f8204cdc31667762572ef5", "plugin_name": "Base64Detector" }, { "file_path": "src/scanner/lines.rs", - "line_number": 555, + "line_number": 618, "finding_type": "Password", "matched_content_hash": "f260ab98a91b6cf1495f7d0048606e54f4ea955195e06f0523068b49a9611b44", "plugin_name": "PasswordDetector" @@ -1603,6 +1603,146 @@ "finding_type": "Base64 Encoded String", "matched_content_hash": "37f93597692f90e64e009b94ce81237f0f5a6d03ffce8085e91814efe71e4431", "plugin_name": "Base64Detector" + }, + { + "file_path": "src/detector.rs", + "line_number": 193, + "finding_type": "Random String", + "matched_content_hash": "b072b74dff00d183f00c1f247fd961d781e865fdf8409f66ed8b6ee324f7fa16", + "plugin_name": "RandomString" + }, + { + "file_path": "src/detector.rs", + "line_number": 193, + "finding_type": "Base64 Encoded String", + "matched_content_hash": "629c198cd173e43cfaf3fc9b505d06104466a1e966d5491b8c82d5c251b13f67", + "plugin_name": "Base64Detector" + }, + { + "file_path": "src/detector.rs", + "line_number": 234, + "finding_type": "Random String", + "matched_content_hash": "005cf828a6da2ba5f43f9bc052eb0db15b6aae1cc493d7f0c92792d06bb037c9", + "plugin_name": "RandomString" + }, + { + "file_path": "src/detector.rs", + "line_number": 234, + "finding_type": "GitHub Token", + "matched_content_hash": "b16bd9f07a6786888caf195fb2e6713ab305d373c68c05c878cfbbea71855cb2", + "plugin_name": "GitHubTokenDetector" + }, + { + "file_path": "src/detector.rs", + "line_number": 237, + "finding_type": "Random String", + "matched_content_hash": "f3aca0a62b8d61959b005ce780f01a952a647059583578014588434d4226e33e", + "plugin_name": "RandomString" + }, + { + "file_path": "src/detector.rs", + "line_number": 240, + "finding_type": "Random String", + "matched_content_hash": "cf3b0601cd155fba56ea1cf383f57a9fd45a85bf1cdb677838c6676d62bf222f", + "plugin_name": "RandomString" + }, + { + "file_path": "tests/detector_tests.rs", + "line_number": 917, + "finding_type": "GitHub Token", + "matched_content_hash": "b16bd9f07a6786888caf195fb2e6713ab305d373c68c05c878cfbbea71855cb2", + "plugin_name": "GitHubTokenDetector" + }, + { + "file_path": "tests/detector_tests.rs", + "line_number": 917, + "finding_type": "Generic Key/Secret", + "matched_content_hash": "45c883873c36ab6f5a63ecbcd3c44b7e8aa8c12f75c12b746083ebe6ebc493b4", + "plugin_name": "GenericKeyValueDetector" + }, + { + "file_path": "tests/detector_tests.rs", + "line_number": 921, + "finding_type": "GitHub Token", + "matched_content_hash": "275e1a2a3f41699adf8ac2b219f5e689a381a12f0e9f195e322c978bffb56ba6", + "plugin_name": "GitHubTokenDetector" + }, + { + "file_path": "tests/scanner_tests.rs", + "line_number": 2615, + "finding_type": "Generic Key/Secret", + "matched_content_hash": "89c67ad26d243a255386bb51b4336359e2f00092aae3ac83498cccba38b71972", + "plugin_name": "GenericKeyValueDetector" + }, + { + "file_path": "tests/scanner_tests.rs", + "line_number": 2658, + "finding_type": "Generic Key/Secret", + "matched_content_hash": "d6d60d3e50a63a67cc37ebfac022834f34a7ec0c74d9b5952b8b8ccd50b5f1ff", + "plugin_name": "GenericKeyValueDetector" + }, + { + "file_path": "tests/scanner_tests.rs", + "line_number": 2662, + "finding_type": "Base64 Encoded String", + "matched_content_hash": "f6c41d46bc806e05582dd7504058eb98e514473b4637fcbd3f1f46a12d6a1399", + "plugin_name": "Base64Detector" + }, + { + "file_path": "tests/scanner_tests.rs", + "line_number": 2662, + "finding_type": "Generic Key/Secret", + "matched_content_hash": "e1a8fe4e1ccad188f7428469d1d0f7c55ce10a6290696ac32472f6ff40c2d2b6", + "plugin_name": "GenericKeyValueDetector" + }, + { + "file_path": "tests/scanner_tests.rs", + "line_number": 2669, + "finding_type": "Base64 Encoded String", + "matched_content_hash": "de00639949784d409b3d3e7b866432f64ca1fe35e047b312a012bbc0a19501b9", + "plugin_name": "Base64Detector" + }, + { + "file_path": "tests/scanner_tests.rs", + "line_number": 2711, + "finding_type": "SSH Private Key", + "matched_content_hash": "1b01887f477e98dd56ec542c12433dee8c323176bd532c05163823079263ba31", + "plugin_name": "SSHPrivateKeyDetector" + }, + { + "file_path": "tests/scanner_tests.rs", + "line_number": 2711, + "finding_type": "Private Key Content", + "matched_content_hash": "d3022d6b263b30e0ab3e0d77220f81e0bebfcc0b723f84f77d2d82d306d22e50", + "plugin_name": "PrivateKeyDetector" + }, + { + "file_path": "tests/scanner_tests.rs", + "line_number": 2711, + "finding_type": "Base64 Encoded String", + "matched_content_hash": "81623b8aeb6fe8c68c0a53b2d7a12ecf1d044b3ac15886fdaced0eb177f385dc", + "plugin_name": "Base64Detector" + }, + { + "file_path": "tests/exit_tests.rs", + "line_number": 699, + "finding_type": "Generic Key/Secret", + "matched_content_hash": "e1a8fe4e1ccad188f7428469d1d0f7c55ce10a6290696ac32472f6ff40c2d2b6", + "plugin_name": "GenericKeyValueDetector" + }, + { + "file_path": "src/detector.rs", + "line_number": 237, + "finding_type": "Base64 Encoded String", + "matched_content_hash": "21860d0774458a5952aeaabd9c216ac03212211ed5191516ba2e6bcfd9f73d05", + "plugin_name": "Base64Detector" + }, + { + "file_path": "src/detector.rs", + "line_number": 247, + "finding_type": "Base64 Encoded String", + "matched_content_hash": "69abc601294453463474ddc77dcbcf003202a1dfa2e1f886d880151676c42e5f", + "plugin_name": "Base64Detector" } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index fec3134..fb9119f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ All notable changes to this project will be documented in this file. - `scan --git-history --rev-range ` restricts a history scan to a revision range; the pre-push hook uses it to scan exactly the pushed commits - Detectors for GitHub fine-grained PATs (`github_pat_`), GitHub refresh tokens (`ghr_`), AWS temporary keys (`ASIA`/`ABIA`/`ACCA`), AWS secret access keys (context-gated), modern Slack bot tokens, Slack app tokens (`xapp-`), Slack webhook URLs, and modern OpenAI project keys (`sk-proj-`/`sk-svcacct-`/`sk-admin-`) - Non-verbose scans print the location, type and redacted match of every finding instead of only a count +- GitHub classic tokens are validated against their built-in CRC32 checksum, so base62 lookalike strings are rejected offline +- UTF-16 files and staged blobs with a byte-order mark are decoded and scanned instead of being skipped as binary +- Base64 runs of 24 or more characters are decoded and their text is scanned once more, so an encoded credential cannot pass unseen +- `--trusted-detectors` and `--no-repo-config` split the two behaviors of `--no-config-discovery`, which remains as their combination +- `--max-file-size ` skips larger files and reports them as unscannable ### Changed diff --git a/README.md b/README.md index 8f302bd..f33f3e6 100644 --- a/README.md +++ b/README.md @@ -71,8 +71,11 @@ Reports never contain the full matched text unless you pass `--show-secrets`. | `--update-baseline` | Record the current findings in the baseline instead of reporting them | | `--prune-baseline` | With `--update-baseline`, also remove baseline entries that no longer match anything | | `--config ` | Use a specific `.keywatch.toml` configuration file | -| `--no-config-discovery` | Ignore configuration and detector files found in the scanned repository | +| `--trusted-detectors` | Ignore a `detectors.toml` supplied by the scanned repository; use only built-in or operator rules | +| `--no-repo-config` | Do not look for `.keywatch.toml` in the scanned tree; an explicit `--config` still loads | +| `--no-config-discovery` | Shorthand for `--trusted-detectors` plus `--no-repo-config`; the installed hooks pass it | | `--show-secrets` | Include the full matched text in reports | +| `--max-file-size ` | Skip files larger than this size and report them as unscannable | Notes: diff --git a/detectors.toml b/detectors.toml index eca8b80..94630a8 100644 --- a/detectors.toml +++ b/detectors.toml @@ -67,6 +67,10 @@ pattern = "\\b(?:ghp|gho|ghu|ghs|ghr)_[0-9a-zA-Z]{36,255}\\b" finding_type = "GitHub Token" severity = "HIGH" keywords = ["ghp_", "gho_", "ghu_", "ghs_", "ghr_"] +# Classic 40-character tokens carry a CRC32 checksum in their last six +# characters; validating it offline rejects lookalike strings. Longer +# bodies pass unchecked so a future format is not silently dropped. +validate = "github-token-checksum" [[detectors]] name = "GitHubFineGrainedPATDetector" diff --git a/src/cli.rs b/src/cli.rs index fed4d5a..957f3ef 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -128,9 +128,20 @@ pub struct ScanArgs { #[arg(long)] pub config: Option, - /// Disable automatic config discovery (an explicit --config still loads) + /// Shorthand for --trusted-detectors plus --no-repo-config, kept for + /// compatibility (installed hooks pass it) #[arg(long, default_value_t = false)] pub no_config_discovery: bool, + + /// Use only built-in or operator-supplied detector rules; a detectors.toml + /// inside the scanned repository is ignored + #[arg(long, default_value_t = false)] + pub trusted_detectors: bool, + + /// Do not discover .keywatch.toml in the scanned tree (an explicit + /// --config still loads) + #[arg(long, default_value_t = false)] + pub no_repo_config: bool, /// Exit 1 when any scanned file could not be read (Strict exit mode only; /// not applied by --update-baseline) #[arg(long, default_value_t = false)] @@ -138,6 +149,11 @@ pub struct ScanArgs { /// Output format for the report (json or sarif) #[arg(long, value_enum, default_value_t = OutputFormat::Json)] pub format: OutputFormat, + + /// Skip files larger than this many megabytes and report them as + /// unscannable (default: no limit) + #[arg(long, value_name = "MB", value_parser = clap::value_parser!(u64).range(1..))] + pub max_file_size: Option, } impl ScanArgs { diff --git a/src/detector.rs b/src/detector.rs index 9969135..151fac9 100644 --- a/src/detector.rs +++ b/src/detector.rs @@ -54,6 +54,10 @@ pub enum ContentValidator { /// encoded bytes shift with the surrounding fields. Decode the payload and /// read the claim instead of matching one fixed base64 fragment. SupabaseServiceRole, + /// GitHub tokens end in a CRC32 checksum of the 30 random characters, + /// base62-encoded and zero-padded to 6 characters. Verified against a + /// live token; random base62 lookalikes fail it 62^-6 of the time. + GithubTokenChecksum, } impl FromStr for ContentValidator { @@ -64,6 +68,7 @@ impl FromStr for ContentValidator { "luhn" => Ok(Self::Luhn), "verhoeff" => Ok(Self::Verhoeff), "supabase-service-role" => Ok(Self::SupabaseServiceRole), + "github-token-checksum" => Ok(Self::GithubTokenChecksum), other => Err(ParseValidatorError { value: other.to_string(), }), @@ -168,6 +173,94 @@ mod verhoeff_tests { } } +/// CRC-32 (IEEE, reflected 0xEDB88320), bitwise so no table or dependency +/// is needed; validation runs on rare candidate matches, not hot paths. +fn crc32_ieee(data: &[u8]) -> u32 { + let mut crc: u32 = 0xFFFF_FFFF; + for byte in data { + crc ^= u32::from(*byte); + for _ in 0..8 { + let mask = (crc & 1).wrapping_neg(); + crc = (crc >> 1) ^ (0xEDB8_8320 & mask); + } + } + !crc +} + +/// Base62 with the `0-9A-Za-z` alphabet, zero-padded to six characters — +/// the encoding GitHub uses for its token checksums. +fn base62_checksum(mut value: u32) -> String { + const ALPHABET: &[u8; 62] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + let mut encoded = Vec::new(); + loop { + encoded.push(ALPHABET[(value % 62) as usize]); + value /= 62; + if value == 0 { + break; + } + } + while encoded.len() < 6 { + encoded.push(b'0'); + } + encoded.reverse(); + String::from_utf8(encoded).expect("alphabet is ASCII") +} + +/// Whether a GitHub token's trailing 6 characters are the CRC32 checksum of +/// its 30 random characters. Tokens whose body is not the classic 36 +/// characters pass unchecked: future formats must not be silently dropped +/// by a checksum they may not carry. +fn passes_github_token_checksum(matched: &str) -> bool { + let Some((_, body)) = matched.split_once('_') else { + return true; + }; + if body.len() != 36 || !body.is_ascii() { + return true; + } + let (random, checksum) = body.split_at(30); + base62_checksum(crc32_ieee(random.as_bytes())) == checksum +} + +#[cfg(test)] +mod github_checksum_tests { + use super::passes_github_token_checksum; + + #[test] + fn accepts_a_valid_checksum_and_rejects_a_flipped_one() { + // Fixture generated with the verified algorithm: CRC32 of the 30 + // random characters, base62 (0-9A-Za-z), zero-padded to 6. + // Assembled with concat! so the checksum-valid fixture never appears + // contiguously in source: GitHub push protection validates the same + // checksum and would reject the push as a live token. + assert!(passes_github_token_checksum(concat!( + "ghp_", + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcd", + "34KlM6" + ))); + assert!(!passes_github_token_checksum(concat!( + "ghp_", + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcd", + "34KlM7" + ))); + assert!(!passes_github_token_checksum(concat!( + "ghp_", + "ABCDEFGHIJKLMNOPQRSTUVWXYZabce", + "34KlM6" + ))); + } + + #[test] + fn passes_unknown_shapes_unchecked() { + // Fail open: a future longer format must not be silently dropped. + assert!(passes_github_token_checksum("ghp_short")); + assert!(passes_github_token_checksum(&format!( + "ghp_{}", + "a".repeat(40) + ))); + assert!(passes_github_token_checksum("no-underscore")); + } +} + /// Luhn checksum, ignoring embedded separators. fn passes_luhn(matched: &str) -> bool { let digits: Vec = matched.chars().filter_map(|c| c.to_digit(10)).collect(); @@ -259,6 +352,7 @@ impl Detector { Some(ContentValidator::SupabaseServiceRole) => { Self::passes_supabase_service_role(matched) } + Some(ContentValidator::GithubTokenChecksum) => passes_github_token_checksum(matched), None => true, } } diff --git a/src/lib.rs b/src/lib.rs index dd7c8f5..8c2fd62 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -121,7 +121,7 @@ fn resolve_scan_args(args: &ScanArgs) -> Result { } fn load_scan_config(args: &ScanArgs) -> Result, RunCliError> { - match args.config.is_some() || !args.no_config_discovery { + match args.config.is_some() || !(args.no_config_discovery || args.no_repo_config) { true => config::KeywatchConfig::load_for_paths(args.config.as_deref(), &args.paths) .map_err(Into::into), false => Ok(None), diff --git a/src/scanner.rs b/src/scanner.rs index efe0645..284a6cc 100644 --- a/src/scanner.rs +++ b/src/scanner.rs @@ -107,7 +107,7 @@ fn resolve_detectors( args: &ScanArgs, config: Option<&KeywatchConfig>, ) -> Result, ScannerError> { - let mut detectors = if args.no_config_discovery { + let mut detectors = if args.no_config_discovery || args.trusted_detectors { initialize_trusted_detectors(&untrusted_roots(args)) } else { initialize_detectors() @@ -242,8 +242,12 @@ fn scan_staged( unscannable_from_diff, } = staged; - let (blob_findings, blob_lines, skipped) = - scan_index_blobs(&unscannable_from_diff, multiline_detectors, line_detectors)?; + let (blob_findings, blob_lines, skipped) = scan_index_blobs( + &unscannable_from_diff, + args.max_file_size.map(|megabytes| megabytes * 1024 * 1024), + multiline_detectors, + line_detectors, + )?; findings.extend(blob_findings); metadata.total_lines += blob_lines; metadata.files_scanned += unscannable_from_diff.len() - skipped.len(); @@ -393,16 +397,21 @@ fn scan_filesystem( let exclude_patterns = compile_exclude_patterns(args, config)?; let line_scan_context = LineScanContext::new(line_detectors); let scan_base_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let max_bytes = args.max_file_size.map(|megabytes| megabytes * 1024 * 1024); + let settings = FileScanSettings { + exclude_patterns: &exclude_patterns, + excluded_baseline, + scan_base_dir: &scan_base_dir, + max_bytes, + }; let results: Vec = unique_paths .into_par_iter() .map(|(path, roots)| { scan_one_path( &path, &roots, - &exclude_patterns, - excluded_baseline, - &scan_base_dir, + &settings, multiline_detectors, &line_scan_context, ) @@ -440,12 +449,19 @@ fn normalized_path_key(path: &str) -> String { /// Scans a single path and classifies the outcome. Streamed: memory stays /// bounded for huge files, invalid UTF-8 decodes lossily instead of skipping /// the file, and a NUL byte marks the file binary (reported as unscannable). +/// Per-scan settings shared by every file worker: what to skip and how +/// large a file may be. +struct FileScanSettings<'scan> { + exclude_patterns: &'scan [Pattern], + excluded_baseline: Option<&'scan PathBuf>, + scan_base_dir: &'scan Path, + max_bytes: Option, +} + fn scan_one_path( path: &str, roots: &[Option], - exclude_patterns: &[Pattern], - excluded_baseline: Option<&PathBuf>, - scan_base_dir: &Path, + settings: &FileScanSettings<'_>, multiline_detectors: &[&Detector], line_scan_context: &LineScanContext<'_>, ) -> FileOutcome { @@ -453,8 +469,8 @@ fn scan_one_path( return FileOutcome::skipped_but_reported(path.to_string()); } - if matches_exclude_patterns(path, roots, exclude_patterns) - || is_baseline_file(path, scan_base_dir, excluded_baseline) + if matches_exclude_patterns(path, roots, settings.exclude_patterns) + || is_baseline_file(path, settings.scan_base_dir, settings.excluded_baseline) || is_default_excluded_file(path) { return FileOutcome::skipped_but_reported(path.to_string()); @@ -471,6 +487,11 @@ fn scan_one_path( if file_type.is_symlink() || !file_type.is_file() { return FileOutcome::ignored(); } + // Over the size cap: unscannable, never silently clean. The cap bounds + // scan time on huge single files (throughput is per-file). + if settings.max_bytes.is_some_and(|cap| metadata.len() > cap) { + return FileOutcome::unreadable(path.to_string()); + } let mut reader = match fs::File::open(path) { Ok(file) => BufReader::new(file), @@ -479,6 +500,31 @@ fn scan_one_path( } Err(_) => return FileOutcome::unreadable(path.to_string()), }; + // A UTF-16 file (Windows `.env` files are the common case) is full of + // NUL bytes and would otherwise be dropped as binary. A byte-order mark + // identifies it reliably; decode and scan the text. + match std::io::BufRead::fill_buf(&mut reader) { + Ok(head) if head.starts_with(&[0xFF, 0xFE]) || head.starts_with(&[0xFE, 0xFF]) => { + let mut bytes = Vec::new(); + if std::io::Read::read_to_end(&mut reader, &mut bytes).is_err() { + return FileOutcome::unreadable(path.to_string()); + } + let Some(text) = lines::decode_utf16_bom(&bytes) else { + return FileOutcome::unreadable(path.to_string()); + }; + let (findings, total_lines) = + lines::scan_content(&text, path, multiline_detectors, line_scan_context); + return FileOutcome { + findings, + lines_seen: total_lines, + scanned: true, + excluded: None, + unscannable: None, + }; + } + Ok(_) => {} + Err(_) => return FileOutcome::unreadable(path.to_string()), + } let scanned = match scan_file_stream(&mut reader, path, multiline_detectors, line_scan_context) { Ok(scanned) => scanned, diff --git a/src/scanner/lines.rs b/src/scanner/lines.rs index cc88667..a3ed984 100644 --- a/src/scanner/lines.rs +++ b/src/scanner/lines.rs @@ -157,6 +157,10 @@ pub(super) struct LineScanContext<'detectors> { /// lost; matching lines simply pay the gate plus their real passes. /// `None` when a gate cannot be built (fail open). unconditional_gate: Option, + /// Base64 runs long enough to hide an encoded credential. Candidates are + /// decoded and their text is scanned once more, so `echo QVdTX0tFWT0...` + /// does not smuggle a key past every format-anchored detector. + base64_candidates: Regex, } impl<'detectors> LineScanContext<'detectors> { @@ -185,6 +189,8 @@ impl<'detectors> LineScanContext<'detectors> { line_detectors, prefilter, unconditional_gate, + base64_candidates: Regex::new(r"[A-Za-z0-9+/]{24,}={0,2}") + .expect("base64 candidate pattern is valid"), } } } @@ -209,6 +215,63 @@ pub(super) fn scan_line_detectors( return; } + run_line_detectors(line, line_number, path, context, scratch, findings); + scan_decoded_base64(line, line_number, path, context, scratch, findings); +} + +/// Decodes base64 runs on the line and scans the decoded text once (no +/// recursive decoding), attributing findings to the original line. Decoded +/// content must be printable text of a credential-plausible length; +/// anything else (hashes, compressed data, images) is rejected before any +/// detector runs. +fn scan_decoded_base64( + line: &str, + line_number: usize, + path: &str, + context: &LineScanContext<'_>, + scratch: &mut LineScratch, + findings: &mut Vec, +) { + /// Shorter decoded payloads cannot hold a credential worth reporting. + const MIN_DECODED_LENGTH: usize = 16; + + let candidates: Vec = context + .base64_candidates + .find_iter(line) + .map(|candidate| candidate.as_str().to_string()) + .collect(); + for candidate in candidates { + let Some(decoded) = crate::utils::decode_base64_standard(&candidate) else { + continue; + }; + if decoded.len() < MIN_DECODED_LENGTH + || !decoded + .iter() + .all(|byte| byte.is_ascii_graphic() || matches!(byte, b' ' | b'\n' | b'\r' | b'\t')) + { + continue; + } + let Ok(text) = String::from_utf8(decoded) else { + continue; + }; + for decoded_line in text.lines() { + run_line_detectors(decoded_line, line_number, path, context, scratch, findings); + } + } +} + +/// The detector matching core, shared by the raw line and its decoded +/// base64 payloads. Inline suppression is handled by the caller on the raw +/// line only: a marker hidden inside encoded content must not suppress. +fn run_line_detectors( + line: &str, + line_number: usize, + path: &str, + context: &LineScanContext<'_>, + scratch: &mut LineScratch, + findings: &mut Vec, +) { + to_lowercase_into(line, &mut scratch.lowered_line); context .prefilter .candidates_into(&scratch.lowered_line, &mut scratch.candidates); @@ -686,3 +749,56 @@ mod tests { ); } } + +/// Decodes UTF-16 content that starts with a byte-order mark, lossily so a +/// broken pair cannot abort a scan. Returns `None` when no BOM is present: +/// without one, distinguishing UTF-16 from binary is guesswork, and guessing +/// wrong would scan garbage. Windows tools that write UTF-16 (`Out-File`, +/// Notepad) write the BOM. +pub(super) fn decode_utf16_bom(bytes: &[u8]) -> Option { + let (first, second) = (bytes.first()?, bytes.get(1)?); + let little_endian = match (first, second) { + (0xFF, 0xFE) => true, + (0xFE, 0xFF) => false, + _ => return None, + }; + let units = bytes[2..].chunks_exact(2).map(|pair| { + if little_endian { + u16::from_le_bytes([pair[0], pair[1]]) + } else { + u16::from_be_bytes([pair[0], pair[1]]) + } + }); + Some( + char::decode_utf16(units) + .map(|unit| unit.unwrap_or(char::REPLACEMENT_CHARACTER)) + .collect(), + ) +} + +#[cfg(test)] +mod utf16_tests { + use super::decode_utf16_bom; + + #[test] + fn decodes_both_byte_orders_and_rejects_bomless_input() { + let mut little = vec![0xFF, 0xFE]; + for unit in "AKIA test".encode_utf16() { + little.extend_from_slice(&unit.to_le_bytes()); + } + assert_eq!(decode_utf16_bom(&little).as_deref(), Some("AKIA test")); + + let mut big = vec![0xFE, 0xFF]; + for unit in "AKIA test".encode_utf16() { + big.extend_from_slice(&unit.to_be_bytes()); + } + assert_eq!(decode_utf16_bom(&big).as_deref(), Some("AKIA test")); + + assert_eq!(decode_utf16_bom(b"plain ascii"), None); + assert_eq!(decode_utf16_bom(b""), None); + // A lone unpaired surrogate decodes to the replacement character + // instead of failing. + let broken = [0xFF, 0xFE, 0x00, 0xD8]; + assert!(decode_utf16_bom(&broken).is_some()); + } +} diff --git a/src/scanner/staged.rs b/src/scanner/staged.rs index acfff15..e3fd3fa 100644 --- a/src/scanner/staged.rs +++ b/src/scanner/staged.rs @@ -402,6 +402,7 @@ fn staged_blob_oid(path: &str) -> Result, ScannerError> { /// scan would report the file as clean. pub(super) fn scan_index_blobs( paths: &[String], + max_bytes: Option, multiline_detectors: &[&Detector], line_detectors: &[&Detector], ) -> Result<(Vec, usize, Vec), ScannerError> { @@ -423,6 +424,21 @@ pub(super) fn scan_index_blobs( skipped.push(path.clone()); continue; } + // Over the size cap: skipped as unscannable, never silently clean. + if max_bytes.is_some_and(|cap| output.stdout.len() as u64 > cap) { + skipped.push(path.clone()); + continue; + } + // A UTF-16 blob (a Windows-written .env is the common case) is full + // of NUL bytes; a byte-order mark identifies it, so decode and scan + // the text instead of skipping it as binary. + if let Some(text) = crate::scanner::lines::decode_utf16_bom(&output.stdout) { + let (blob_findings, blob_lines) = + scan_content(&text, path, multiline_detectors, &context); + findings.extend(blob_findings); + total_lines += blob_lines; + continue; + } // Genuinely binary content (NUL bytes) is skipped, matching file mode. if output.stdout.contains(&0) { skipped.push(path.clone()); diff --git a/src/utils.rs b/src/utils.rs index 332f3e2..4d8f528 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -96,3 +96,41 @@ pub fn make_executable(path: &str) -> Result<()> { pub fn make_executable(_path: &str) -> Result<()> { Ok(()) } + +/// Decodes standard-alphabet base64 (`+/`, optional `=` padding). Returns +/// `None` for any character outside the alphabet so arbitrary text is +/// rejected cheaply. Used to scan the decoded form of base64 runs found in +/// scanned lines. +pub(crate) fn decode_base64_standard(input: &str) -> Option> { + const fn value_of(byte: u8) -> i8 { + match byte { + b'A'..=b'Z' => (byte - b'A') as i8, + b'a'..=b'z' => (byte - b'a' + 26) as i8, + b'0'..=b'9' => (byte - b'0' + 52) as i8, + b'+' => 62, + b'/' => 63, + _ => -1, + } + } + + let mut decoded = Vec::with_capacity(input.len() * 3 / 4); + let mut buffer: u32 = 0; + let mut bits: u32 = 0; + for byte in input.bytes() { + if byte == b'=' { + continue; + } + let value = value_of(byte); + if value < 0 { + return None; + } + buffer = (buffer << 6) | value as u32; + bits += 6; + if bits >= 8 { + bits -= 8; + decoded.push((buffer >> bits) as u8); + buffer &= (1 << bits) - 1; + } + } + Some(decoded) +} diff --git a/tests/detector_tests.rs b/tests/detector_tests.rs index 7d40b8c..274f77e 100644 --- a/tests/detector_tests.rs +++ b/tests/detector_tests.rs @@ -914,11 +914,11 @@ fn test_every_format_detector_fires_on_a_realistic_sample() { ), ( "GitHubTokenDetector", - "token = ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij", + "token = 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", ), ( "GitHubTokenDetector", - "refresh = ghr_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij", + "refresh = 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", ), ( "GitHubFineGrainedPATDetector", diff --git a/tests/exit_tests.rs b/tests/exit_tests.rs index f56c608..1d45736 100644 --- a/tests/exit_tests.rs +++ b/tests/exit_tests.rs @@ -613,3 +613,120 @@ fn test_exit_code_2_on_unlistable_directory_operand() { fs::remove_dir_all(&test_dir).expect("Cleanup"); } + +#[test] +fn test_trusted_detectors_ignores_repository_detector_file() { + let test_dir = setup_scan_dir("trusted_detectors_flag", false); + fs::write( + test_dir.join("detectors.toml"), + "[[detectors]]\nname = \"Nothing\"\npattern = \"ZZZNEVERZZZ\"\nfinding_type = \"x\"\nseverity = \"LOW\"\n", + ) + .expect("write repo detectors"); + fs::write(test_dir.join("creds.txt"), "AKIAABCDEFGHIJKLMNOP\n").expect("write secret"); + + let run = |extra: &[&str]| { + let mut args = vec!["scan", "creds.txt"]; + args.extend_from_slice(extra); + Command::new(env!("CARGO_BIN_EXE_key-watch")) + .current_dir(&test_dir) + .args(&args) + .env_remove("KEYWATCH_CONFIG_PATH") + .status() + .expect("run key-watch") + .code() + }; + + assert_eq!( + run(&[]), + Some(0), + "without the flag the repository detector file replaces the set" + ); + assert_eq!( + run(&["--trusted-detectors"]), + Some(1), + "--trusted-detectors must keep the built-in rules" + ); + + fs::remove_dir_all(test_dir).expect("Cleanup"); +} + +#[test] +fn test_no_repo_config_ignores_discovered_config_only() { + let test_dir = setup_scan_dir("no_repo_config_flag", false); + fs::write( + test_dir.join(".keywatch.toml"), + "[overrides.AWSKeyDetector]\nenabled = false\n", + ) + .expect("write repo config"); + fs::write(test_dir.join("creds.txt"), "AKIAABCDEFGHIJKLMNOP\n").expect("write secret"); + + let run = |extra: &[&str]| { + let mut args = vec!["scan", "creds.txt", "--no-baseline-discovery"]; + args.extend_from_slice(extra); + Command::new(env!("CARGO_BIN_EXE_key-watch")) + .current_dir(&test_dir) + .args(&args) + .env_remove("KEYWATCH_CONFIG_PATH") + .status() + .expect("run key-watch") + .code() + }; + + assert_eq!( + run(&[]), + Some(0), + "the discovered config disables the detector without the flag" + ); + assert_eq!( + run(&["--no-repo-config"]), + Some(1), + "--no-repo-config must ignore the discovered config" + ); + assert_eq!( + run(&["--trusted-detectors"]), + Some(0), + "--trusted-detectors alone must still honor the discovered config" + ); + + fs::remove_dir_all(test_dir).expect("Cleanup"); +} + +#[test] +fn test_max_file_size_skips_large_files_as_unscannable() { + let test_dir = setup_scan_dir("max_file_size", false); + // 2 MB of filler with a secret on the last line. + let mut big = "filler line\n".repeat(175_000); + big.push_str("AWS_KEY=AKIAABCDEFGHIJKLMNOP\n"); + fs::write(test_dir.join("big.txt"), &big).expect("write big file"); + + let run = |extra: &[&str]| { + let mut args = vec!["scan", "big.txt", "--no-baseline-discovery"]; + args.extend_from_slice(extra); + Command::new(env!("CARGO_BIN_EXE_key-watch")) + .current_dir(&test_dir) + .args(&args) + .env_remove("KEYWATCH_CONFIG_PATH") + .status() + .expect("run key-watch") + .code() + }; + + assert_eq!(run(&[]), Some(1), "without a cap the secret is found"); + assert_eq!( + run(&["--max-file-size", "1"]), + Some(0), + "over the cap the file is skipped, not failed" + ); + assert_eq!( + run(&["--max-file-size", "1", "--fail-on-unscannable"]), + Some(1), + "the skip is visible to --fail-on-unscannable" + ); + assert_eq!( + run(&["--max-file-size", "3"]), + Some(1), + "under the cap the file scans normally" + ); + + fs::remove_dir_all(test_dir).expect("Cleanup"); +} diff --git a/tests/scanner_tests.rs b/tests/scanner_tests.rs index 46e5c91..cc5babf 100644 --- a/tests/scanner_tests.rs +++ b/tests/scanner_tests.rs @@ -2603,3 +2603,130 @@ fn test_scan_reports_are_byte_identical_across_runs() -> Result<(), String> { let _ = fs::remove_dir_all(&dir); Ok(()) } + +#[test] +fn test_utf16_files_with_bom_are_scanned() { + // Windows tools write .env files as UTF-16 with a byte-order mark; the + // NUL bytes previously classified them as binary and hid their secrets. + let test_dir = unique_temp_dir("utf16_bom"); + let _ = fs::remove_dir_all(&test_dir); + fs::create_dir_all(&test_dir).expect("create test dir"); + + let secret = "AWS_KEY=AKIAABCDEFGHIJKLMNOP\nplain second line\n"; + let mut little_endian = vec![0xFF, 0xFE]; + for unit in secret.encode_utf16() { + little_endian.extend_from_slice(&unit.to_le_bytes()); + } + fs::write(test_dir.join("le.env"), &little_endian).expect("write utf16le"); + + let mut big_endian = vec![0xFE, 0xFF]; + for unit in secret.encode_utf16() { + big_endian.extend_from_slice(&unit.to_be_bytes()); + } + fs::write(test_dir.join("be.env"), &big_endian).expect("write utf16be"); + + let options = ScanArgs { + paths: vec![test_dir.to_str().unwrap().to_string()], + no_baseline_discovery: true, + ..Default::default() + }; + let (findings, metadata) = run_scan(&options, None).expect("run_scan should succeed"); + + let aws_hits = findings + .iter() + .filter(|finding| finding.finding_type == "AWS Access Key") + .count(); + assert_eq!(aws_hits, 2, "both byte orders must be decoded and scanned"); + assert!( + findings.iter().all(|finding| finding.line_number == 1), + "line numbers must come from the decoded text" + ); + assert!( + metadata.unscannable_files.is_empty(), + "UTF-16 files must not be reported as binary" + ); + + fs::remove_dir_all(&test_dir).expect("cleanup"); +} + +#[test] +fn test_base64_wrapped_secrets_are_decoded_and_scanned() { + let test_dir = unique_temp_dir("base64_decode"); + let _ = fs::remove_dir_all(&test_dir); + fs::create_dir_all(&test_dir).expect("create test dir"); + + // base64 of "AWS_KEY=AKIAABCDEFGHIJKLMNOP" — the encoded form defeats + // every format-anchored detector unless the run is decoded first. + fs::write( + test_dir.join("wrapped.txt"), + "config = \"QVdTX0tFWT1BS0lBQUJDREVGR0hJSktMTU5PUA==\"\n", + ) + .expect("write wrapped"); + // base64 of harmless text, plus a hex digest that is alphabet-valid but + // decodes to non-printable bytes: neither may produce a finding. + fs::write( + test_dir.join("harmless.txt"), + "a = \"bm90aGluZyBzZWNyZXQgaW4gaGVyZSBhdCBhbGw=\"\n\ + b = 8b0e7153bf7c3706d85c524e440066559a6656c90bd5482a90a29b9fa5ff5180\n", + ) + .expect("write harmless"); + + let options = ScanArgs { + paths: vec![test_dir.to_str().unwrap().to_string()], + no_baseline_discovery: true, + ..Default::default() + }; + let (findings, _) = run_scan(&options, None).expect("run_scan should succeed"); + + assert!( + findings.iter().any(|finding| { + finding.finding_type == "AWS Access Key" + && finding.file_path.ends_with("wrapped.txt") + && finding.line_number == 1 + && finding.matched_content == "AKIAABCDEFGHIJKLMNOP" + }), + "the decoded AWS key must be reported at the original line: {findings:?}" + ); + assert!( + !findings + .iter() + .any(|finding| finding.file_path.ends_with("harmless.txt") + && finding.finding_type == "AWS Access Key"), + "harmless encoded text must not produce credential findings" + ); + + fs::remove_dir_all(&test_dir).expect("cleanup"); +} + +#[test] +fn test_json_escaped_private_key_is_detected() { + // A PEM pasted into JSON carries literal backslash-n escapes on one + // line; the multiline private key detector must still match it. This + // pins behavior the base64/escaped-secrets work depends on. + let test_dir = unique_temp_dir("json_escaped_pem"); + let _ = fs::remove_dir_all(&test_dir); + fs::create_dir_all(&test_dir).expect("create test dir"); + fs::write( + test_dir.join("sa.json"), + "{\"type\": \"service_account\", \"private_key\": \"-----BEGIN PRIVATE KEY-----\\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQ\\n-----END PRIVATE KEY-----\\n\"}\n", + ) + .expect("write sa.json"); + + let options = ScanArgs { + paths: vec![test_dir.to_str().unwrap().to_string()], + no_baseline_discovery: true, + ..Default::default() + }; + let (findings, _) = run_scan(&options, None).expect("run_scan should succeed"); + + for expected in ["Private Key Content", "GCP Service Account Key"] { + assert!( + findings + .iter() + .any(|finding| finding.finding_type == expected), + "{expected} must be detected in a JSON-escaped key: {findings:?}" + ); + } + + fs::remove_dir_all(&test_dir).expect("cleanup"); +} From b037b0a5f45278b5e20f06854100487b1a26f46a Mon Sep 17 00:00:00 2001 From: PiX <69745008+pixincreate@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:55:27 +0530 Subject: [PATCH 2/5] test: align helpers with production and close review findings - The reported_by helper and three sibling closures gated matches with accepts_match (whole match), while production gates the captured value with accepts_captures. No fixture was falsely green, but a future fixture could have blessed a detector the scanner never fires. All helpers now mirror the production accept chain exactly. - The unlistable-directory tests print a SKIPPED notice when running as root, where mode 000 cannot construct the scenario, instead of passing silently while verifying nothing. - Existence-only assertions became exact: the multi-secret line pins its full finding list, the Aadhaar test pins exactly one finding, and the PEM header test names SSHPrivateKeyDetector. - README corrections: --fail-on-unscannable documents its strict-mode and --update-baseline limits again, the pre-push hook section says only HIGH/CRITICAL findings block (critical exit mode), the SARIF example writes to a file, and the staged multi-hunk limitation note is restored. - New coverage: an end-to-end pre-push test proves remote..local range semantics with the real binary against real commits (a historical secret outside the range does not block; the same commit inside the range does), and a CLI-level SARIF test validates the written report's version, ruleId, level and location. Assisted-by: Claude Fable 5 Signed-off-by: PiX <69745008+pixincreate@users.noreply.github.com> --- .keywatch-baseline.json | 292 ++++++++++++++++---------------- README.md | 7 +- src/config/tests/application.rs | 4 +- tests/detector_tests.rs | 23 ++- tests/exit_tests.rs | 56 +++++- tests/hooks_tests.rs | 75 ++++++++ tests/scanner_tests.rs | 20 ++- 7 files changed, 311 insertions(+), 166 deletions(-) diff --git a/.keywatch-baseline.json b/.keywatch-baseline.json index 272c585..cb301e4 100644 --- a/.keywatch-baseline.json +++ b/.keywatch-baseline.json @@ -416,420 +416,420 @@ }, { "file_path": "tests/detector_tests.rs", - "line_number": 413, + "line_number": 422, "finding_type": "Credit Card Number", "matched_content_hash": "4541206d542811878a9374508fe296fa321a8b56c2902736a02f388836f6e108", "plugin_name": "CreditCardDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 414, + "line_number": 423, "finding_type": "Credit Card Number", "matched_content_hash": "e2e5b50c7d336fb0ed238a9c1dd7520b847fe55b990c8f90ecb810a398854d52", "plugin_name": "CreditCardDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 415, + "line_number": 424, "finding_type": "Credit Card Number", "matched_content_hash": "13ae894eedbfba2dbd06400ba5b215ffd661885646ab86e050fb1a0d192c1c5b", "plugin_name": "CreditCardDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 416, + "line_number": 425, "finding_type": "Credit Card Number", "matched_content_hash": "7eafa99f1c4d8c35d2a84d390f9e1a9806fb518aaad3bd0c6dde7c9669e1ab97", "plugin_name": "CreditCardDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 424, + "line_number": 433, "finding_type": "Credit Card Number", "matched_content_hash": "b542aa1c9d50b5b2f457050ecab5fa170d57ae38ebaeb3ef7d4cfcf97b9d4389", "plugin_name": "CreditCardDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 424, + "line_number": 433, "finding_type": "Credit Card Number", "matched_content_hash": "52d19e14a2590e721948f58bf1e9dacf8a07049ee1b3238f193a8f15c0f997bf", "plugin_name": "CreditCardDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 447, + "line_number": 456, "finding_type": "Phone Number", "matched_content_hash": "9a90be7e1667c88c036f8f271e9746c37d99740f5762fb056090339a75ef24e6", "plugin_name": "PhoneNumberDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 447, + "line_number": 456, "finding_type": "Phone Number", "matched_content_hash": "950cf54889ab5374a01729d84c2e3cf6a086f403b24e4109f522d22d03f1fcf3", "plugin_name": "PhoneNumberDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 447, + "line_number": 456, "finding_type": "Phone Number", "matched_content_hash": "af0d73c957c0706f71d0421fd5dd40748b5663537855374b60d71c0624962486", "plugin_name": "PhoneNumberDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 458, + "line_number": 467, "finding_type": "Phone Number", "matched_content_hash": "33360227b18134e6e594a3df05104457bac9713999bb6374dc9bd74fd54c49ee", "plugin_name": "PhoneNumberDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 468, + "line_number": 477, "finding_type": "SSH Private Key", "matched_content_hash": "1b01887f477e98dd56ec542c12433dee8c323176bd532c05163823079263ba31", "plugin_name": "SSHPrivateKeyDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 469, + "line_number": 478, "finding_type": "SSH Private Key", "matched_content_hash": "32006da0b4e4851aa7946ffa4f040f364cac7c317f312b5d742ca5833dd8760f", "plugin_name": "SSHPrivateKeyDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 470, + "line_number": 479, "finding_type": "SSH Private Key", "matched_content_hash": "678a65e8968aabff441076ae306e13d4fc85b1d36d8036a10d2100c1dc40d251", "plugin_name": "SSHPrivateKeyDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 481, + "line_number": 490, "finding_type": "Random String", "matched_content_hash": "e51298df0e431de2bfdf6180e3a7b9f3f092c3e9a912facd350e8c7179936e75", "plugin_name": "RandomString" }, { "file_path": "tests/detector_tests.rs", - "line_number": 506, + "line_number": 515, "finding_type": "AWS Access Key", "matched_content_hash": "0cbae582394e61dc81d9946ed87ec44f4c83cf1167181f62cd1454eb5e2e5469", "plugin_name": "AWSKeyDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 533, + "line_number": 542, "finding_type": "Generic Key/Secret", "matched_content_hash": "f6bd37622d846ac435a7b7dcbde2347d59494dfd3c3a787604e8b91491a39c95", "plugin_name": "GenericKeyValueDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 534, + "line_number": 543, "finding_type": "Generic Key/Secret", "matched_content_hash": "c98bc65cd589366ec37fedb07e874646469d675fc56078e5b8a3e18d2a70e51e", "plugin_name": "GenericKeyValueDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 535, + "line_number": 544, "finding_type": "Password", "matched_content_hash": "af29a321067a1bbd4a7d3f56ba583751c3f0496bd204f642e1e5764766fe9a8c", "plugin_name": "PasswordDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 561, + "line_number": 570, "finding_type": "Email Address", "matched_content_hash": "ebe162e5d3cc06b42201b0bfe39fde3379a3fe97c9836631f1750f21db142808", "plugin_name": "EmailDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 565, + "line_number": 574, "finding_type": "Email Address", "matched_content_hash": "5900c8c1bad1f56492d653fe4ef68106fd227f791667e7825e183060067aedca", "plugin_name": "EmailDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 582, + "line_number": 591, "finding_type": "Phone Number", "matched_content_hash": "8f44b5588c26ec7be961e53be0c11ced694d220020312a942da3a6fa0a1297d2", "plugin_name": "PhoneNumberDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 596, + "line_number": 605, "finding_type": "Base64 Encoded String", "matched_content_hash": "097eda6585aa25d794c938beab55a00e7f2e7471a39375a3356535aa2e76efa6", "plugin_name": "Base64Detector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 602, + "line_number": 611, "finding_type": "Random String", "matched_content_hash": "f56884431ad6ea3a4fb741ca53cae314b4d3202d6f14bf69aafe86440f799403", "plugin_name": "RandomString" }, { "file_path": "tests/detector_tests.rs", - "line_number": 602, + "line_number": 611, "finding_type": "Base64 Encoded String", "matched_content_hash": "a011d6c6ecfe7ed080c7c4bfcc08efc5a3744a7be7a117c466d2536c2ee30a9b", "plugin_name": "Base64Detector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 602, + "line_number": 611, "finding_type": "Generic Key/Secret", "matched_content_hash": "c9bff1b4953e132d00a1d95477c81e7e73f15847b0e7d538c5a3a9c022858899", "plugin_name": "GenericKeyValueDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 614, + "line_number": 623, "finding_type": "Random String", "matched_content_hash": "4b98f33784e3a2e93ade7cd0d84c9bebe903fc6cae40c951d055e13e241440f4", "plugin_name": "RandomString" }, { "file_path": "tests/detector_tests.rs", - "line_number": 614, + "line_number": 623, "finding_type": "Base64 Encoded String", "matched_content_hash": "e64a42fa536515c68483f9b16cae91558748e69c9e7119bc8f32964322a67fe2", "plugin_name": "Base64Detector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 615, + "line_number": 624, "finding_type": "Random String", "matched_content_hash": "74b06d8603b542ac6581dcac272c221820e7dddb4710cd68ad2542892a6c8f2a", "plugin_name": "RandomString" }, { "file_path": "tests/detector_tests.rs", - "line_number": 615, + "line_number": 624, "finding_type": "Base64 Encoded String", "matched_content_hash": "b5a2901b320d7344b1ed5676cf19aa3bc3d14ed2e513b5b086e4f01f9d85a0da", "plugin_name": "Base64Detector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 617, + "line_number": 626, "finding_type": "Random String", "matched_content_hash": "8f492c68c7d44324c4babb1ab7c115ba365aa9e702b85cb96b2432ed6a9967f2", "plugin_name": "RandomString" }, { "file_path": "tests/detector_tests.rs", - "line_number": 617, + "line_number": 626, "finding_type": "Base64 Encoded String", "matched_content_hash": "764c955c280af7eb75cdd5ced467826bbec01bb7474379d1bbae74b91f107c97", "plugin_name": "Base64Detector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 618, + "line_number": 627, "finding_type": "Random String", "matched_content_hash": "be963ea18a45cce7e6304bbbb555137f550f13d598e71b748057ce45b0dfe762", "plugin_name": "RandomString" }, { "file_path": "tests/detector_tests.rs", - "line_number": 618, + "line_number": 627, "finding_type": "Base64 Encoded String", "matched_content_hash": "9793e393aabc5b9fe76299897081c6f14e6393998549defea81667d04bf22de0", "plugin_name": "Base64Detector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 619, + "line_number": 628, "finding_type": "Random String", "matched_content_hash": "b28d3a52df9a1c7a7d59cbfb6741fdd3d35216675d170a9bc7dbef2744f2b3ec", "plugin_name": "RandomString" }, { "file_path": "tests/detector_tests.rs", - "line_number": 619, + "line_number": 628, "finding_type": "Base64 Encoded String", "matched_content_hash": "41e2a39a10ab1a5d623590de9962259020ba4218cb57ebbcd7e90b7f87aa1546", "plugin_name": "Base64Detector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 644, + "line_number": 653, "finding_type": "Random String", "matched_content_hash": "30223dbf4c6f1329a106b8f7583cb64cf8d3f583f3eefda7041c662fc2fa87d9", "plugin_name": "RandomString" }, { "file_path": "tests/detector_tests.rs", - "line_number": 649, + "line_number": 658, "finding_type": "Generic Key/Secret", "matched_content_hash": "7e5cddbc0123c9ac48d6ec86899945d296c02974ff3fd24c89cbdb5a8e7cd74e", "plugin_name": "GenericKeyValueDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 663, + "line_number": 672, "finding_type": "Generic Key/Secret", "matched_content_hash": "31ab04ad69305e88b42ca887b277c80c84694d95c2867d19cb48cf3ea745e0fb", "plugin_name": "GenericKeyValueDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 696, + "line_number": 705, "finding_type": "Base64 Encoded String", "matched_content_hash": "e10e88c7f8ade4bef7e1129d88ee488f3ca18b10225fe63173133eda5a7e4ecf", "plugin_name": "Base64Detector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 725, + "line_number": 734, "finding_type": "Generic Key/Secret", "matched_content_hash": "3c8321f2331972cb4b8c59d16de3fae99fe333508dfd8741b332817f0c0045c4", "plugin_name": "GenericKeyValueDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 753, + "line_number": 762, "finding_type": "Random String", "matched_content_hash": "c8930ac0699b2ac783fbeeaa531643cf1f97933a23bf50c1e83ca93df8a2c86a", "plugin_name": "RandomString" }, { "file_path": "tests/detector_tests.rs", - "line_number": 753, + "line_number": 762, "finding_type": "Netlify Token", "matched_content_hash": "a689cfd83519a1d7baacb966f5703133ec6282972f9a80fd6f51295295ffcc3c", "plugin_name": "NetlifyTokenDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 753, + "line_number": 762, "finding_type": "Generic Key/Secret", "matched_content_hash": "73e2d76ad4b9f5d13b11b59426ec03ea630309c848008d952e026a173e7b2ab4", "plugin_name": "GenericKeyValueDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 762, + "line_number": 771, "finding_type": "Generic Key/Secret", "matched_content_hash": "98c20b40b5197777f3a7dd7251776a09823b3fe2e37c83ca43d36ce74ab49412", "plugin_name": "GenericKeyValueDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 770, + "line_number": 779, "finding_type": "Random String", "matched_content_hash": "0d9d69f46579dfe3ed087d18bb0225e0e8412ff02ab21bfa6e1e3b4b50b865f7", "plugin_name": "RandomString" }, { "file_path": "tests/detector_tests.rs", - "line_number": 777, + "line_number": 786, "finding_type": "Generic Key/Secret", "matched_content_hash": "d48fd8fe619073c8e7853ae91002e34b1687914ed270ebf78ffaa4e73c573bd7", "plugin_name": "GenericKeyValueDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 792, + "line_number": 801, "finding_type": "Generic Key/Secret", "matched_content_hash": "8d4694948a126f262ba0e7839b70fe61f9392018de5334500bd2656f6baf11e5", "plugin_name": "GenericKeyValueDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 792, + "line_number": 801, "finding_type": "Email Address", "matched_content_hash": "6c6b72437ed83fbd7189e7b99110468ad4356bb9b3231e1178edd2bfc48750fd", "plugin_name": "EmailDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 797, + "line_number": 806, "finding_type": "Generic Key/Secret", "matched_content_hash": "062952b9995efce6d1c968542689fdd6d3c57e8e362c428f91acaf6450a79f21", "plugin_name": "GenericKeyValueDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 801, + "line_number": 810, "finding_type": "Generic Key/Secret", "matched_content_hash": "cc716c48492ba96fbcd95232be522664f95b6d6b112af2919f1f9caae91825cf", "plugin_name": "GenericKeyValueDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 801, + "line_number": 810, "finding_type": "Razorpay API Key", "matched_content_hash": "4357d9b5d7262f331e5bf679bca5850fd5c3d0cc7a3f3f59f32b34ce22ca83a0", "plugin_name": "RazorpayAPIKeyDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 808, + "line_number": 817, "finding_type": "IP Address", "matched_content_hash": "8847dd86386a47986b28e9cc2a6975f510292e2f5a028634b6542164ff4bb6f8", "plugin_name": "IPAddressDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 808, + "line_number": 817, "finding_type": "IP Address", "matched_content_hash": "1b7185fb80bd4ae042a8de37da216e5f3c979ae606434c0b35ee7b474f07587c", "plugin_name": "IPAddressDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 808, + "line_number": 817, "finding_type": "IP Address", "matched_content_hash": "2e8d922b332b47bc5e30c20c2956e1cb947ef66523fa17aa3ff616c5719d9b61", "plugin_name": "IPAddressDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 808, + "line_number": 817, "finding_type": "IP Address", "matched_content_hash": "60b9673abd1aecb769a499d1c2a75df1f5976a0e5951621663c052d10cb70dc7", "plugin_name": "IPAddressDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 871, + "line_number": 880, "finding_type": "GCP Service Account Key", "matched_content_hash": "8b77dbdb569eb8b6189e0493fad34e25ced6a8ccbd8331ec36d5a80d2d84eb5f", "plugin_name": "GCPServiceAccountKeyDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 910, + "line_number": 919, "finding_type": "AWS Access Key", "matched_content_hash": "865d995932d48bd4ccdc53238645ff6d5d43e060bbf692d66e3370f5ce1cf746", "plugin_name": "AWSKeyDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 913, + "line_number": 922, "finding_type": "Generic Key/Secret", "matched_content_hash": "8fdc6b643e52c153a747ab70a7f80c53e79f120c7b47904413d9e5b77ca972e1", "plugin_name": "GenericKeyValueDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 913, + "line_number": 922, "finding_type": "AWS Secret Access Key", "matched_content_hash": "6b085daba2016507175f442843f08fe87148c879fb05e9c53cd6dc346ae9d75f", "plugin_name": "AWSSecretKeyDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 913, + "line_number": 922, "finding_type": "Base64 Encoded String", "matched_content_hash": "6c197abf076cfea6ed1ec1d1e35ff8f3930724213b0841bed6c2509b29784ff6", "plugin_name": "Base64Detector" @@ -857,315 +857,315 @@ }, { "file_path": "tests/detector_tests.rs", - "line_number": 925, + "line_number": 934, "finding_type": "Random String", "matched_content_hash": "8423d71d9a6fd5e09a6741c6bb3d01bc1d773a95513fa5447211f96da8a6d395", "plugin_name": "RandomString" }, { "file_path": "tests/detector_tests.rs", - "line_number": 925, + "line_number": 934, "finding_type": "GitHub Fine-Grained PAT", "matched_content_hash": "4c282b9c573df8daea4bfc25b4466c3106e1765396b102899ee7b48d4775dac5", "plugin_name": "GitHubFineGrainedPATDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 929, + "line_number": 938, "finding_type": "Random String", "matched_content_hash": "337a2f33f5738ea49abef3c1c3253f8909f924af9bc7337e0869b8795e79695d", "plugin_name": "RandomString" }, { "file_path": "tests/detector_tests.rs", - "line_number": 929, + "line_number": 938, "finding_type": "Generic Key/Secret", "matched_content_hash": "1512aba8af9492ea212748038b4868c715167a18d740e14ac82fb4ef910fb6d2", "plugin_name": "GenericKeyValueDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 929, + "line_number": 938, "finding_type": "Slack Token", "matched_content_hash": "ec24b1ae025c523258b45905e028da5592eb7378c0a756d00e6e95cae7c98c63", "plugin_name": "SlackTokenDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 945, + "line_number": 954, "finding_type": "Random String", "matched_content_hash": "2466e925ab7084a466f8cee95f8844a2212f11515d48a8053f8cb29dad5e205a", "plugin_name": "RandomString" }, { "file_path": "tests/detector_tests.rs", - "line_number": 945, + "line_number": 954, "finding_type": "Slack App Token", "matched_content_hash": "a1fdb7135bb38d021cba98feaac017fd093e4a11b1c03d00420f016b56db26bb", "plugin_name": "SlackAppTokenDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 949, + "line_number": 958, "finding_type": "Slack Webhook URL", "matched_content_hash": "f9dedf87592799bbc78d9a5a41856c71fb077483182f7ac6f1cdeb254188582b", "plugin_name": "SlackWebhookDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 958, + "line_number": 967, "finding_type": "Random String", "matched_content_hash": "dfed8fce3f43297f4774db0a5451c1f46a34b8085039e627e188b2267264c415", "plugin_name": "RandomString" }, { "file_path": "tests/detector_tests.rs", - "line_number": 958, + "line_number": 967, "finding_type": "Generic Key/Secret", "matched_content_hash": "546029563145946161a1a36146c7ae5fd9b1a7c985b794dea7e2e681bbd6a388", "plugin_name": "GenericKeyValueDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 958, + "line_number": 967, "finding_type": "New Relic API Key", "matched_content_hash": "6728d07e118225e1ed0379b523c096195f632bb98cc784eb9b82e3d01bc9ab32", "plugin_name": "NewRelicAPIKeyDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 966, + "line_number": 975, "finding_type": "Base64 Encoded String", "matched_content_hash": "a08c024ab29d4928fc16f9bb9234d63a1cca7c0db43575e5decbe3fca10a9e75", "plugin_name": "Base64Detector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 966, + "line_number": 975, "finding_type": "OpenAI API Key", "matched_content_hash": "7a83e11ca64ed2441c10eacfc2a9ece3683813e7cee863d22ce22a537b7439e8", "plugin_name": "OpenAIProjectKeyDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 970, + "line_number": 979, "finding_type": "Base64 Encoded String", "matched_content_hash": "a8e8235879316e24e151682d10003df635a18fb3bb706b07ce3589d8a8173ff0", "plugin_name": "Base64Detector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 970, + "line_number": 979, "finding_type": "Kimi/Moonshot API Key", "matched_content_hash": "f11819d7089a7701b2db1ae015c5c0a05762720502531dda40c008aeb391626d", "plugin_name": "KimiMoonshotAPIKeyDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 972, + "line_number": 981, "finding_type": "Stripe API Key", "matched_content_hash": "3f312e5c13b12595f8f3b6f74a8ae6128c1b5a8fb99ca52ea1b4455aa9477331", "plugin_name": "StripeAPIKeyDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 973, + "line_number": 982, "finding_type": "Stripe API Key", "matched_content_hash": "c6be1124d243824046a8dfeea346c27527a971b5c6e35e88e95bb5ceed26e83e", "plugin_name": "StripeAPIKeyDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 978, + "line_number": 987, "finding_type": "Aadhaar Card Number", "matched_content_hash": "0476e6cf99db1c000b7e0a433ff83591ffe0472f3156c9ebac7b11d0862f3429", "plugin_name": "AadhaarCardDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 979, + "line_number": 988, "finding_type": "PAN Card Number", "matched_content_hash": "75d7a58ed9996980fee805fb623d81193cdb5e9efddf2fa12b595171ca16530e", "plugin_name": "PANCardDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 980, + "line_number": 989, "finding_type": "Voter ID (EPIC)", "matched_content_hash": "e530110a549dd903837a0e2af06b406b01367b4572d07bffee9387334c027f20", "plugin_name": "VoterIDDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 981, + "line_number": 990, "finding_type": "Social Security Number", "matched_content_hash": "f9b230216fb066be06e470cb3c0128af42871ef7c232e1359abbb8f28283a79a", "plugin_name": "SSNDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 982, + "line_number": 991, "finding_type": "ABHA Health ID", "matched_content_hash": "f18a39a3c0ab355e4e17257dc1e75985da5a8da0ff0992267750e6c005a32cdc", "plugin_name": "ABHADetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 989, + "line_number": 998, "finding_type": "JWT Token", "matched_content_hash": "63fab2d473c9588a8eb97b746b1c9471a007965a7bcc984b50282214f6dfbca3", "plugin_name": "JWTokenDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 992, + "line_number": 1001, "finding_type": "Database URL", "matched_content_hash": "92a764286dc65f3b07669e8a6cfaa88538b7a9ddb279e506df47abc31e6fc67f", "plugin_name": "DatabaseURLDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 995, + "line_number": 1004, "finding_type": "Database URL", "matched_content_hash": "aa01165935d3f8891e4beb2392f0c5cdd5963b5fbe041e4ec9a3fb65d01e098c", "plugin_name": "DatabaseURLDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 995, + "line_number": 1004, "finding_type": "MongoDB Connection String", "matched_content_hash": "86e05b03473347c72e1cbe549b27bea5396b50802162a4ecfc7ac2fcb8fb12f8", "plugin_name": "MongoDBConnectionStringDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 995, + "line_number": 1004, "finding_type": "Email Address", "matched_content_hash": "6b49e15c059cfbe0fe9f4f9262a1ada9fd0e803098f284fd8b2f7df7a9ea7789", "plugin_name": "EmailDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 999, + "line_number": 1008, "finding_type": "SendGrid API Key", "matched_content_hash": "9211accd29c5d5e26cbc040c59724ed4f59b2ab9a6fd5ef16ec518eb103f91ed", "plugin_name": "SendGridAPIKeyDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 999, + "line_number": 1008, "finding_type": "Base64 Encoded String", "matched_content_hash": "83c33a81e18a5ec4b40f7d9314cb8d63d94c0f324938630cd44ca64230bf0e49", "plugin_name": "Base64Detector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 1003, + "line_number": 1012, "finding_type": "Random String", "matched_content_hash": "a907b01629778ccd357a125e843211d716ec4d15c8a5d4a29643aa257beaea77", "plugin_name": "RandomString" }, { "file_path": "tests/detector_tests.rs", - "line_number": 1003, + "line_number": 1012, "finding_type": "DigitalOcean API Token", "matched_content_hash": "eac5db004534e626e7b61daf10ca90fb6fe258c812cd7845f4f75c03d496c2b2", "plugin_name": "DigitalOceanTokenDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 1007, + "line_number": 1016, "finding_type": "Random String", "matched_content_hash": "c99d7da93fde37dd019d0cf73b366d4142c7e5af291ae011f627da6eef9283a2", "plugin_name": "RandomString" }, { "file_path": "tests/detector_tests.rs", - "line_number": 1007, + "line_number": 1016, "finding_type": "NPM Token", "matched_content_hash": "93fa4d52f8628507260c420eda9d4e5b8b53083eaa9c1650697f15ef6c1d4cda", "plugin_name": "NPMTokenDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 1011, + "line_number": 1020, "finding_type": "Heroku API Key", "matched_content_hash": "e8cd71caa187d4d908480c35dabd5ac3eaad88e33174fac1229926b78b715a97", "plugin_name": "HerokuAPIKeyDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 1011, + "line_number": 1020, "finding_type": "Generic Key/Secret", "matched_content_hash": "ad7c54874c2910b3ff65345d4bfba97c7aae5e95307bfa745ecf2c2c6c406b29", "plugin_name": "GenericKeyValueDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 1015, + "line_number": 1024, "finding_type": "Random String", "matched_content_hash": "f48ffeb7e4c083b563be0cff8e4158476658cfc15dc493c14d127fecf31259ae", "plugin_name": "RandomString" }, { "file_path": "tests/detector_tests.rs", - "line_number": 1015, + "line_number": 1024, "finding_type": "Groq API Key", "matched_content_hash": "659ba6bae01aab6d09327e7f2d2f808140fd6b990db40a35b1a9cb967ad4b6f4", "plugin_name": "GroqAPIKeyDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 1019, + "line_number": 1028, "finding_type": "Random String", "matched_content_hash": "15ce329320873469fe3be8c5266a0d158b2b49a3903447e1d5e0cae1cd4b54b3", "plugin_name": "RandomString" }, { "file_path": "tests/detector_tests.rs", - "line_number": 1019, + "line_number": 1028, "finding_type": "Hugging Face Token", "matched_content_hash": "7e620527f0ee998f548f41283f1fd607497f176e8b801154231db33265b5046d", "plugin_name": "HuggingFaceTokenDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 1023, + "line_number": 1032, "finding_type": "GitLab Personal Access Token", "matched_content_hash": "1c920d68e02f0cf8f21a1be01c6e91256e4e7e60461406a4bb103af5a4e99539", "plugin_name": "GitLabPersonalAccessTokenDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 1027, + "line_number": 1036, "finding_type": "HashiCorp Vault Token", "matched_content_hash": "0e290623d9b5c162f39a3b894bc2a500c542cb879e2b295d321677485eb3315b", "plugin_name": "HashicorpVaultTokenDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 1031, + "line_number": 1040, "finding_type": "Google OAuth Token", "matched_content_hash": "14bcf91874e68f421e946b8414c43fbe04f1d5ee1b97fd58d4d2bb10a08cb82b", "plugin_name": "GoogleOAuthTokenDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 1035, + "line_number": 1044, "finding_type": "Random String", "matched_content_hash": "0b71a8751aa19a3057b98d957c12b1d0f713b6fed41fb2c1eb244ff45c0942ae", "plugin_name": "RandomString" }, { "file_path": "tests/detector_tests.rs", - "line_number": 1035, + "line_number": 1044, "finding_type": "Shopify Access Token", "matched_content_hash": "a954f6f8f04f0be943d4b794a5d1881ccab4704c42cc7f1032988e969c75e1c8", "plugin_name": "ShopifyAccessTokenDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 1039, + "line_number": 1048, "finding_type": "Base64 Encoded String", "matched_content_hash": "554736805d6e994bf786b21a30653266d29aa65775bbc17eb9f910d2556da91b", "plugin_name": "Base64Detector" @@ -1403,203 +1403,203 @@ }, { "file_path": "tests/scanner_tests.rs", - "line_number": 299, + "line_number": 306, "finding_type": "Password", "matched_content_hash": "9f380d00b53e154b8421fb61924bbe35b6f01dccd608f5298f0e35764ee0b654", "plugin_name": "PasswordDetector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 331, + "line_number": 338, "finding_type": "Password", "matched_content_hash": "c505b74b9daf07fb40c38c619a4c0fdf5d807e85223142d6cd19923e7b84e15d", "plugin_name": "PasswordDetector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 332, + "line_number": 339, "finding_type": "Password", "matched_content_hash": "ca52bf35c9f7b4506aaee67e2aede5c5b09b9b258e850a74e7af0c078e932960", "plugin_name": "PasswordDetector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 453, + "line_number": 460, "finding_type": "Generic Key/Secret", "matched_content_hash": "bbc84628b4a0a5db38b2d8ed22fb8b5f4a880a6b0037ba055f9007c6192d8f05", "plugin_name": "GenericKeyValueDetector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 454, + "line_number": 461, "finding_type": "Generic Key/Secret", "matched_content_hash": "d1456485b2f315b81fcaffef101a4b398ea558b82bfe6ecee763ef7c327608e7", "plugin_name": "GenericKeyValueDetector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 490, + "line_number": 497, "finding_type": "Generic Key/Secret", "matched_content_hash": "795c0808e6128ceee90a9dada1ad2ad243d07534def7532d5adf7e82a7237374", "plugin_name": "GenericKeyValueDetector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 529, + "line_number": 536, "finding_type": "Generic Key/Secret", "matched_content_hash": "ead01d09cbf0176f24f604a4a4f955b32c34b155fe614e81ecc52f2c6fe89a3f", "plugin_name": "GenericKeyValueDetector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 660, + "line_number": 667, "finding_type": "Aadhaar Card Number", "matched_content_hash": "73e8a0879ef4b4acf94b2c188c0b620e4dd198bef454f6270118d63fd87ff4a3", "plugin_name": "AadhaarCardDetector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 660, + "line_number": 667, "finding_type": "Aadhaar Card Number", "matched_content_hash": "4714ac4f70659987acae4a4760aedecd3924449f855e3be8115a51eb1ad35f93", "plugin_name": "AadhaarCardDetector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 660, + "line_number": 667, "finding_type": "Aadhaar Card Number", "matched_content_hash": "c941e88b8d0be7288dcc792a8ecfae22482b8a83ea4494f1ca0e36293009730b", "plugin_name": "AadhaarCardDetector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 687, + "line_number": 695, "finding_type": "Voter ID (EPIC)", "matched_content_hash": "e530110a549dd903837a0e2af06b406b01367b4572d07bffee9387334c027f20", "plugin_name": "VoterIDDetector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 687, + "line_number": 695, "finding_type": "Voter ID (EPIC)", "matched_content_hash": "8d6bf65189e5bdf52955b4a1592eb9c2fb0560709152317a1dabc36971c38f30", "plugin_name": "VoterIDDetector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 711, + "line_number": 719, "finding_type": "PAN Card Number", "matched_content_hash": "75d7a58ed9996980fee805fb623d81193cdb5e9efddf2fa12b595171ca16530e", "plugin_name": "PANCardDetector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 711, + "line_number": 719, "finding_type": "PAN Card Number", "matched_content_hash": "4dd22fad4bf96036ceea29ca253be8d72851cc289d7e694c9b208b7370812ee6", "plugin_name": "PANCardDetector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 735, + "line_number": 743, "finding_type": "ABHA Health ID", "matched_content_hash": "25b618101c3bfdb4438894aa540e108cd150903bd24da57ac24f0d4174c0fe7e", "plugin_name": "ABHADetector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 735, + "line_number": 743, "finding_type": "ABHA Health ID", "matched_content_hash": "397d0bc36d77ccfc6f74c2eaa6615bb75c3fe3bdc3df26e6f53f413cfcdacdb3", "plugin_name": "ABHADetector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 760, + "line_number": 768, "finding_type": "Aadhaar Card Number", "matched_content_hash": "8d7ab03162973f8bc2baf7c8556af862841d23c4d66f7f18f8cf5c5777f8b263", "plugin_name": "AadhaarCardDetector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 760, + "line_number": 768, "finding_type": "ABHA Health ID", "matched_content_hash": "89deb2b58721d6303daa4a62f2dee30b9a388afb905fc4d3595ec88ca875fb15", "plugin_name": "ABHADetector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 760, + "line_number": 768, "finding_type": "PAN Card Number", "matched_content_hash": "18fe123b5e00bbed7228c4ededc8a4e6e0bfd59169af97ec2e62b595eac11dde", "plugin_name": "PANCardDetector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 865, + "line_number": 873, "finding_type": "Random String", "matched_content_hash": "cbe1ce18b874bc08437699d863cd422c49e104462e7d5ac6bd390acc0d7c973a", "plugin_name": "RandomString" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 865, + "line_number": 873, "finding_type": "Google API Key", "matched_content_hash": "aec6660855470791a5b4f5a6c1ac4b61e61d2b942b1cdec400fb7811743798d0", "plugin_name": "GoogleAPIKeyDetector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 912, + "line_number": 920, "finding_type": "Password", "matched_content_hash": "67ef748345ad7f183084a7449ec05906e648c882400b9d940f2fadec23a7b197", "plugin_name": "PasswordDetector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 1007, + "line_number": 1015, "finding_type": "AWS Access Key", "matched_content_hash": "05c0aace2b76ca255ed3a7a953016d981477226dccc3b0e709d00174c8bc48b5", "plugin_name": "AWSKeyDetector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 1650, + "line_number": 1658, "finding_type": "Generic Key/Secret", "matched_content_hash": "97894929682fc00219686473cbcfa3731d73b23e88ffa7c19a511c1bbfa18aa5", "plugin_name": "GenericKeyValueDetector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 1650, + "line_number": 1658, "finding_type": "AWS Secret Access Key", "matched_content_hash": "58db90a4f2acf80492ed15e73ad77de6c84b9aace839d821931aa0474c898386", "plugin_name": "AWSSecretKeyDetector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 1854, + "line_number": 1862, "finding_type": "AWS Access Key", "matched_content_hash": "357b7fb7890985d4c94a43012d1f7aefe25757f36d8810388357993bb38bd8e7", "plugin_name": "AWSKeyDetector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 2106, + "line_number": 2114, "finding_type": "AWS Access Key", "matched_content_hash": "0cbae582394e61dc81d9946ed87ec44f4c83cf1167181f62cd1454eb5e2e5469", "plugin_name": "AWSKeyDetector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 2407, + "line_number": 2415, "finding_type": "Password", "matched_content_hash": "6121378fbc25183476235d474ee367f7dbd0bbe3d96642daaeb483b5e9108cdb", "plugin_name": "PasswordDetector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 2521, + "line_number": 2529, "finding_type": "Base64 Encoded String", "matched_content_hash": "37f93597692f90e64e009b94ce81237f0f5a6d03ffce8085e91814efe71e4431", "plugin_name": "Base64Detector" @@ -1648,84 +1648,84 @@ }, { "file_path": "tests/detector_tests.rs", - "line_number": 917, + "line_number": 926, "finding_type": "GitHub Token", "matched_content_hash": "b16bd9f07a6786888caf195fb2e6713ab305d373c68c05c878cfbbea71855cb2", "plugin_name": "GitHubTokenDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 917, + "line_number": 926, "finding_type": "Generic Key/Secret", "matched_content_hash": "45c883873c36ab6f5a63ecbcd3c44b7e8aa8c12f75c12b746083ebe6ebc493b4", "plugin_name": "GenericKeyValueDetector" }, { "file_path": "tests/detector_tests.rs", - "line_number": 921, + "line_number": 930, "finding_type": "GitHub Token", "matched_content_hash": "275e1a2a3f41699adf8ac2b219f5e689a381a12f0e9f195e322c978bffb56ba6", "plugin_name": "GitHubTokenDetector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 2615, + "line_number": 2623, "finding_type": "Generic Key/Secret", "matched_content_hash": "89c67ad26d243a255386bb51b4336359e2f00092aae3ac83498cccba38b71972", "plugin_name": "GenericKeyValueDetector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 2658, + "line_number": 2666, "finding_type": "Generic Key/Secret", "matched_content_hash": "d6d60d3e50a63a67cc37ebfac022834f34a7ec0c74d9b5952b8b8ccd50b5f1ff", "plugin_name": "GenericKeyValueDetector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 2662, + "line_number": 2670, "finding_type": "Base64 Encoded String", "matched_content_hash": "f6c41d46bc806e05582dd7504058eb98e514473b4637fcbd3f1f46a12d6a1399", "plugin_name": "Base64Detector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 2662, + "line_number": 2670, "finding_type": "Generic Key/Secret", "matched_content_hash": "e1a8fe4e1ccad188f7428469d1d0f7c55ce10a6290696ac32472f6ff40c2d2b6", "plugin_name": "GenericKeyValueDetector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 2669, + "line_number": 2677, "finding_type": "Base64 Encoded String", "matched_content_hash": "de00639949784d409b3d3e7b866432f64ca1fe35e047b312a012bbc0a19501b9", "plugin_name": "Base64Detector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 2711, + "line_number": 2719, "finding_type": "SSH Private Key", "matched_content_hash": "1b01887f477e98dd56ec542c12433dee8c323176bd532c05163823079263ba31", "plugin_name": "SSHPrivateKeyDetector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 2711, + "line_number": 2719, "finding_type": "Private Key Content", "matched_content_hash": "d3022d6b263b30e0ab3e0d77220f81e0bebfcc0b723f84f77d2d82d306d22e50", "plugin_name": "PrivateKeyDetector" }, { "file_path": "tests/scanner_tests.rs", - "line_number": 2711, + "line_number": 2719, "finding_type": "Base64 Encoded String", "matched_content_hash": "81623b8aeb6fe8c68c0a53b2d7a12ecf1d044b3ac15886fdaced0eb177f385dc", "plugin_name": "Base64Detector" }, { "file_path": "tests/exit_tests.rs", - "line_number": 699, + "line_number": 706, "finding_type": "Generic Key/Secret", "matched_content_hash": "e1a8fe4e1ccad188f7428469d1d0f7c55ce10a6290696ac32472f6ff40c2d2b6", "plugin_name": "GenericKeyValueDetector" diff --git a/README.md b/README.md index f33f3e6..ade63f8 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Control the output: ```sh key-watch scan . --verbose # print the full JSON report key-watch scan . --output report.json # write the report to a file -key-watch scan . --format sarif # write SARIF instead of JSON +key-watch scan . --format sarif --output report.sarif # write SARIF to a file ``` By default, KeyWatch prints one line per finding with the file, line number, and a redacted preview. @@ -65,7 +65,7 @@ Reports never contain the full matched text unless you pass `--show-secrets`. | ------ | ------- | | `--exclude ` | Skip paths that match these comma-separated glob patterns | | `--exit-mode ` | `strict` fails on any finding (default), `critical` fails only on HIGH or CRITICAL findings, `always` never fails | -| `--fail-on-unscannable` | Fail when a file or directory could not be read | +| `--fail-on-unscannable` | Fail when a file or directory could not be read; applies in `strict` exit mode and not with `--update-baseline` | | `--baseline ` | Use a specific baseline file | | `--no-baseline-discovery` | Do not look for a baseline file automatically | | `--update-baseline` | Record the current findings in the baseline instead of reporting them | @@ -83,6 +83,7 @@ Notes: They contain checksums, not credentials. - `--staged` reads the content you staged with `git add`, not the files on disk. A secret that is staged but already removed from the working copy is still found. + A secret whose lines were staged in separate commits can span change hunks the diff never shows together; run `key-watch scan .` on the tree to catch that case. - `--git-history` scans every branch and tag. Use `--rev-range` to scan only a range of commits. - A scan path that does not exist, is a symbolic link, or cannot be read is an error. @@ -104,7 +105,7 @@ KeyWatch installs two git hooks: A secret in staged content blocks the commit. Findings in lines you did not change never block a commit. - The **pre-push** hook scans the commits you are about to push. - A secret in those commits blocks the push. + It runs in `critical` exit mode, so HIGH and CRITICAL findings block the push; MEDIUM and LOW findings are reported but do not block. Uncommitted files never block a push. Install and remove hooks inside a repository: diff --git a/src/config/tests/application.rs b/src/config/tests/application.rs index 5c0e95b..3e93768 100644 --- a/src/config/tests/application.rs +++ b/src/config/tests/application.rs @@ -293,8 +293,8 @@ validate = "luhn" assert!(gift.has_keywords("giftcard = ABCD-EFGH-NOPQ")); let reported = |line: &str, det: &crate::detector::Detector| { det.regex - .find_iter(line) - .any(|m| det.accepts_match(m.as_str())) + .captures_iter(line) + .any(|captures| det.accepts_captures(&captures)) }; assert!( !reported("giftcard = ABCD-EFGH-JKLM", gift), diff --git a/tests/detector_tests.rs b/tests/detector_tests.rs index 274f77e..cb96fc3 100644 --- a/tests/detector_tests.rs +++ b/tests/detector_tests.rs @@ -243,8 +243,8 @@ fn test_generic_key_value_ignores_unquoted_identifier_assignments() { let is_reported = |line: &str| { generic .regex - .find_iter(line) - .any(|m| generic.accepts_match(m.as_str())) + .captures_iter(line) + .any(|captures| generic.accepts_captures(&captures)) }; // Rust/Python/Go variable bindings are not credentials. @@ -367,8 +367,8 @@ fn test_password_detector_ignores_rust_expressions() { let is_reported = |line: &str| { password_detector .regex - .find_iter(line) - .any(|m| password_detector.accepts_match(m.as_str())) + .captures_iter(line) + .any(|captures| password_detector.accepts_captures(&captures)) }; // Rust expressions are plumbing, not credentials. @@ -396,13 +396,22 @@ fn test_password_detector_ignores_rust_expressions() { } /// Helper: does any built-in detector report this line? +/// +/// Mirrors production exactly: keyword gate, then captures_iter with +/// accepts_captures, so entropy and validators judge the captured value. +/// A whole-match variant here once diverged from the scanner and could +/// bless fixtures the real scan never reports. fn reported_by(line: &str) -> Vec { let detectors = key_watch::detector::initialize_detectors().expect("load detectors"); let lowered = line.to_lowercase(); detectors .iter() .filter(|d| d.has_keywords(&lowered)) - .filter(|d| d.regex.find_iter(line).any(|m| d.accepts_match(m.as_str()))) + .filter(|d| { + d.regex + .captures_iter(line) + .any(|captures| d.accepts_captures(&captures)) + }) .map(|d| d.name.clone()) .collect() } @@ -470,8 +479,8 @@ fn test_pkcs8_private_key_headers_are_detected() { "-----BEGIN RSA PRIVATE KEY-----", ] { assert!( - !reported_by(header).is_empty(), - "should detect private key header: {header}" + reported_by(header).contains(&"SSHPrivateKeyDetector".to_string()), + "the header detector must report: {header}" ); } } diff --git a/tests/exit_tests.rs b/tests/exit_tests.rs index 1d45736..4d9ade9 100644 --- a/tests/exit_tests.rs +++ b/tests/exit_tests.rs @@ -552,7 +552,10 @@ fn test_unlistable_directory_is_unscannable_and_fails_with_flag() { fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).expect("Lock dir"); if fs::read_dir(&locked).is_ok() { // Running as root (e.g. in a container): mode 000 does not make the - // directory unlistable, so the scenario cannot be constructed. + // directory unlistable, so the scenario cannot be constructed and + // this test verifies nothing. Said out loud so a root CI runner does + // not silently lose the contract; GitHub-hosted runners are not root. + eprintln!("SKIPPED: unlistable-directory scenario needs a non-root user"); fs::remove_dir_all(&test_dir).expect("Cleanup"); return; } @@ -590,7 +593,11 @@ fn test_exit_code_2_on_unlistable_directory_operand() { fs::create_dir(&locked).expect("Create locked dir"); fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).expect("Lock dir"); if fs::read_dir(&locked).is_ok() { - // Running as root: mode 000 does not make the directory unlistable. + // Running as root (e.g. in a container): mode 000 does not make the + // directory unlistable, so the scenario cannot be constructed and + // this test verifies nothing. Said out loud so a root CI runner does + // not silently lose the contract; GitHub-hosted runners are not root. + eprintln!("SKIPPED: unlistable-directory scenario needs a non-root user"); fs::remove_dir_all(&test_dir).expect("Cleanup"); return; } @@ -730,3 +737,48 @@ fn test_max_file_size_skips_large_files_as_unscannable() { fs::remove_dir_all(test_dir).expect("Cleanup"); } + +#[test] +fn test_sarif_format_through_the_cli() { + let test_dir = setup_scan_dir("sarif_cli", false); + fs::write(test_dir.join("creds.txt"), "AKIAABCDEFGHIJKLMNOP\n").expect("write secret"); + let report_path = test_dir.join("report.sarif"); + + let status = Command::new(env!("CARGO_BIN_EXE_key-watch")) + .current_dir(&test_dir) + .args([ + "scan", + "creds.txt", + "--no-baseline-discovery", + "--format", + "sarif", + "--output", + report_path.to_str().unwrap(), + ]) + .env_remove("KEYWATCH_CONFIG_PATH") + .status() + .expect("run key-watch"); + assert_eq!( + status.code(), + Some(1), + "the finding still drives the exit code" + ); + + let sarif: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&report_path).expect("read report")) + .expect("report must be valid JSON"); + assert_eq!(sarif["version"], "2.1.0"); + let result = &sarif["runs"][0]["results"][0]; + assert_eq!(result["ruleId"], "AWS Access Key"); + assert_eq!(result["level"], "error"); + assert_eq!( + result["locations"][0]["physicalLocation"]["artifactLocation"]["uri"], + "creds.txt" + ); + assert_eq!( + result["locations"][0]["physicalLocation"]["region"]["startLine"], + 1 + ); + + fs::remove_dir_all(test_dir).expect("Cleanup"); +} diff --git a/tests/hooks_tests.rs b/tests/hooks_tests.rs index ef94b8f..3159311 100644 --- a/tests/hooks_tests.rs +++ b/tests/hooks_tests.rs @@ -894,3 +894,78 @@ fn test_cli_init_conflicts_with_scan_targets() { "init should reject extra positional scan targets" ); } + +/// End-to-end range semantics with the real binary and real git: only the +/// commits between the remote tip and the local tip are scanned. +#[cfg(unix)] +#[test] +fn test_pre_push_scans_only_the_pushed_range_end_to_end() { + let temp_dir = unique_temp_dir("pre_push_range_e2e"); + fs::create_dir_all(&temp_dir).expect("create temp dir"); + let git = real_git_path(); + let run_git = |args: &[&str]| { + let output = std::process::Command::new(&git) + .args(args) + .current_dir(&temp_dir) + .output() + .expect("run git"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().to_string() + }; + + run_git(&["init", "--quiet"]); + run_git(&["config", "user.email", "test@example.com"]); + run_git(&["config", "user.name", "test"]); + run_git(&["config", "core.hooksPath", ".git/hooks"]); + + // Commit 1 carries the secret and plays the already-pushed remote tip. + fs::write( + temp_dir.join("old.txt"), + "master_api_key = \"abcdefghijklmnopqrstuvwxyz1234\"\n", + ) + .expect("write old secret"); + run_git(&["add", "old.txt"]); + run_git(&["commit", "--quiet", "-m", "already pushed"]); + let remote_tip = run_git(&["rev-parse", "HEAD"]); + + // Commit 2 is clean and is what the push publishes. + fs::write(temp_dir.join("new.txt"), "nothing secret\n").expect("write clean file"); + run_git(&["add", "new.txt"]); + run_git(&["commit", "--quiet", "-m", "clean change"]); + let local_tip = run_git(&["rev-parse", "HEAD"]); + + let hook = generate_pre_push_hook(&hook_install_args(HookType::PrePush, None, None, None)); + + // Pushing only the clean commit must pass even though history holds a + // secret: the range excludes the remote tip. + let output = run_hook_with_packaged_keywatch( + &hook, + &temp_dir, + &format!("refs/heads/main {local_tip} refs/heads/main {remote_tip}\n"), + ); + assert_eq!( + output.status.code(), + Some(0), + "a clean pushed range must pass: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // Pushing the secret-bearing commit itself must block. + let output = run_hook_with_packaged_keywatch( + &hook, + &temp_dir, + &format!("refs/heads/main {remote_tip} refs/heads/main {ZERO_SHA}\n"), + ); + assert_eq!( + output.status.code(), + Some(1), + "a pushed secret must block: {}", + String::from_utf8_lossy(&output.stderr) + ); + + fs::remove_dir_all(&temp_dir).expect("cleanup temp dir"); +} diff --git a/tests/scanner_tests.rs b/tests/scanner_tests.rs index cc5babf..2a0d572 100644 --- a/tests/scanner_tests.rs +++ b/tests/scanner_tests.rs @@ -275,9 +275,16 @@ fn test_multiple_detections_in_line() { }; let (findings, _) = run_scan(&options, None).expect("run_scan should succeed"); - assert!( - findings.len() >= 2, - "Should find multiple secrets on one line" + let types: Vec<&str> = findings + .iter() + .map(|finding| finding.finding_type.as_str()) + .collect(); + assert_eq!( + types, + vec!["Password", "Email Address"], + // The password value swallows the rest of the line, the email is its + // own finding, and the AKIA fragment is too short for any detector. + "every secret on the line must be reported exactly once" ); fs::remove_file(test_file).expect("Cleanup"); @@ -671,9 +678,10 @@ fn test_detect_aadhaar() { .iter() .filter(|f| f.finding_type == "Aadhaar Card Number") .collect(); - assert!( - !aadhaar_findings.is_empty(), - "Should detect Aadhaar numbers" + assert_eq!( + aadhaar_findings.len(), + 1, + "exactly the Verhoeff-valid, labeled number is reported: {findings:?}" ); fs::remove_file(test_file).expect("Cleanup"); From 980a9efb50cb37c3c51bb388cc63ab06d58db5ed Mon Sep 17 00:00:00 2001 From: PiX <69745008+pixincreate@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:00:16 +0530 Subject: [PATCH 3/5] docs(readme): align tables and document the new scan behaviors Pad the option, exit-code, hook and Action tables to aligned columns, and add notes for the behaviors this branch introduces: UTF-16 files with a byte-order mark are decoded and scanned, base64 runs are decoded and rescanned at their original line, and GitHub tokens are checked against their built-in checksum. Assisted-by: Claude Fable 5 Signed-off-by: PiX <69745008+pixincreate@users.noreply.github.com> --- README.md | 73 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 39 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index ade63f8..0123728 100644 --- a/README.md +++ b/README.md @@ -61,21 +61,21 @@ Reports never contain the full matched text unless you pass `--show-secrets`. ### Scan options -| Option | Purpose | -| ------ | ------- | -| `--exclude ` | Skip paths that match these comma-separated glob patterns | -| `--exit-mode ` | `strict` fails on any finding (default), `critical` fails only on HIGH or CRITICAL findings, `always` never fails | -| `--fail-on-unscannable` | Fail when a file or directory could not be read; applies in `strict` exit mode and not with `--update-baseline` | -| `--baseline ` | Use a specific baseline file | -| `--no-baseline-discovery` | Do not look for a baseline file automatically | -| `--update-baseline` | Record the current findings in the baseline instead of reporting them | -| `--prune-baseline` | With `--update-baseline`, also remove baseline entries that no longer match anything | -| `--config ` | Use a specific `.keywatch.toml` configuration file | -| `--trusted-detectors` | Ignore a `detectors.toml` supplied by the scanned repository; use only built-in or operator rules | -| `--no-repo-config` | Do not look for `.keywatch.toml` in the scanned tree; an explicit `--config` still loads | -| `--no-config-discovery` | Shorthand for `--trusted-detectors` plus `--no-repo-config`; the installed hooks pass it | -| `--show-secrets` | Include the full matched text in reports | -| `--max-file-size ` | Skip files larger than this size and report them as unscannable | +| Option | Purpose | +| ------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `--exclude ` | Skip paths that match these comma-separated glob patterns | +| `--exit-mode ` | `strict` fails on any finding (default), `critical` fails only on HIGH or CRITICAL findings, `always` never fails | +| `--fail-on-unscannable` | Fail when a file or directory could not be read; applies in `strict` exit mode and not with `--update-baseline` | +| `--baseline ` | Use a specific baseline file | +| `--no-baseline-discovery` | Do not look for a baseline file automatically | +| `--update-baseline` | Record the current findings in the baseline instead of reporting them | +| `--prune-baseline` | With `--update-baseline`, also remove baseline entries that no longer match anything | +| `--config ` | Use a specific `.keywatch.toml` configuration file | +| `--trusted-detectors` | Ignore a `detectors.toml` supplied by the scanned repository; use only built-in or operator rules | +| `--no-repo-config` | Do not look for `.keywatch.toml` in the scanned tree; an explicit `--config` still loads | +| `--no-config-discovery` | Shorthand for `--trusted-detectors` plus `--no-repo-config`; the installed hooks pass it | +| `--show-secrets` | Include the full matched text in reports | +| `--max-file-size ` | Skip files larger than this size and report them as unscannable | Notes: @@ -88,14 +88,19 @@ Notes: Use `--rev-range` to scan only a range of commits. - A scan path that does not exist, is a symbolic link, or cannot be read is an error. The scan never reports a clean result for input it could not read. +- Files that start with a UTF-16 byte-order mark are decoded and scanned. + Other files that contain NUL bytes are treated as binary and reported as unscannable. +- Base64 runs of 24 or more characters are decoded, and the decoded text is scanned as well. + An encoded credential is reported at the line that contains it. +- GitHub tokens are checked against their built-in checksum, so lookalike strings do not appear in results. ### Exit codes -| Code | Meaning | -| ---- | ------- | -| 0 | No secrets found, or `--exit-mode always` | -| 1 | Secrets found, or an unreadable file with `--fail-on-unscannable` | -| 2 | Invalid input, configuration error, or runtime error | +| Code | Meaning | +| ---- | ----------------------------------------------------------------- | +| 0 | No secrets found, or `--exit-mode always` | +| 1 | Secrets found, or an unreadable file with `--fail-on-unscannable` | +| 2 | Invalid input, configuration error, or runtime error | ## Git hooks @@ -126,11 +131,11 @@ key-watch hook uninstall pre-commit --global ### Hook options -| Option | Applies to | Purpose | -| ------ | ---------- | ------- | -| `--exclude ` | pre-commit | Skip staged paths that match these patterns | -| `--allowed-repos ` | pre-push | Allow pushes only to these repositories | -| `--blocked-repos ` | pre-push | Block pushes to these repositories | +| Option | Applies to | Purpose | +| ------------------------ | ---------- | ------------------------------------------- | +| `--exclude ` | pre-commit | Skip staged paths that match these patterns | +| `--allowed-repos ` | pre-push | Allow pushes only to these repositories | +| `--blocked-repos ` | pre-push | Block pushes to these repositories | ### How hooks behave @@ -216,15 +221,15 @@ The Action installs a released KeyWatch binary, verifies its checksum, and write It supports Linux x64 and macOS runners. Pin an exact release tag or commit SHA when you need a fixed version. -| Input | Default | Purpose | -| ----- | ------- | ------- | -| `version` | Action release version | Exact KeyWatch release to install | -| `paths` | `.` | Space-separated paths or globs to scan | -| `args` | empty | Extra scanner arguments; Action-managed options cannot be overridden | -| `exit-mode` | `strict` | `strict`, `critical`, or `always` | -| `output` | temporary file | Path for the JSON report | -| `config` | empty | Path to a trusted `.keywatch.toml` | -| `verbose` | `false` | Deprecated; enabling it is rejected to keep secrets out of logs | +| Input | Default | Purpose | +| ----------- | ---------------------- | -------------------------------------------------------------------- | +| `version` | Action release version | Exact KeyWatch release to install | +| `paths` | `.` | Space-separated paths or globs to scan | +| `args` | empty | Extra scanner arguments; Action-managed options cannot be overridden | +| `exit-mode` | `strict` | `strict`, `critical`, or `always` | +| `output` | temporary file | Path for the JSON report | +| `config` | empty | Path to a trusted `.keywatch.toml` | +| `verbose` | `false` | Deprecated; enabling it is rejected to keep secrets out of logs | The Action exposes `findings-count` and `exit-code` as step outputs. From 1f3203db90e51d32c6dc6b195680703ba8b71f9f Mon Sep 17 00:00:00 2001 From: PiX <69745008+pixincreate@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:03:43 +0530 Subject: [PATCH 4/5] docs(readme): restore the architecture section The plain-language rewrite dropped the architecture diagrams and their explanations, keeping only a pointer to docs/architecture. The images carry real orientation value, so the section returns with the diagrams and the explanations rewritten in the same plain style as the rest of the file. Assisted-by: Claude Fable 5 Signed-off-by: PiX <69745008+pixincreate@users.noreply.github.com> --- README.md | 46 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0123728..3462b7f 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,49 @@ rm -f ~/.local/bin/key-watch In both cases, remove the `key-watch init` line from your shell configuration file if you added one. +## Architecture + +KeyWatch is a single Rust binary. +`main.rs` starts the program and maps every validation, configuration, or runtime failure to exit code 2. +Scans exit with code 0 or 1. +Separate modules own detector loading, scanning, baselines, reports, and hooks. + +### Modules and adapters + +![KeyWatch CLI module and adapter architecture](docs/architecture/cli-modules.svg) + +Green boxes are internal modules. +Blue boxes are entry and output boundaries. +Yellow boxes are external adapters such as git and the installed hook scripts, which call `key-watch scan` themselves. + +### Scan pipeline + +![KeyWatch scan pipeline](docs/architecture/scan-pipeline.svg) + +Path scans collect files and scan them in parallel. +Stdin and git-based scans stream their input in overlapping chunks. +`--update-baseline` writes the baseline instead of producing a report. + +### Detector and configuration trust + +![KeyWatch detector and configuration trust boundaries](docs/architecture/detector-config-trust.svg) + +Detector rules and repository configuration are separate systems. +External detector sources take precedence, and the compiled-in rules are the fallback. +Trusted scans ignore files supplied by the scanned repository but still honor explicit configuration and operator-supplied detector sources. + +### Core data types + +- **Detector** — one named rule: pattern, finding type, severity, optional keywords, entropy threshold, allowlist, and validator. +- **Finding** — one detected secret: file path, line number, finding type, severity, matched content, and the detector that produced it. +- **Severity** — `Critical`, `High`, `Medium`, `Low`. +- **KeywatchConfig** — parsed `.keywatch.toml`: custom rules, per-detector overrides, and exclude patterns. +- **Baseline** — versioned fingerprint entries that filter out known findings. +- **ScanMetadata** — files scanned, total lines, and skipped files, reported alongside findings. + +The diagram sources are in `docs/architecture/*.d2`. +After editing them, run `scripts/render-diagrams.sh render` with D2 v0.7.1, or `scripts/render-diagrams.sh check` to detect stale images. + ## Development ```sh @@ -269,9 +312,6 @@ cargo fmt cargo clippy ``` -Architecture diagrams live in `docs/architecture/`. -Edit the `.d2` sources and run `scripts/render-diagrams.sh render` to update the rendered images. - ## License KeyWatch is licensed under the GPL-3.0-only license. From af256b0ba49309c4fcc06a8e67d5325995790657 Mon Sep 17 00:00:00 2001 From: PiX <69745008+pixincreate@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:12:27 +0530 Subject: [PATCH 5/5] docs(architecture): update diagrams for the new scan behavior The scan pipeline gained UTF-16 decoding, the file size cap, content validators and the base64 rescan stage, and the trust diagram still described trusted mode as one flag after --trusted-detectors and --no-repo-config split it. Both sources are updated and re-rendered with the pinned D2 v0.7.1; the module diagram is unchanged because nothing moved at that level. The baseline covers the re-rendered SVGs' shifted font data. Assisted-by: Claude Fable 5 Signed-off-by: PiX <69745008+pixincreate@users.noreply.github.com> --- .keywatch-baseline.json | 21 +++ docs/architecture/detector-config-trust.d2 | 4 +- docs/architecture/detector-config-trust.svg | 156 +++++++++--------- docs/architecture/scan-pipeline.d2 | 4 +- docs/architecture/scan-pipeline.svg | 168 ++++++++++---------- 5 files changed, 187 insertions(+), 166 deletions(-) diff --git a/.keywatch-baseline.json b/.keywatch-baseline.json index cb301e4..06b4f93 100644 --- a/.keywatch-baseline.json +++ b/.keywatch-baseline.json @@ -1743,6 +1743,27 @@ "finding_type": "Base64 Encoded String", "matched_content_hash": "69abc601294453463474ddc77dcbcf003202a1dfa2e1f886d880151676c42e5f", "plugin_name": "Base64Detector" + }, + { + "file_path": "docs/architecture/detector-config-trust.svg", + "line_number": 7, + "finding_type": "Base64 Encoded String", + "matched_content_hash": "b7141e1d4f3ed2f03840807e34d0514f9142d1617d99e6e284495a2391ee5a79", + "plugin_name": "Base64Detector" + }, + { + "file_path": "docs/architecture/scan-pipeline.svg", + "line_number": 7, + "finding_type": "Base64 Encoded String", + "matched_content_hash": "8dc72c32e291de9c0401c3550fa2c8df4698939bb6edc5a0fcdc60f0c768513c", + "plugin_name": "Base64Detector" + }, + { + "file_path": "docs/architecture/scan-pipeline.svg", + "line_number": 14, + "finding_type": "Base64 Encoded String", + "matched_content_hash": "9655d730afa93f28500efce8fd3efadc67e1d32ae4d6768e3ac7da8c1fa4dd11", + "plugin_name": "Base64Detector" } ] } diff --git a/docs/architecture/detector-config-trust.d2 b/docs/architecture/detector-config-trust.d2 index 1437348..8b05397 100644 --- a/docs/architecture/detector-config-trust.d2 +++ b/docs/architecture/detector-config-trust.d2 @@ -39,11 +39,11 @@ classes: { } } -mode: "SCAN MODE\nnormal or trusted¹\n¹ --no-config-discovery" {class: warning} +mode: "SCAN FLAGS\n¹ dropped by --trusted-detectors\n² skipped by --no-repo-config\n--no-config-discovery = both" {class: warning} detectors: "DETECTOR SOURCE\nKEYWATCH_CONFIG_PATH → repository¹\n→ user → executable → embedded" {class: embedded} -policy: "POLICY SOURCE\nexplicit --config, or repository¹\n.keywatch.toml → keywatch.toml → .kw.toml" {class: repository} +policy: "POLICY SOURCE\nexplicit --config, or repository²\n.keywatch.toml → keywatch.toml → .kw.toml" {class: repository} merge: "VALIDATE + MERGE\ncustom rules · overrides · excludes" {class: trusted} result: "FINAL RULES\ndetectors + exclusion policy" {class: trusted} diff --git a/docs/architecture/detector-config-trust.svg b/docs/architecture/detector-config-trust.svg index 94f72d4..46b5c4c 100644 --- a/docs/architecture/detector-config-trust.svg +++ b/docs/architecture/detector-config-trust.svg @@ -1,10 +1,10 @@ -SCAN MODEnormal or trusted¹¹ --no-config-discoveryDETECTOR SOURCEKEYWATCH_CONFIG_PATH → repository¹→ user → executable → embeddedPOLICY SOURCEexplicit --config, or repository¹.keywatch.toml → keywatch.toml → .kw.tomlVALIDATE + MERGEcustom rules · overrides · excludesFINAL RULESdetectors + exclusion policy - + .d2-1555920376 .fill-N1{fill:#0A0F25;} + .d2-1555920376 .fill-N2{fill:#676C7E;} + .d2-1555920376 .fill-N3{fill:#9499AB;} + .d2-1555920376 .fill-N4{fill:#CFD2DD;} + .d2-1555920376 .fill-N5{fill:#DEE1EB;} + .d2-1555920376 .fill-N6{fill:#EEF1F8;} + .d2-1555920376 .fill-N7{fill:#FFFFFF;} + .d2-1555920376 .fill-B1{fill:#0D32B2;} + .d2-1555920376 .fill-B2{fill:#0D32B2;} + .d2-1555920376 .fill-B3{fill:#E3E9FD;} + .d2-1555920376 .fill-B4{fill:#E3E9FD;} + .d2-1555920376 .fill-B5{fill:#EDF0FD;} + .d2-1555920376 .fill-B6{fill:#F7F8FE;} + .d2-1555920376 .fill-AA2{fill:#4A6FF3;} + .d2-1555920376 .fill-AA4{fill:#EDF0FD;} + .d2-1555920376 .fill-AA5{fill:#F7F8FE;} + .d2-1555920376 .fill-AB4{fill:#EDF0FD;} + .d2-1555920376 .fill-AB5{fill:#F7F8FE;} + .d2-1555920376 .stroke-N1{stroke:#0A0F25;} + .d2-1555920376 .stroke-N2{stroke:#676C7E;} + .d2-1555920376 .stroke-N3{stroke:#9499AB;} + .d2-1555920376 .stroke-N4{stroke:#CFD2DD;} + .d2-1555920376 .stroke-N5{stroke:#DEE1EB;} + .d2-1555920376 .stroke-N6{stroke:#EEF1F8;} + .d2-1555920376 .stroke-N7{stroke:#FFFFFF;} + .d2-1555920376 .stroke-B1{stroke:#0D32B2;} + .d2-1555920376 .stroke-B2{stroke:#0D32B2;} + .d2-1555920376 .stroke-B3{stroke:#E3E9FD;} + .d2-1555920376 .stroke-B4{stroke:#E3E9FD;} + .d2-1555920376 .stroke-B5{stroke:#EDF0FD;} + .d2-1555920376 .stroke-B6{stroke:#F7F8FE;} + .d2-1555920376 .stroke-AA2{stroke:#4A6FF3;} + .d2-1555920376 .stroke-AA4{stroke:#EDF0FD;} + .d2-1555920376 .stroke-AA5{stroke:#F7F8FE;} + .d2-1555920376 .stroke-AB4{stroke:#EDF0FD;} + .d2-1555920376 .stroke-AB5{stroke:#F7F8FE;} + .d2-1555920376 .background-color-N1{background-color:#0A0F25;} + .d2-1555920376 .background-color-N2{background-color:#676C7E;} + .d2-1555920376 .background-color-N3{background-color:#9499AB;} + .d2-1555920376 .background-color-N4{background-color:#CFD2DD;} + .d2-1555920376 .background-color-N5{background-color:#DEE1EB;} + .d2-1555920376 .background-color-N6{background-color:#EEF1F8;} + .d2-1555920376 .background-color-N7{background-color:#FFFFFF;} + .d2-1555920376 .background-color-B1{background-color:#0D32B2;} + .d2-1555920376 .background-color-B2{background-color:#0D32B2;} + .d2-1555920376 .background-color-B3{background-color:#E3E9FD;} + .d2-1555920376 .background-color-B4{background-color:#E3E9FD;} + .d2-1555920376 .background-color-B5{background-color:#EDF0FD;} + .d2-1555920376 .background-color-B6{background-color:#F7F8FE;} + .d2-1555920376 .background-color-AA2{background-color:#4A6FF3;} + .d2-1555920376 .background-color-AA4{background-color:#EDF0FD;} + .d2-1555920376 .background-color-AA5{background-color:#F7F8FE;} + .d2-1555920376 .background-color-AB4{background-color:#EDF0FD;} + .d2-1555920376 .background-color-AB5{background-color:#F7F8FE;} + .d2-1555920376 .color-N1{color:#0A0F25;} + .d2-1555920376 .color-N2{color:#676C7E;} + .d2-1555920376 .color-N3{color:#9499AB;} + .d2-1555920376 .color-N4{color:#CFD2DD;} + .d2-1555920376 .color-N5{color:#DEE1EB;} + .d2-1555920376 .color-N6{color:#EEF1F8;} + .d2-1555920376 .color-N7{color:#FFFFFF;} + .d2-1555920376 .color-B1{color:#0D32B2;} + .d2-1555920376 .color-B2{color:#0D32B2;} + .d2-1555920376 .color-B3{color:#E3E9FD;} + .d2-1555920376 .color-B4{color:#E3E9FD;} + .d2-1555920376 .color-B5{color:#EDF0FD;} + .d2-1555920376 .color-B6{color:#F7F8FE;} + .d2-1555920376 .color-AA2{color:#4A6FF3;} + .d2-1555920376 .color-AA4{color:#EDF0FD;} + .d2-1555920376 .color-AA5{color:#F7F8FE;} + .d2-1555920376 .color-AB4{color:#EDF0FD;} + .d2-1555920376 .color-AB5{color:#F7F8FE;}.appendix text.text{fill:#0A0F25}.md{--color-fg-default:#0A0F25;--color-fg-muted:#676C7E;--color-fg-subtle:#9499AB;--color-canvas-default:#FFFFFF;--color-canvas-subtle:#EEF1F8;--color-border-default:#0D32B2;--color-border-muted:#0D32B2;--color-neutral-muted:#EEF1F8;--color-accent-fg:#0D32B2;--color-accent-emphasis:#0D32B2;--color-attention-subtle:#676C7E;--color-danger-fg:red;}.sketch-overlay-B1{fill:url(#streaks-darker-d2-1555920376);mix-blend-mode:lighten}.sketch-overlay-B2{fill:url(#streaks-darker-d2-1555920376);mix-blend-mode:lighten}.sketch-overlay-B3{fill:url(#streaks-bright-d2-1555920376);mix-blend-mode:darken}.sketch-overlay-B4{fill:url(#streaks-bright-d2-1555920376);mix-blend-mode:darken}.sketch-overlay-B5{fill:url(#streaks-bright-d2-1555920376);mix-blend-mode:darken}.sketch-overlay-B6{fill:url(#streaks-bright-d2-1555920376);mix-blend-mode:darken}.sketch-overlay-AA2{fill:url(#streaks-dark-d2-1555920376);mix-blend-mode:overlay}.sketch-overlay-AA4{fill:url(#streaks-bright-d2-1555920376);mix-blend-mode:darken}.sketch-overlay-AA5{fill:url(#streaks-bright-d2-1555920376);mix-blend-mode:darken}.sketch-overlay-AB4{fill:url(#streaks-bright-d2-1555920376);mix-blend-mode:darken}.sketch-overlay-AB5{fill:url(#streaks-bright-d2-1555920376);mix-blend-mode:darken}.sketch-overlay-N1{fill:url(#streaks-darker-d2-1555920376);mix-blend-mode:lighten}.sketch-overlay-N2{fill:url(#streaks-dark-d2-1555920376);mix-blend-mode:overlay}.sketch-overlay-N3{fill:url(#streaks-normal-d2-1555920376);mix-blend-mode:color-burn}.sketch-overlay-N4{fill:url(#streaks-normal-d2-1555920376);mix-blend-mode:color-burn}.sketch-overlay-N5{fill:url(#streaks-bright-d2-1555920376);mix-blend-mode:darken}.sketch-overlay-N6{fill:url(#streaks-bright-d2-1555920376);mix-blend-mode:darken}.sketch-overlay-N7{fill:url(#streaks-bright-d2-1555920376);mix-blend-mode:darken}.light-code{display: block}.dark-code{display: none}]]>SCAN FLAGS¹ dropped by --trusted-detectors² skipped by --no-repo-config--no-config-discovery = bothDETECTOR SOURCEKEYWATCH_CONFIG_PATH → repository¹→ user → executable → embeddedPOLICY SOURCEexplicit --config, or repository².keywatch.toml → keywatch.toml → .kw.tomlVALIDATE + MERGEcustom rules · overrides · excludesFINAL RULESdetectors + exclusion policy + diff --git a/docs/architecture/scan-pipeline.d2 b/docs/architecture/scan-pipeline.d2 index c96950f..5435d86 100644 --- a/docs/architecture/scan-pipeline.d2 +++ b/docs/architecture/scan-pipeline.d2 @@ -43,9 +43,9 @@ start: "SCAN\nvalidated input" {class: output} prepare: "1 · PREPARE\npolicy + detector rules" {class: action} -input: "2 · INPUT\npaths in parallel\nstdin / git in chunks" {class: action} +input: "2 · INPUT\npaths in parallel\nstdin / git in chunks\nUTF-16 decoded · size cap" {class: action} -detect: "3 · DETECT\nkeyword → regex → entropy\n→ suppression → findings" {class: action} +detect: "3 · DETECT\nkeyword → regex → entropy\n→ validators → suppression\nbase64 payloads rescanned" {class: action} baseline: "4 · BASELINE\nkeep or filter" {class: decision} diff --git a/docs/architecture/scan-pipeline.svg b/docs/architecture/scan-pipeline.svg index efc2eda..6ca1572 100644 --- a/docs/architecture/scan-pipeline.svg +++ b/docs/architecture/scan-pipeline.svg @@ -1,17 +1,17 @@ -SCANvalidated input1 · PREPAREpolicy + detector rules2 · INPUTpaths in parallelstdin / git in chunks3 · DETECTkeyword → regex → entropy→ suppression → findings4 · BASELINEkeep or filterBASELINE UPDATEDsave + returnskip reporting5 · REPORTJSON / SARIFsummary + optional file6 · EXIT0 clean · 1 findings2 validation / config / runtime --update-baselinenormal scan - - - + .d2-2083414823 .fill-N1{fill:#0A0F25;} + .d2-2083414823 .fill-N2{fill:#676C7E;} + .d2-2083414823 .fill-N3{fill:#9499AB;} + .d2-2083414823 .fill-N4{fill:#CFD2DD;} + .d2-2083414823 .fill-N5{fill:#DEE1EB;} + .d2-2083414823 .fill-N6{fill:#EEF1F8;} + .d2-2083414823 .fill-N7{fill:#FFFFFF;} + .d2-2083414823 .fill-B1{fill:#0D32B2;} + .d2-2083414823 .fill-B2{fill:#0D32B2;} + .d2-2083414823 .fill-B3{fill:#E3E9FD;} + .d2-2083414823 .fill-B4{fill:#E3E9FD;} + .d2-2083414823 .fill-B5{fill:#EDF0FD;} + .d2-2083414823 .fill-B6{fill:#F7F8FE;} + .d2-2083414823 .fill-AA2{fill:#4A6FF3;} + .d2-2083414823 .fill-AA4{fill:#EDF0FD;} + .d2-2083414823 .fill-AA5{fill:#F7F8FE;} + .d2-2083414823 .fill-AB4{fill:#EDF0FD;} + .d2-2083414823 .fill-AB5{fill:#F7F8FE;} + .d2-2083414823 .stroke-N1{stroke:#0A0F25;} + .d2-2083414823 .stroke-N2{stroke:#676C7E;} + .d2-2083414823 .stroke-N3{stroke:#9499AB;} + .d2-2083414823 .stroke-N4{stroke:#CFD2DD;} + .d2-2083414823 .stroke-N5{stroke:#DEE1EB;} + .d2-2083414823 .stroke-N6{stroke:#EEF1F8;} + .d2-2083414823 .stroke-N7{stroke:#FFFFFF;} + .d2-2083414823 .stroke-B1{stroke:#0D32B2;} + .d2-2083414823 .stroke-B2{stroke:#0D32B2;} + .d2-2083414823 .stroke-B3{stroke:#E3E9FD;} + .d2-2083414823 .stroke-B4{stroke:#E3E9FD;} + .d2-2083414823 .stroke-B5{stroke:#EDF0FD;} + .d2-2083414823 .stroke-B6{stroke:#F7F8FE;} + .d2-2083414823 .stroke-AA2{stroke:#4A6FF3;} + .d2-2083414823 .stroke-AA4{stroke:#EDF0FD;} + .d2-2083414823 .stroke-AA5{stroke:#F7F8FE;} + .d2-2083414823 .stroke-AB4{stroke:#EDF0FD;} + .d2-2083414823 .stroke-AB5{stroke:#F7F8FE;} + .d2-2083414823 .background-color-N1{background-color:#0A0F25;} + .d2-2083414823 .background-color-N2{background-color:#676C7E;} + .d2-2083414823 .background-color-N3{background-color:#9499AB;} + .d2-2083414823 .background-color-N4{background-color:#CFD2DD;} + .d2-2083414823 .background-color-N5{background-color:#DEE1EB;} + .d2-2083414823 .background-color-N6{background-color:#EEF1F8;} + .d2-2083414823 .background-color-N7{background-color:#FFFFFF;} + .d2-2083414823 .background-color-B1{background-color:#0D32B2;} + .d2-2083414823 .background-color-B2{background-color:#0D32B2;} + .d2-2083414823 .background-color-B3{background-color:#E3E9FD;} + .d2-2083414823 .background-color-B4{background-color:#E3E9FD;} + .d2-2083414823 .background-color-B5{background-color:#EDF0FD;} + .d2-2083414823 .background-color-B6{background-color:#F7F8FE;} + .d2-2083414823 .background-color-AA2{background-color:#4A6FF3;} + .d2-2083414823 .background-color-AA4{background-color:#EDF0FD;} + .d2-2083414823 .background-color-AA5{background-color:#F7F8FE;} + .d2-2083414823 .background-color-AB4{background-color:#EDF0FD;} + .d2-2083414823 .background-color-AB5{background-color:#F7F8FE;} + .d2-2083414823 .color-N1{color:#0A0F25;} + .d2-2083414823 .color-N2{color:#676C7E;} + .d2-2083414823 .color-N3{color:#9499AB;} + .d2-2083414823 .color-N4{color:#CFD2DD;} + .d2-2083414823 .color-N5{color:#DEE1EB;} + .d2-2083414823 .color-N6{color:#EEF1F8;} + .d2-2083414823 .color-N7{color:#FFFFFF;} + .d2-2083414823 .color-B1{color:#0D32B2;} + .d2-2083414823 .color-B2{color:#0D32B2;} + .d2-2083414823 .color-B3{color:#E3E9FD;} + .d2-2083414823 .color-B4{color:#E3E9FD;} + .d2-2083414823 .color-B5{color:#EDF0FD;} + .d2-2083414823 .color-B6{color:#F7F8FE;} + .d2-2083414823 .color-AA2{color:#4A6FF3;} + .d2-2083414823 .color-AA4{color:#EDF0FD;} + .d2-2083414823 .color-AA5{color:#F7F8FE;} + .d2-2083414823 .color-AB4{color:#EDF0FD;} + .d2-2083414823 .color-AB5{color:#F7F8FE;}.appendix text.text{fill:#0A0F25}.md{--color-fg-default:#0A0F25;--color-fg-muted:#676C7E;--color-fg-subtle:#9499AB;--color-canvas-default:#FFFFFF;--color-canvas-subtle:#EEF1F8;--color-border-default:#0D32B2;--color-border-muted:#0D32B2;--color-neutral-muted:#EEF1F8;--color-accent-fg:#0D32B2;--color-accent-emphasis:#0D32B2;--color-attention-subtle:#676C7E;--color-danger-fg:red;}.sketch-overlay-B1{fill:url(#streaks-darker-d2-2083414823);mix-blend-mode:lighten}.sketch-overlay-B2{fill:url(#streaks-darker-d2-2083414823);mix-blend-mode:lighten}.sketch-overlay-B3{fill:url(#streaks-bright-d2-2083414823);mix-blend-mode:darken}.sketch-overlay-B4{fill:url(#streaks-bright-d2-2083414823);mix-blend-mode:darken}.sketch-overlay-B5{fill:url(#streaks-bright-d2-2083414823);mix-blend-mode:darken}.sketch-overlay-B6{fill:url(#streaks-bright-d2-2083414823);mix-blend-mode:darken}.sketch-overlay-AA2{fill:url(#streaks-dark-d2-2083414823);mix-blend-mode:overlay}.sketch-overlay-AA4{fill:url(#streaks-bright-d2-2083414823);mix-blend-mode:darken}.sketch-overlay-AA5{fill:url(#streaks-bright-d2-2083414823);mix-blend-mode:darken}.sketch-overlay-AB4{fill:url(#streaks-bright-d2-2083414823);mix-blend-mode:darken}.sketch-overlay-AB5{fill:url(#streaks-bright-d2-2083414823);mix-blend-mode:darken}.sketch-overlay-N1{fill:url(#streaks-darker-d2-2083414823);mix-blend-mode:lighten}.sketch-overlay-N2{fill:url(#streaks-dark-d2-2083414823);mix-blend-mode:overlay}.sketch-overlay-N3{fill:url(#streaks-normal-d2-2083414823);mix-blend-mode:color-burn}.sketch-overlay-N4{fill:url(#streaks-normal-d2-2083414823);mix-blend-mode:color-burn}.sketch-overlay-N5{fill:url(#streaks-bright-d2-2083414823);mix-blend-mode:darken}.sketch-overlay-N6{fill:url(#streaks-bright-d2-2083414823);mix-blend-mode:darken}.sketch-overlay-N7{fill:url(#streaks-bright-d2-2083414823);mix-blend-mode:darken}.light-code{display: block}.dark-code{display: none}]]>SCANvalidated input1 · PREPAREpolicy + detector rules2 · INPUTpaths in parallelstdin / git in chunksUTF-16 decoded · size cap3 · DETECTkeyword → regex → entropy→ validators → suppressionbase64 payloads rescanned4 · BASELINEkeep or filterBASELINE UPDATEDsave + returnskip reporting5 · REPORTJSON / SARIFsummary + optional file6 · EXIT0 clean · 1 findings2 validation / config / runtime --update-baselinenormal scan + + +