residoo 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -104,6 +104,18 @@ won't be built into the tool that writes it.
104
104
  - Covers Stripe keys in both modes: live (`sk_live`/`rk_live`) and test
105
105
  (`sk_test`/`rk_test`), because a leaked test key still holds real
106
106
  permissions in its sandbox and reveals account structure.
107
+ - Pairs an AWS secret access key (40 base64 characters, no vendor prefix, not
108
+ a rule on its own) with a nearby confirmed access key id, and reports both
109
+ at high confidence: the pairing is the vendor-specific signal, not the
110
+ shape alone. Ambiguous pairings (more than one candidate nearby) are
111
+ reported as nothing rather than a guess. See `src/pairing.js`.
112
+ - With `--include-noisy`, filters the broad generic-secret rules by how
113
+ machine-random the matched value actually looks (a lightweight, offline
114
+ approximation of BPE-tokenization rarity checks): ordinary English, a
115
+ placeholder, or a variable name is suppressed with its own stated reason
116
+ instead of padding the count; a value that reads as random gets its
117
+ confidence raised to `medium`. Never applied to the default high-confidence
118
+ rules. See `src/rarity.js`.
107
119
  - Redacts everything in its own output. You get a shape and a first/last-4
108
120
  preview, never the real value, including in `--json` mode. A decoded or
109
121
  rejoined secret is redacted exactly like a plain one.
@@ -292,7 +304,7 @@ As a GitHub Action (this repository doubles as a composite action):
292
304
  ```yaml
293
305
  steps:
294
306
  - uses: actions/checkout@v4
295
- - uses: dandovdub/residoo@v0.3.0
307
+ - uses: dandovdub/residoo@v0.3.4
296
308
  ```
297
309
 
298
310
  As a pre-commit hook:
@@ -300,7 +312,7 @@ As a pre-commit hook:
300
312
  ```yaml
301
313
  repos:
302
314
  - repo: https://github.com/dandovdub/residoo
303
- rev: v0.3.0
315
+ rev: v0.3.4
304
316
  hooks:
305
317
  - id: residoo
306
318
  ```
package/SECURITY.md CHANGED
@@ -45,8 +45,19 @@ just asserted. See the git history for the actual commands run:
45
45
  - **Not vulnerable to regex denial-of-service.** Every pattern checked
46
46
  against the nested-quantifier shape behind real, dated CVEs in adjacent
47
47
  tooling (e.g. CVE-2026-0621, a ReDoS in Anthropic's own MCP SDK from
48
- catastrophic backtracking on an exploded template pattern). Also stress-
49
- tested directly against multi-megabyte adversarial inputs.
48
+ catastrophic backtracking on an exploded template pattern). A second,
49
+ distinct failure mode was found and fixed during a pre-launch audit: an
50
+ open-ended quantifier (`{n,}`) matching a multi-megabyte same-charset run
51
+ can overflow V8's regex engine on stack depth alone, independent of
52
+ catastrophic backtracking. The raw-match, base64-decode, and split-line
53
+ passes shared one try/catch at the time, so a crash partway through the
54
+ rule list could silently skip every rule after it for that line. Every
55
+ rule's quantifier is now explicitly bounded to its format's real maximum
56
+ length (a credential shape has a knowable ceiling), each of the three
57
+ passes has its own try/catch as a second, independent layer, and a
58
+ regression test asserts a real secret placed immediately after a
59
+ multi-megabyte adversarial run is still found. Stress-tested directly
60
+ against multi-megabyte adversarial inputs, including that exact shape.
50
61
  - **No supply-chain surface.** Zero runtime dependencies, zero
51
62
  pre/post-install lifecycle scripts. Check `package.json` yourself;
52
63
  there's nothing to hide behind a `postinstall` hook.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "residoo",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Find secrets leaking through your AI coding agent's session history. Zero network calls in the scan path, zero dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "CloudRoam (https://cloudroam.io)",
package/src/pairing.js ADDED
@@ -0,0 +1,83 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Feature 3: paired-secret detection.
5
+ *
6
+ * An AWS secret access key is a 40-character base64 value with no
7
+ * vendor-recognizable prefix. Reported alone, it is indistinguishable from
8
+ * a hash, a session id, or any other base64-shaped string, so it is not one
9
+ * of patterns.js's own PATTERNS: a bare rule for it would be exactly the
10
+ * noisy, low-confidence shape that file's own header keeps out of the
11
+ * default set.
12
+ *
13
+ * But an AWS secret key is never leaked alone: every real credential pair
14
+ * ships an access key id alongside it (the id names WHICH key; the secret
15
+ * authenticates it, and one is useless to an attacker without the other),
16
+ * so every AWS SDK config, env file, or credentials file that leaks the
17
+ * secret leaks the id in the same breath. Instead of a standalone rule,
18
+ * this looks for a 40-char base64 run within a tight window of an
19
+ * already-confirmed AKIA/ASIA match: the PAIRING is the vendor-specific
20
+ * signal, not the shape alone. Same idea as betterleaks' `components`
21
+ * mechanism (pairing a low-signal shape to a nearby high-confidence rule to
22
+ * raise combined confidence), built offline and dependency-free like every
23
+ * other mechanism here: no rule is added to the default set, and a bare
24
+ * 40-char base64 string anywhere else on a line, with no access key nearby,
25
+ * is still silently ignored exactly as before this feature existed.
26
+ */
27
+
28
+ const WINDOW = 400; // chars searched on each side of the access-key match
29
+
30
+ // AWS secret keys are exactly 40 base64-alphabet characters (40 is a
31
+ // multiple of 4, so real keys carry no "=" padding). \b on both sides so a
32
+ // candidate embedded in a longer alnum run (a hash, a dash-free UUID) is
33
+ // not mistaken for one — the same boundary discipline every rule in
34
+ // patterns.js already applies to its own matches.
35
+ const CANDIDATE_RE = /\b[A-Za-z0-9/+]{40}\b/g;
36
+
37
+ /**
38
+ * A run of 12+ identical characters at either end. This is the same
39
+ * placeholder tell as scan.js's zeroEntropyTail, reimplemented locally (not
40
+ * imported) because it must check BOTH ends here: a candidate window can
41
+ * hold a placeholder abutting real text on either side, where scan.js's own
42
+ * rules only ever see a value anchored at a rule's own prefix, so only the
43
+ * tail end needs checking there.
44
+ */
45
+ function looksZeroEntropy(value) {
46
+ const isRun = (s) => {
47
+ for (let i = 1; i < s.length; i++) if (s[i] !== s[0]) return false;
48
+ return true;
49
+ };
50
+ return isRun(value.slice(0, 12)) || isRun(value.slice(-12));
51
+ }
52
+
53
+ /**
54
+ * Find an AWS secret access key candidate paired with an already-matched
55
+ * access key id or session token on this line. `akiaValue` and `akiaIndex`
56
+ * locate the paired match so the search can be windowed around it and so
57
+ * the access key's own text is never re-matched as its own pair.
58
+ *
59
+ * Returns the candidate string, or null when there is none, or when more
60
+ * than one distinct candidate sits in the window. Ambiguous pairing is
61
+ * reported as nothing at all: for a finding whose whole point is "this is
62
+ * high confidence because of what it's next to," guessing wrong is worse
63
+ * than staying silent.
64
+ */
65
+ function findPairedSecret(line, akiaValue, akiaIndex) {
66
+ const start = Math.max(0, akiaIndex - WINDOW);
67
+ const end = Math.min(line.length, akiaIndex + akiaValue.length + WINDOW);
68
+ const around = line.slice(start, end);
69
+ CANDIDATE_RE.lastIndex = 0;
70
+ let m;
71
+ let found = null;
72
+ while ((m = CANDIDATE_RE.exec(around)) !== null) {
73
+ const value = m[0];
74
+ if (value !== akiaValue && !looksZeroEntropy(value)) {
75
+ if (found !== null && found !== value) return null;
76
+ found = value;
77
+ }
78
+ if (m.index === CANDIDATE_RE.lastIndex) CANDIDATE_RE.lastIndex++;
79
+ }
80
+ return found;
81
+ }
82
+
83
+ module.exports = { findPairedSecret };
package/src/patterns.js CHANGED
@@ -21,13 +21,13 @@ const PATTERNS = [
21
21
  { id: "private_key_block", label: "Private key block", confidence: "high",
22
22
  re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/g },
23
23
  { id: "github_pat", label: "GitHub personal access token", confidence: "high",
24
- re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g },
24
+ re: /\bgh[pousr]_[A-Za-z0-9]{36,255}\b/g },
25
25
  { id: "gitlab_pat", label: "GitLab personal access token", confidence: "high",
26
- re: /\bglpat-[A-Za-z0-9_-]{20,}\b/g },
26
+ re: /\bglpat-[A-Za-z0-9_-]{20,100}\b/g },
27
27
  { id: "slack_token", label: "Slack token", confidence: "high",
28
- re: /\bxox[baprs]-[0-9A-Za-z-]{10,}\b/g },
28
+ re: /\bxox[baprs]-[0-9A-Za-z-]{10,500}\b/g },
29
29
  { id: "stripe_key", label: "Stripe API key (live mode)", confidence: "high",
30
- re: /\b(sk|rk)_live_[A-Za-z0-9]{20,}\b/g },
30
+ re: /\b(sk|rk)_live_[A-Za-z0-9]{20,250}\b/g },
31
31
  // The sandbox-mode twin of the rule above, same body charset and the same
32
32
  // 20-char floor. Format verified against two production detectors plus the
33
33
  // vendor (2026-09-02): gitleaks' stripe-access-token rule matches
@@ -45,34 +45,34 @@ const PATTERNS = [
45
45
  // transcript that pastes sk_test today is the same workflow that will
46
46
  // paste sk_live at go-live.
47
47
  { id: "stripe_test_key", label: "Stripe API key (test mode)", confidence: "high",
48
- re: /\b(sk|rk)_test_[A-Za-z0-9]{20,}\b/g },
48
+ re: /\b(sk|rk)_test_[A-Za-z0-9]{20,250}\b/g },
49
49
  // The negative lookahead keeps this rule mutually exclusive with anthropic_key
50
50
  // and openrouter_key below — without it, "sk-ant-..." or "sk-or-v1-..." match
51
51
  // BOTH this pattern and the more specific one, and get reported twice under
52
52
  // two different (one wrong) provider labels. Verified: all three regexes
53
53
  // independently matched their overlapping synthetic keys before this fix.
54
54
  { id: "openai_key", label: "OpenAI API key", confidence: "high",
55
- re: /\bsk-(?!ant-|or-)(proj-)?[A-Za-z0-9_-]{20,}\b/g },
55
+ re: /\bsk-(?!ant-|or-)(proj-)?[A-Za-z0-9_-]{20,300}\b/g },
56
56
  { id: "anthropic_key", label: "Anthropic API key", confidence: "high",
57
- re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g },
57
+ re: /\bsk-ant-[A-Za-z0-9_-]{20,300}\b/g },
58
58
  { id: "google_api_key", label: "Google / Firebase API key", confidence: "high",
59
59
  re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
60
60
  { id: "npm_token", label: "npm access token", confidence: "high",
61
61
  re: /\bnpm_[A-Za-z0-9]{36}\b/g },
62
62
  { id: "sendgrid_key", label: "SendGrid API key", confidence: "high",
63
- re: /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/g },
63
+ re: /\bSG\.[A-Za-z0-9_-]{16,100}\.[A-Za-z0-9_-]{16,100}\b/g },
64
64
  { id: "twilio_key", label: "Twilio API key", confidence: "high",
65
65
  re: /\bSK[a-f0-9]{32}\b/g },
66
66
  { id: "jwt", label: "JWT-shaped token", confidence: "medium",
67
- re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g },
67
+ re: /\beyJ[A-Za-z0-9_-]{10,2000}\.eyJ[A-Za-z0-9_-]{10,20000}\.[A-Za-z0-9_-]{10,2000}\b/g },
68
68
  { id: "connection_string_with_password", label: "Database connection string with embedded password", confidence: "high",
69
- re: /\b(postgres(?:ql)?|mysql|mongodb(?:\+srv)?):\/\/[^\s:@\/]+:[^\s@\/]{3,}@[^\s\/]+/g },
69
+ re: /\b(postgres(?:ql)?|mysql|mongodb(?:\+srv)?):\/\/[^\s:@\/]{1,255}:[^\s@\/]{3,255}@[^\s\/]{1,255}/g },
70
70
  { id: "bearer_header", label: "Authorization: Bearer header with a real-looking token", confidence: "medium",
71
- re: /\bauthorization["']?\s*[:=]\s*["']?bearer\s+[A-Za-z0-9._-]{16,}/gi },
71
+ re: /\bauthorization["']?\s*[:=]\s*["']?bearer\s+[A-Za-z0-9._-]{16,1000}/gi },
72
72
  { id: "refresh_token_field", label: "refresh_token field", confidence: "medium",
73
- re: /"refresh_token"\s*:\s*"[^"\s]{20,}"/gi },
73
+ re: /"refresh_token"\s*:\s*"[^"\s]{20,4000}"/gi },
74
74
  { id: "access_token_field", label: "access_token field", confidence: "medium",
75
- re: /"access_token"\s*:\s*"[^"\s]{20,}"/gi },
75
+ re: /"access_token"\s*:\s*"[^"\s]{20,4000}"/gi },
76
76
 
77
77
  // ── AI / LLM providers (added: competitive gap-close, see project history) ─
78
78
  // Every regex body below was checked against a production, field-tested
@@ -102,7 +102,7 @@ const PATTERNS = [
102
102
  // "pplx-" + a >=40-char body — consistent across independent sources even
103
103
  // without one canonical spec page.
104
104
  { id: "perplexity_key", label: "Perplexity API key", confidence: "high",
105
- re: /\bpplx-[A-Za-z0-9]{40,}\b/g },
105
+ re: /\bpplx-[A-Za-z0-9]{40,200}\b/g },
106
106
  { id: "replicate_token", label: "Replicate API token", confidence: "high",
107
107
  re: /\br8_[0-9A-Za-z_-]{37}\b/g },
108
108
 
@@ -121,7 +121,7 @@ const PATTERNS = [
121
121
  // Confirmed against 1Password's own developer docs (developer.1password.com
122
122
  // -> 1password.dev/service-accounts/security): the token is "ops_" plus a
123
123
  // base64-encoded JWT, so it always continues "eyJ" (base64 of `{"`).
124
- re: /\bops_eyJ[A-Za-z0-9+/=_-]{40,}\b/g },
124
+ re: /\bops_eyJ[A-Za-z0-9+/=_-]{40,2000}\b/g },
125
125
 
126
126
  // ── Comms / SaaS ───────────────────────────────────────────────────────
127
127
  { id: "discord_webhook", label: "Discord webhook URL", confidence: "high",
@@ -138,13 +138,13 @@ const PATTERNS = [
138
138
  // but Notion has not published an exact body length for it, so its bound
139
139
  // below is a floor, not a verified exact count.
140
140
  { id: "notion_token", label: "Notion integration token", confidence: "high",
141
- re: /\b(?:secret_[A-Za-z0-9]{43}|ntn_[A-Za-z0-9]{20,})\b/g },
141
+ re: /\b(?:secret_[A-Za-z0-9]{43}|ntn_[A-Za-z0-9]{20,200})\b/g },
142
142
  { id: "linear_key", label: "Linear API key", confidence: "high",
143
143
  re: /\blin_api_[0-9A-Za-z]{40}\b/g },
144
144
  { id: "sentry_token", label: "Sentry auth token", confidence: "high",
145
145
  // Covers both current Sentry token shapes: org-scoped (sntrys_, base64
146
146
  // JWT-like body) and user-scoped (sntryu_, hex body).
147
- re: /\b(?:sntrys_eyJ[A-Za-z0-9+/=_]{100,}|sntryu_[a-f0-9]{64})\b/g },
147
+ re: /\b(?:sntrys_eyJ[A-Za-z0-9+/=_]{100,4000}|sntryu_[a-f0-9]{64})\b/g },
148
148
  ];
149
149
 
150
150
  /**
@@ -154,9 +154,9 @@ const PATTERNS = [
154
154
  */
155
155
  const NOISY_PATTERNS = [
156
156
  { id: "generic_password_assignment", label: "password / pwd assignment", confidence: "low",
157
- re: /\b(password|passwd|pwd)\s*[:=]\s*["']?[^\s"']{6,}["']?/gi },
157
+ re: /\b(password|passwd|pwd)\s*[:=]\s*["']?[^\s"']{6,500}["']?/gi },
158
158
  { id: "generic_secret_assignment", label: "generic secret / apikey assignment", confidence: "low",
159
- re: /\b(api[_-]?key|secret)\s*[:=]\s*["']?[A-Za-z0-9_\-\/+=]{12,}["']?/gi },
159
+ re: /\b(api[_-]?key|secret)\s*[:=]\s*["']?[A-Za-z0-9_\-\/+=]{12,500}["']?/gi },
160
160
  ];
161
161
 
162
162
  /**
package/src/rarity.js ADDED
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * "Rare, not random" — a lightweight, offline approximation of betterleaks'
5
+ * BPE-tokenization rarity filter (see their "Rare Not Random" writeup),
6
+ * applied only to NOISY_PATTERNS matches: a bare `password = "..."` or
7
+ * `api_key = "..."` assignment is the hardest class of finding precisely
8
+ * because most matches are placeholders, variable names, or ordinary
9
+ * English ("password = correcthorsebattery", "secret = temporary_value"),
10
+ * not real secrets.
11
+ *
12
+ * A real BPE tokenizer needs an embedded merge-rules vocabulary (GPT-2's is
13
+ * roughly 50,000 entries) — far too heavy for a zero-dependency, small CLI.
14
+ * This approximates the same signal ("does this look like language, or like
15
+ * noise") with a small, hand-picked table of common English letter bigrams
16
+ * (standard digraph-frequency tables — "th", "he", "in", "er"... together
17
+ * cover a large majority of ordinary English text) instead of a learned
18
+ * vocabulary: real secret material is high-entropy machine output and
19
+ * essentially never strings together English digraphs at the rate real
20
+ * words and sentences do, while a placeholder, a variable name, or a
21
+ * pasted sentence almost always does.
22
+ *
23
+ * This is deliberately not a security boundary. It only ever adjusts
24
+ * confidence on the already-opt-in, already low-confidence NOISY_PATTERNS
25
+ * rules (see patterns.js's own header on why those are opt-in) and is never
26
+ * applied to, and never changes the outcome of, any of the default 38
27
+ * high/medium-confidence rules.
28
+ */
29
+
30
+ // The most frequent English letter bigrams (standard digraph-frequency
31
+ // tables, e.g. Konheim's letter-pair frequency study). Not exhaustive by
32
+ // design: the point is common, unmistakably-linguistic pairs, not full
33
+ // coverage of every English bigram.
34
+ const COMMON_BIGRAMS = new Set([
35
+ "th", "he", "in", "er", "an", "re", "on", "at", "en", "nd",
36
+ "ti", "es", "or", "te", "of", "ed", "is", "it", "al", "ar",
37
+ "st", "to", "nt", "ng", "se", "ha", "as", "ou", "io", "le",
38
+ "ve", "co", "me", "de", "hi", "ri", "ro", "ic", "ne", "ea",
39
+ "ra", "ce", "li", "ch", "ll", "be", "ma", "si", "om", "ur",
40
+ ]);
41
+
42
+ /**
43
+ * Fraction of the value's consecutive lowercase-letter bigrams that are one
44
+ * of the common English digraphs above. A non-letter character (digit,
45
+ * punctuation, symbol) breaks a bigram pair rather than being skipped over:
46
+ * a real secret's occasional letter run should not accidentally read as
47
+ * language just because two of its letters happen to land next to each
48
+ * other and spell a common pair across what was actually a digit boundary.
49
+ */
50
+ function commonBigramFraction(value) {
51
+ const lower = value.toLowerCase();
52
+ let total = 0;
53
+ let common = 0;
54
+ for (let i = 0; i < lower.length - 1; i++) {
55
+ const a = lower[i], b = lower[i + 1];
56
+ if (a >= "a" && a <= "z" && b >= "a" && b <= "z") {
57
+ total++;
58
+ if (COMMON_BIGRAMS.has(a + b)) common++;
59
+ }
60
+ }
61
+ return total === 0 ? 0 : common / total;
62
+ }
63
+
64
+ // Above this fraction, a value reads as language (or a language-shaped
65
+ // placeholder) rather than machine-random output. Calibrated against common
66
+ // English words and phrases scoring well above it, and random/base64/hex
67
+ // strings scoring at or near zero (see tests/smoke.js).
68
+ const LANGUAGE_THRESHOLD = 0.20;
69
+
70
+ /** True when `value` reads as machine-random rather than as language. */
71
+ function looksRandom(value) {
72
+ return commonBigramFraction(value) < LANGUAGE_THRESHOLD;
73
+ }
74
+
75
+ module.exports = { looksRandom, commonBigramFraction };
package/src/rotation.js CHANGED
@@ -97,6 +97,21 @@ const ROTATION_GUIDANCE = {
97
97
  ],
98
98
  revokeNote: "Deactivate before delete: a deactivated key can be re-enabled while you hunt down stragglers, a deleted one cannot.",
99
99
  },
100
+ // The secret half of the same pair (see pairing.js): reported only when
101
+ // found near a matched aws_access_key_id, so the same key is the one that
102
+ // needs deactivating. Same console flow, called out separately because the
103
+ // finding itself is a distinct rule id and deserves its own runbook rather
104
+ // than silently reusing aws_access_key_id's guidance under a different name.
105
+ aws_secret_access_key_paired: {
106
+ label: "AWS IAM secret access key (paired with a leaked access key id)",
107
+ rotateUrl: "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html",
108
+ steps: [
109
+ "This is the secret half of the access key id also found on this line",
110
+ "Console: IAM > Users > your user > Security credentials > Access keys",
111
+ "Deactivate and delete the paired access key; its secret dies with it",
112
+ ],
113
+ revokeNote: "An AWS secret key cannot be revoked on its own: deactivating its paired access key id is what invalidates it.",
114
+ },
100
115
  // Fetched https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_revoke-sessions.html
101
116
  // (2026-09-02): "Revoke IAM role temporary security credentials", console
102
117
  // path IAM > Roles > role > Revoke sessions tab.
package/src/scan.js CHANGED
@@ -3,6 +3,17 @@
3
3
  const path = require("path");
4
4
  const { PATTERNS, NOISY_PATTERNS, redact } = require("./patterns");
5
5
  const { findDecodedMatches, findBoundaryMatches, contentProjection } = require("./decode");
6
+ const { findPairedSecret } = require("./pairing");
7
+ const { looksRandom } = require("./rarity");
8
+
9
+ // Rule ids that findPairedSecret's window search applies to (see pairing.js):
10
+ // AWS access key ids and STS session tokens both pair with the same shape
11
+ // of 40-char base64 secret value.
12
+ const AWS_PAIR_RULE_IDS = new Set(["aws_access_key_id", "aws_session_token"]);
13
+
14
+ // The two NOISY_PATTERNS ids (see patterns.js): the only rules the rarity
15
+ // check (rarity.js) ever touches. Never applied to the default 38 rules.
16
+ const NOISY_RULE_IDS = new Set(["generic_password_assignment", "generic_secret_assignment"]);
6
17
 
7
18
  /**
8
19
  * Text immediately before a match that strongly suggests "this is an example
@@ -165,27 +176,62 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
165
176
  // last and only where surrounding text exists (`before` is null for the
166
177
  // decode and boundary passes, whose transforms have no stable "40 chars
167
178
  // before" in the original line).
168
- const suppressionReason = (value, before) => {
179
+ const suppressionReason = (value, before, ruleId) => {
169
180
  if (VENDOR_EXAMPLE_VALUES.has(value)) return "vendor-documented example value";
170
181
  if (zeroEntropyTail(value)) return "zero-entropy body";
171
182
  if (before !== null && SUPPRESS_CONTEXT_RE.test(before)) return "placeholder-like context";
183
+ // Rarity check (rarity.js): only the two opt-in NOISY_PATTERNS rules ever
184
+ // reach here with a matching ruleId. A generic password/secret
185
+ // assignment whose value reads as English (a placeholder, a variable
186
+ // name, a pasted sentence) is exactly the false-positive class those
187
+ // rules are known for; a value that reads as machine-random is not.
188
+ if (ruleId && NOISY_RULE_IDS.has(ruleId) && !looksRandom(value)) return "reads like natural language, not random";
172
189
  return null;
173
190
  };
174
191
 
192
+ // Confidence for a NOISY_PATTERNS match that survives every suppression
193
+ // check is bumped from the rule's default "low" to "medium" when the
194
+ // value also reads as machine-random (rarity.js): passing both "not a
195
+ // known placeholder shape" AND "doesn't read like language" is a real
196
+ // signal boost, not just the absence of a red flag. Never touches any of
197
+ // the default 38 rules' own confidence.
198
+ const resolveConfidence = (ruleId, value, defaultConfidence, suppressedReason) => {
199
+ if (suppressedReason) return "low";
200
+ if (NOISY_RULE_IDS.has(ruleId) && looksRandom(value)) return "medium";
201
+ return defaultConfidence;
202
+ };
203
+
175
204
  const matchLine = (line, file, relFile, lineNo, mtimeMs) => {
176
205
  for (const rule of rules) {
177
206
  rule.re.lastIndex = 0; // rules are reused across files; reset global regex state
178
207
  let m;
179
208
  while ((m = rule.re.exec(line)) !== null) {
180
209
  const before = line.slice(Math.max(0, m.index - CONTEXT_WINDOW), m.index);
181
- const suppressedReason = suppressionReason(m[0], before);
210
+ const suppressedReason = suppressionReason(m[0], before, rule.id);
182
211
  if (suppressedReason && !includeSuppressed) {
183
212
  suppressedCount++;
184
213
  } else {
185
214
  record(rule, m[0], relFile, file, lineNo,
186
215
  mtimeMs,
187
- suppressedReason ? "low" : rule.confidence,
216
+ resolveConfidence(rule.id, m[0], rule.confidence, suppressedReason),
188
217
  suppressedReason);
218
+ // Feature 3: paired-secret detection (see pairing.js). Only
219
+ // attempted for an UNSUPPRESSED access-key finding — pairing a
220
+ // vendor-example or placeholder access key with a random-looking
221
+ // neighbor would be a false amplification, not a real finding.
222
+ if (!suppressedReason && AWS_PAIR_RULE_IDS.has(rule.id)) {
223
+ const paired = findPairedSecret(line, m[0], m.index);
224
+ if (paired) {
225
+ const pairedSuppressedReason = suppressionReason(paired, null);
226
+ if (pairedSuppressedReason && !includeSuppressed) {
227
+ suppressedCount++;
228
+ } else {
229
+ record({ id: "aws_secret_access_key_paired", label: "AWS Secret Access Key (paired with access key id)" },
230
+ paired, relFile, file, lineNo, mtimeMs,
231
+ pairedSuppressedReason ? "low" : "high", pairedSuppressedReason, { paired: true });
232
+ }
233
+ }
234
+ }
189
235
  }
190
236
  if (m.index === rule.re.lastIndex) rule.re.lastIndex++; // guard zero-width matches
191
237
  }
@@ -217,7 +263,7 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
217
263
  // caller and reused across both of a line's pairs.
218
264
  const boundaryPair = (contentA, contentB, file, relFile, lineNoA, mtimeMs) => {
219
265
  for (const b of findBoundaryMatches(contentA, contentB, rules)) {
220
- const suppressedReason = suppressionReason(b.value, null);
266
+ const suppressedReason = suppressionReason(b.value, null, b.ruleId);
221
267
  if (suppressedReason && !includeSuppressed) {
222
268
  // One straddling match is one suppressed match, even though an
223
269
  // unsuppressed one records against both contributing lines.
@@ -225,7 +271,7 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
225
271
  continue;
226
272
  }
227
273
  const span = [lineNoA, lineNoA + 1];
228
- const conf = suppressedReason ? "low" : b.confidence;
274
+ const conf = resolveConfidence(b.ruleId, b.value, b.confidence, suppressedReason);
229
275
  record({ id: b.ruleId, label: b.label }, b.value, relFile, file, lineNoA, mtimeMs, conf, suppressedReason, { spanLines: span });
230
276
  record({ id: b.ruleId, label: b.label }, b.value, relFile, file, lineNoA + 1, mtimeMs, conf, suppressedReason, { spanLines: span });
231
277
  }
@@ -290,19 +336,36 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
290
336
  // Per-file degradation flag, surfaced at most once so a pathological
291
337
  // file produces one visible entry, not thousands.
292
338
  let lineMatchFailed = false;
339
+ // Each pass gets its own try/catch: every rule quantifier is bounded
340
+ // (see patterns.js) so none of these should throw on adversarial input
341
+ // any more, but this is the second, independent layer against that
342
+ // failure mode — a throw in one pass must never suppress the other
343
+ // two for the same line. Without this, a bug reintroduced in any one
344
+ // pass silently blinds the other two for that line rather than
345
+ // degrading loudly on its own. One unmatched line must degrade to a
346
+ // visible per-file flag, never abort the scan and discard every
347
+ // finding already collected (same contract as the readLines catch
348
+ // above).
349
+ const flagFailed = () => {
350
+ if (!lineMatchFailed) {
351
+ lineMatchFailed = true;
352
+ unreadableFiles.push({ file: safeName(file), reason: "some lines could not be matched" });
353
+ }
354
+ };
293
355
  for (let i = 0; i < lines.length; i++) {
294
356
  const line = lines[i];
295
357
  if (line) {
296
- // A rule regex itself can throw on adversarial input: V8's
297
- // backtrack stack overflows (RangeError) when an open-ended
298
- // quantifier meets a prefix followed by a multi-megabyte
299
- // same-charset run — real transcripts contain such lines. One
300
- // unmatched line must degrade to a visible per-file flag, never
301
- // abort the scan and discard every finding already collected
302
- // (same contract as the readLines catch above).
303
358
  try {
304
359
  matchLine(line, file, relFile, i + 1, mtimeMs);
360
+ } catch (err) {
361
+ flagFailed();
362
+ }
363
+ try {
305
364
  decodeLine(line, file, relFile, i + 1, mtimeMs);
365
+ } catch (err) {
366
+ flagFailed();
367
+ }
368
+ try {
306
369
  const content = contentProjection(line);
307
370
  // Boundary join with the previous line (2-way splits only; see
308
371
  // decode.js). Both lines must be non-empty so a blank separator
@@ -312,10 +375,7 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
312
375
  }
313
376
  prevContent = content;
314
377
  } catch (err) {
315
- if (!lineMatchFailed) {
316
- lineMatchFailed = true;
317
- unreadableFiles.push({ file: safeName(file), reason: "some lines could not be matched" });
318
- }
378
+ flagFailed();
319
379
  prevContent = null;
320
380
  }
321
381
  } else {