diff --git a/.keywatch-baseline.json b/.keywatch-baseline.json index 640ae66..06b4f93 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" @@ -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,206 +1403,367 @@ }, { "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" + }, + { + "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": 926, + "finding_type": "GitHub Token", + "matched_content_hash": "b16bd9f07a6786888caf195fb2e6713ab305d373c68c05c878cfbbea71855cb2", + "plugin_name": "GitHubTokenDetector" + }, + { + "file_path": "tests/detector_tests.rs", + "line_number": 926, + "finding_type": "Generic Key/Secret", + "matched_content_hash": "45c883873c36ab6f5a63ecbcd3c44b7e8aa8c12f75c12b746083ebe6ebc493b4", + "plugin_name": "GenericKeyValueDetector" + }, + { + "file_path": "tests/detector_tests.rs", + "line_number": 930, + "finding_type": "GitHub Token", + "matched_content_hash": "275e1a2a3f41699adf8ac2b219f5e689a381a12f0e9f195e322c978bffb56ba6", + "plugin_name": "GitHubTokenDetector" + }, + { + "file_path": "tests/scanner_tests.rs", + "line_number": 2623, + "finding_type": "Generic Key/Secret", + "matched_content_hash": "89c67ad26d243a255386bb51b4336359e2f00092aae3ac83498cccba38b71972", + "plugin_name": "GenericKeyValueDetector" + }, + { + "file_path": "tests/scanner_tests.rs", + "line_number": 2666, + "finding_type": "Generic Key/Secret", + "matched_content_hash": "d6d60d3e50a63a67cc37ebfac022834f34a7ec0c74d9b5952b8b8ccd50b5f1ff", + "plugin_name": "GenericKeyValueDetector" + }, + { + "file_path": "tests/scanner_tests.rs", + "line_number": 2670, + "finding_type": "Base64 Encoded String", + "matched_content_hash": "f6c41d46bc806e05582dd7504058eb98e514473b4637fcbd3f1f46a12d6a1399", + "plugin_name": "Base64Detector" + }, + { + "file_path": "tests/scanner_tests.rs", + "line_number": 2670, + "finding_type": "Generic Key/Secret", + "matched_content_hash": "e1a8fe4e1ccad188f7428469d1d0f7c55ce10a6290696ac32472f6ff40c2d2b6", + "plugin_name": "GenericKeyValueDetector" + }, + { + "file_path": "tests/scanner_tests.rs", + "line_number": 2677, + "finding_type": "Base64 Encoded String", + "matched_content_hash": "de00639949784d409b3d3e7b866432f64ca1fe35e047b312a012bbc0a19501b9", + "plugin_name": "Base64Detector" + }, + { + "file_path": "tests/scanner_tests.rs", + "line_number": 2719, + "finding_type": "SSH Private Key", + "matched_content_hash": "1b01887f477e98dd56ec542c12433dee8c323176bd532c05163823079263ba31", + "plugin_name": "SSHPrivateKeyDetector" + }, + { + "file_path": "tests/scanner_tests.rs", + "line_number": 2719, + "finding_type": "Private Key Content", + "matched_content_hash": "d3022d6b263b30e0ab3e0d77220f81e0bebfcc0b723f84f77d2d82d306d22e50", + "plugin_name": "PrivateKeyDetector" + }, + { + "file_path": "tests/scanner_tests.rs", + "line_number": 2719, + "finding_type": "Base64 Encoded String", + "matched_content_hash": "81623b8aeb6fe8c68c0a53b2d7a12ecf1d044b3ac15886fdaced0eb177f385dc", + "plugin_name": "Base64Detector" + }, + { + "file_path": "tests/exit_tests.rs", + "line_number": 706, + "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" + }, + { + "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/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..3462b7f 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. @@ -61,18 +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 | -| `--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 | -| `--no-config-discovery` | Ignore configuration and detector files found in the scanned repository | -| `--show-secrets` | Include the full matched text in reports | +| 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: @@ -80,18 +83,24 @@ 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. 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 @@ -101,7 +110,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: @@ -122,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 @@ -212,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. @@ -251,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 @@ -260,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. 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/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 + + + 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/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/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..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}" ); } } @@ -914,11 +923,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..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; } @@ -613,3 +620,165 @@ 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"); +} + +#[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 46e5c91..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"); @@ -2603,3 +2611,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"); +}