residoo 0.3.3 → 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 +14 -2
- package/package.json +1 -1
- package/src/pairing.js +83 -0
- package/src/rarity.js +75 -0
- package/src/rotation.js +15 -0
- package/src/scan.js +51 -5
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.
|
|
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.
|
|
315
|
+
rev: v0.3.4
|
|
304
316
|
hooks:
|
|
305
317
|
- id: residoo
|
|
306
318
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "residoo",
|
|
3
|
-
"version": "0.3.
|
|
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/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
|
-
|
|
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 =
|
|
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
|
}
|