residoo 0.3.3 → 0.3.5
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 +32 -7
- package/package.json +1 -1
- package/src/cli.js +86 -9
- package/src/keychain.js +143 -0
- package/src/pairing.js +83 -0
- package/src/rarity.js +75 -0
- package/src/report.js +72 -1
- package/src/rotation.js +15 -0
- package/src/scan.js +51 -5
package/README.md
CHANGED
|
@@ -58,11 +58,13 @@ precise about rather than lumping together:
|
|
|
58
58
|
do nothing for the months of transcripts already sitting on disk, or for
|
|
59
59
|
any session run without the hook active. residoo scans **retroactively, at
|
|
60
60
|
rest**: every file already there, from every past session.
|
|
61
|
-
- **agentsweep** is a genuine, welcome peer covering similar ground. Broader
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
- **agentsweep** is a genuine, welcome peer covering similar ground. Broader
|
|
62
|
+
on detection rules (209 to residoo's smaller, deliberately high-confidence
|
|
63
|
+
set) and it does in-place redaction, where residoo's `--seal` makes an
|
|
64
|
+
encrypted copy instead. residoo has more agent sources (43 to 31), and
|
|
65
|
+
both now ship SARIF output and a pre-commit hook. The tradeoffs are worth
|
|
66
|
+
naming precisely rather than either dismissing it or copying it blindly.
|
|
67
|
+
It needs Python 3.11+ and three pip packages (all clean ones, on
|
|
66
68
|
inspection; no known CVEs), where residoo needs nothing beyond Node. Its
|
|
67
69
|
own README documents that its in-place redaction leaves the pre-redaction
|
|
68
70
|
original sitting in a **plaintext** `.bak` file, and its issue tracker shows
|
|
@@ -104,9 +106,32 @@ won't be built into the tool that writes it.
|
|
|
104
106
|
- Covers Stripe keys in both modes: live (`sk_live`/`rk_live`) and test
|
|
105
107
|
(`sk_test`/`rk_test`), because a leaked test key still holds real
|
|
106
108
|
permissions in its sandbox and reveals account structure.
|
|
109
|
+
- Pairs an AWS secret access key (40 base64 characters, no vendor prefix, not
|
|
110
|
+
a rule on its own) with a nearby confirmed access key id, and reports both
|
|
111
|
+
at high confidence: the pairing is the vendor-specific signal, not the
|
|
112
|
+
shape alone. Ambiguous pairings (more than one candidate nearby) are
|
|
113
|
+
reported as nothing rather than a guess. See `src/pairing.js`.
|
|
114
|
+
- With `--include-noisy`, filters the broad generic-secret rules by how
|
|
115
|
+
machine-random the matched value actually looks (a lightweight, offline
|
|
116
|
+
approximation of BPE-tokenization rarity checks): ordinary English, a
|
|
117
|
+
placeholder, or a variable name is suppressed with its own stated reason
|
|
118
|
+
instead of padding the count; a value that reads as random gets its
|
|
119
|
+
confidence raised to `medium`. Never applied to the default high-confidence
|
|
120
|
+
rules. See `src/rarity.js`.
|
|
107
121
|
- Redacts everything in its own output. You get a shape and a first/last-4
|
|
108
122
|
preview, never the real value, including in `--json` mode. A decoded or
|
|
109
123
|
rejoined secret is redacted exactly like a plain one.
|
|
124
|
+
- `--sarif` emits SARIF 2.1.0 for GitHub code scanning's Security tab and
|
|
125
|
+
inline pull-request annotations, the same format gitleaks/trufflehog/
|
|
126
|
+
agentsweep already speak, so residoo's own Action and pre-commit hook plug
|
|
127
|
+
straight into GitHub's native UI. `--json` remains the format for the full
|
|
128
|
+
picture (findings, integrity, rotation) together.
|
|
129
|
+
- `--seal --keychain` stores the vault key in the OS's own secure credential
|
|
130
|
+
store (macOS today, Linux with `secret-tool` installed) instead of a typed
|
|
131
|
+
passphrase: nothing to remember, and a truly random key instead of one
|
|
132
|
+
whose strength depends on what you typed. Tradeoff stated plainly: a
|
|
133
|
+
keychain-backed vault lives on that machine/account only, a passphrase
|
|
134
|
+
travels, a keychain-backed key does not. See `src/keychain.js`.
|
|
110
135
|
- Tells you how many **distinct** secrets it found versus how many times one
|
|
111
136
|
got echoed back across tool calls, so the headline number reflects real
|
|
112
137
|
exposure, not repetition.
|
|
@@ -292,7 +317,7 @@ As a GitHub Action (this repository doubles as a composite action):
|
|
|
292
317
|
```yaml
|
|
293
318
|
steps:
|
|
294
319
|
- uses: actions/checkout@v4
|
|
295
|
-
- uses: dandovdub/residoo@v0.3.
|
|
320
|
+
- uses: dandovdub/residoo@v0.3.5
|
|
296
321
|
```
|
|
297
322
|
|
|
298
323
|
As a pre-commit hook:
|
|
@@ -300,7 +325,7 @@ As a pre-commit hook:
|
|
|
300
325
|
```yaml
|
|
301
326
|
repos:
|
|
302
327
|
- repo: https://github.com/dandovdub/residoo
|
|
303
|
-
rev: v0.3.
|
|
328
|
+
rev: v0.3.5
|
|
304
329
|
hooks:
|
|
305
330
|
- id: residoo
|
|
306
331
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "residoo",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
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/cli.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
const path = require("path");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const crypto = require("crypto");
|
|
4
6
|
const { availableSources, ALL_SOURCES } = require("./sources");
|
|
5
7
|
const { scan, emptyResult } = require("./scan");
|
|
6
|
-
const { render, renderIntegrity, renderJson } = require("./report");
|
|
8
|
+
const { render, renderIntegrity, renderJson, renderSarif } = require("./report");
|
|
7
9
|
const { checkIntegrity } = require("./integrity");
|
|
8
10
|
const {
|
|
9
11
|
ROTATION_GUIDANCE, guidanceFor, loadAcks, ackFinding, renderRotation,
|
|
@@ -62,6 +64,10 @@ Usage:
|
|
|
62
64
|
|
|
63
65
|
Scan options:
|
|
64
66
|
--json machine-readable output (full detail, still redacted)
|
|
67
|
+
--sarif SARIF 2.1.0 output (secret findings only), for
|
|
68
|
+
GitHub code scanning's Security tab and inline PR
|
|
69
|
+
annotations. Use --json for the full picture
|
|
70
|
+
(findings + integrity + rotation) instead.
|
|
65
71
|
--project [dir] scan a repository checkout instead of this machine
|
|
66
72
|
(default dir: current directory). Covers committed
|
|
67
73
|
agent transcripts, agent config/rules files, and
|
|
@@ -101,6 +107,14 @@ Seal options (used with scan):
|
|
|
101
107
|
--seal after scanning, encrypt every transcript that carried a
|
|
102
108
|
finding into a local vault directory (AES-256-GCM,
|
|
103
109
|
passphrase-derived key; originals are left untouched)
|
|
110
|
+
--keychain with --seal (or unseal): use a truly random key stored
|
|
111
|
+
in the OS keychain instead of a typed passphrase.
|
|
112
|
+
Nothing to remember, and the key's strength no longer
|
|
113
|
+
depends on passphrase choice. macOS today; Linux when
|
|
114
|
+
secret-tool (libsecret) is installed. TRADEOFF: a
|
|
115
|
+
keychain-backed vault lives on THIS machine/account
|
|
116
|
+
only, unlike a passphrase, it is not portable to
|
|
117
|
+
another machine.
|
|
104
118
|
--vault-dir <dir> where to create the vault (default: ./residoo-vault-<stamp>)
|
|
105
119
|
--upload-cloudroam ALSO upload the sealed vault to CloudRoam. This is the
|
|
106
120
|
only residoo feature that touches the network, it is
|
|
@@ -116,6 +130,10 @@ Unseal:
|
|
|
116
130
|
restore one entry, verified
|
|
117
131
|
byte-identical via its
|
|
118
132
|
recorded SHA-256
|
|
133
|
+
--keychain add to either unseal form above: retrieve the vault
|
|
134
|
+
key from the OS keychain instead of prompting for a
|
|
135
|
+
passphrase. Only works for a vault that was sealed
|
|
136
|
+
with --keychain on this same machine/account.
|
|
119
137
|
|
|
120
138
|
The passphrase is read from RESIDOO_PASSPHRASE, or prompted (hidden) on a TTY.
|
|
121
139
|
|
|
@@ -138,6 +156,52 @@ async function getPassphrase({ confirmNew }) {
|
|
|
138
156
|
return p1;
|
|
139
157
|
}
|
|
140
158
|
|
|
159
|
+
/**
|
|
160
|
+
* The sealing secret for `scan --seal`: a keychain-generated random key (see
|
|
161
|
+
* keychain.js), or a typed passphrase. `vaultId` is null in passphrase mode;
|
|
162
|
+
* in keychain mode the caller writes it to `.keychain-id` inside the vault
|
|
163
|
+
* once sealFindings has created the directory, so unseal can find it again.
|
|
164
|
+
* The generated secret is passed straight through to the SAME
|
|
165
|
+
* sealFindings/deriveKey path a typed passphrase would use — scrypt on a
|
|
166
|
+
* full 256-bit-entropy input is harmless extra defense, and reusing that
|
|
167
|
+
* already-tested path means no change to sealcrypto.js/sealvault.js at all.
|
|
168
|
+
*/
|
|
169
|
+
async function resolveSealSecret(args) {
|
|
170
|
+
if (!args.includes("--keychain")) {
|
|
171
|
+
return { passphrase: await getPassphrase({ confirmNew: true }), vaultId: null };
|
|
172
|
+
}
|
|
173
|
+
const keychain = require("./keychain");
|
|
174
|
+
if (!keychain.isSupported()) throw new Error(`--keychain: ${keychain.unsupportedReason()}`);
|
|
175
|
+
const vaultId = crypto.randomUUID();
|
|
176
|
+
const passphrase = crypto.randomBytes(32).toString("base64");
|
|
177
|
+
keychain.store(vaultId, passphrase);
|
|
178
|
+
return { passphrase, vaultId };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The unsealing secret for `unseal`: a keychain-retrieved key, or a typed passphrase. */
|
|
182
|
+
async function resolveUnsealSecret(args, vaultDir) {
|
|
183
|
+
if (!args.includes("--keychain")) return getPassphrase({ confirmNew: false });
|
|
184
|
+
const keychain = require("./keychain");
|
|
185
|
+
if (!keychain.isSupported()) throw new Error(`--keychain: ${keychain.unsupportedReason()}`);
|
|
186
|
+
const idPath = path.join(vaultDir, ".keychain-id");
|
|
187
|
+
if (!fs.existsSync(idPath)) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
`No .keychain-id marker in ${vaultDir}: this vault was not sealed with --keychain, ` +
|
|
190
|
+
`or the marker file was moved separately from the vault. Try unsealing without --keychain.`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
const vaultId = fs.readFileSync(idPath, "utf-8").trim();
|
|
194
|
+
try {
|
|
195
|
+
return keychain.retrieve(vaultId);
|
|
196
|
+
} catch {
|
|
197
|
+
throw new Error(
|
|
198
|
+
"Could not retrieve this vault's key from the OS keychain. It may have been removed, " +
|
|
199
|
+
"or this may be a different machine/account than the one that sealed it: a keychain-backed " +
|
|
200
|
+
"vault is not portable across machines."
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
141
205
|
async function runSeal(result, args) {
|
|
142
206
|
const { sealFindings, uploadVaultToCloudRoam } = require("./sealvault");
|
|
143
207
|
|
|
@@ -149,21 +213,27 @@ async function runSeal(result, args) {
|
|
|
149
213
|
|
|
150
214
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
151
215
|
const vaultDir = argValue(args, "--vault-dir") || path.resolve(`residoo-vault-${stamp}`);
|
|
152
|
-
const passphrase = await
|
|
216
|
+
const { passphrase, vaultId } = await resolveSealSecret(args);
|
|
153
217
|
|
|
154
218
|
process.stdout.write(`\nSealing ${filesWithFindings.length} file(s) with findings into ${vaultDir}\n`);
|
|
155
219
|
const { entries } = await sealFindings({
|
|
156
220
|
files: filesWithFindings, vaultDir, passphrase,
|
|
157
221
|
log: (s) => process.stdout.write(s + "\n"),
|
|
158
222
|
});
|
|
223
|
+
// Written only after sealFindings has created vaultDir. Plaintext, but
|
|
224
|
+
// holds nothing sensitive: a random id with no meaning outside this
|
|
225
|
+
// keychain lookup, never the key itself and never anything about what the
|
|
226
|
+
// vault contains.
|
|
227
|
+
if (vaultId) fs.writeFileSync(path.join(vaultDir, ".keychain-id"), vaultId, { mode: 0o600 });
|
|
159
228
|
const totalPlain = entries.reduce((s, e) => s + e.plainBytes, 0);
|
|
160
229
|
const totalSealed = entries.reduce((s, e) => s + e.sealedBytes, 0);
|
|
161
230
|
process.stdout.write(
|
|
162
231
|
`\nSealed ${entries.length} file(s): ${(totalPlain / 1024 / 1024).toFixed(1)}MB plain -> ` +
|
|
163
232
|
`${(totalSealed / 1024 / 1024).toFixed(1)}MB encrypted.\n` +
|
|
164
233
|
`Originals were NOT touched. Once you've verified a restore works\n` +
|
|
165
|
-
`(residoo unseal ${path.basename(vaultDir)} --restore 0001.sealed --out /tmp/check), removing the\n` +
|
|
166
|
-
`plaintext originals is your call; residoo never deletes anything itself.\n`
|
|
234
|
+
`(residoo unseal ${path.basename(vaultDir)}${vaultId ? " --keychain" : ""} --restore 0001.sealed --out /tmp/check), removing the\n` +
|
|
235
|
+
`plaintext originals is your call; residoo never deletes anything itself.\n` +
|
|
236
|
+
(vaultId ? `The vault key is stored in the OS keychain, never typed, never written in plaintext to disk.\n` : "")
|
|
167
237
|
);
|
|
168
238
|
|
|
169
239
|
if (args.includes("--upload-cloudroam")) {
|
|
@@ -192,7 +262,7 @@ async function runUnseal(args) {
|
|
|
192
262
|
const vaultDir = args[1];
|
|
193
263
|
if (!vaultDir) { process.stderr.write("usage: residoo unseal <vault-dir> [--restore <n> --out <path>]\n"); return 2; }
|
|
194
264
|
|
|
195
|
-
const passphrase = await
|
|
265
|
+
const passphrase = await resolveUnsealSecret(args, vaultDir);
|
|
196
266
|
let manifest;
|
|
197
267
|
try {
|
|
198
268
|
manifest = openManifest(vaultDir, passphrase);
|
|
@@ -314,6 +384,7 @@ async function main(argv) {
|
|
|
314
384
|
}
|
|
315
385
|
|
|
316
386
|
const wantsJson = args.includes("--json");
|
|
387
|
+
const wantsSarif = args.includes("--sarif");
|
|
317
388
|
const includeNoisy = args.includes("--include-noisy");
|
|
318
389
|
const includeSuppressed = args.includes("--include-suppressed");
|
|
319
390
|
const failOnFind = args.includes("--fail-on-find");
|
|
@@ -402,7 +473,11 @@ async function main(argv) {
|
|
|
402
473
|
if (sources.length === 0) {
|
|
403
474
|
const empty = emptyResult();
|
|
404
475
|
const integrity = wantsIntegrity ? runIntegrity() : null;
|
|
405
|
-
if (
|
|
476
|
+
if (wantsSarif) {
|
|
477
|
+
// Same contract as --json below: a CI step consuming SARIF must
|
|
478
|
+
// always get a valid SARIF document, even with nothing to scan.
|
|
479
|
+
process.stdout.write(renderSarif(empty) + "\n");
|
|
480
|
+
} else if (wantsJson) {
|
|
406
481
|
// A --json caller (CI, a script piping into jq) must always get valid JSON
|
|
407
482
|
// on stdout, even on the "nothing to scan" path — a plain-text message on
|
|
408
483
|
// stderr with exit 0 silently breaks that contract.
|
|
@@ -422,9 +497,11 @@ async function main(argv) {
|
|
|
422
497
|
const result = await scan({ sources, includeNoisy, includeSuppressed });
|
|
423
498
|
const integrity = wantsIntegrity ? runIntegrity() : null;
|
|
424
499
|
const rotation = renderRotation(result.findings, acks);
|
|
425
|
-
process.stdout.write((
|
|
426
|
-
?
|
|
427
|
-
:
|
|
500
|
+
process.stdout.write((wantsSarif
|
|
501
|
+
? renderSarif(result)
|
|
502
|
+
: wantsJson
|
|
503
|
+
? renderJson(result, integrity, rotation)
|
|
504
|
+
: render(result, { noColor, integrity, rotation })) + "\n");
|
|
428
505
|
|
|
429
506
|
if (args.includes("--seal")) {
|
|
430
507
|
const sealExit = await runSeal(result, args);
|
package/src/keychain.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { execFileSync } = require("child_process");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* OS-native secure credential storage for `scan --seal --keychain` and
|
|
7
|
+
* `unseal --keychain`. Instead of a user-typed passphrase run through
|
|
8
|
+
* scrypt, residoo generates a truly random, high-entropy secret and hands
|
|
9
|
+
* it to the OS's own secure store: nothing needs to be typed or
|
|
10
|
+
* remembered, and the vault's strength no longer depends on a human's
|
|
11
|
+
* passphrase choice (the weak spot a security audit flagged in passphrase
|
|
12
|
+
* mode, where the only floor was an 8-character length check).
|
|
13
|
+
*
|
|
14
|
+
* The secret itself is still passed straight through to sealcrypto.js's
|
|
15
|
+
* existing deriveKey(passphrase, salt) exactly as a typed passphrase would
|
|
16
|
+
* be: scrypt on a full 256-bit-entropy input is harmless extra defense in
|
|
17
|
+
* depth, and reusing the same, already-tested code path here means no
|
|
18
|
+
* change to sealcrypto.js or sealvault.js at all — only how the secret is
|
|
19
|
+
* obtained changes.
|
|
20
|
+
*
|
|
21
|
+
* Scoped honestly rather than half-built everywhere: macOS via the
|
|
22
|
+
* `security` CLI (built into the OS, no new dependency) is the primary,
|
|
23
|
+
* fully-supported path. Linux via `secret-tool` (libsecret) works when it's
|
|
24
|
+
* installed and is treated as best-effort, gated by isSupported() the same
|
|
25
|
+
* way every source in this codebase declares availability rather than
|
|
26
|
+
* assuming it. Windows has no equivalent built-in CLI story and is refused
|
|
27
|
+
* outright with a clear message rather than half-implemented against a
|
|
28
|
+
* module residoo would have to newly depend on.
|
|
29
|
+
*
|
|
30
|
+
* IMPORTANT TRADEOFF, stated plainly: a keychain-backed vault key lives in
|
|
31
|
+
* THIS machine's (or account's) secure store. It is not portable the way a
|
|
32
|
+
* passphrase is — unseal it on a different machine and there is nothing to
|
|
33
|
+
* retrieve. Use a passphrase instead when a vault needs to travel.
|
|
34
|
+
*/
|
|
35
|
+
const SERVICE = "residoo-vault";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Test-only escape hatch, macOS only: when RESIDOO_TEST_KEYCHAIN_FILE is
|
|
39
|
+
* set, every operation below scopes to that keychain FILE instead of the
|
|
40
|
+
* real default login keychain. Exists so this project's own tests can run
|
|
41
|
+
* a genuine store/retrieve/remove round trip against a throwaway keychain
|
|
42
|
+
* created and destroyed within the test (see tests/smoke.js), crossing a
|
|
43
|
+
* spawned child process boundary via env var rather than a function
|
|
44
|
+
* parameter, without ever touching, prompting about, or depending on
|
|
45
|
+
* whatever machine happens to run them. Not a documented flag: no real
|
|
46
|
+
* user has a reason to set this, and even if one did, the only effect is
|
|
47
|
+
* redirecting to a named file instead of the default keychain, never an
|
|
48
|
+
* unexpected access to anything.
|
|
49
|
+
*/
|
|
50
|
+
function testKeychainFile() {
|
|
51
|
+
return process.env.RESIDOO_TEST_KEYCHAIN_FILE || null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isSupported() {
|
|
55
|
+
if (process.platform === "darwin") return true;
|
|
56
|
+
if (process.platform === "linux") {
|
|
57
|
+
try {
|
|
58
|
+
execFileSync("which", ["secret-tool"], { stdio: "ignore" });
|
|
59
|
+
return true;
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function unsupportedReason() {
|
|
68
|
+
if (process.platform === "darwin") return null;
|
|
69
|
+
if (process.platform === "linux") {
|
|
70
|
+
return "secret-tool (libsecret) is not installed. Install it (e.g. \"apt install libsecret-tools\" or \"dnf install libsecret\") or omit --keychain to use a passphrase instead.";
|
|
71
|
+
}
|
|
72
|
+
return `--keychain is not supported on ${process.platform} yet. Omit --keychain to use a passphrase instead.`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Store `secret` (a string) under `account` in the OS keychain, for later
|
|
77
|
+
* retrieve(account). `keychainFile`, macOS only, is an escape hatch used
|
|
78
|
+
* ONLY by this project's own tests: passing a path scopes the operation to
|
|
79
|
+
* that specific keychain FILE instead of the real default login keychain,
|
|
80
|
+
* so a test round-trip never touches, prompts about, or depends on the
|
|
81
|
+
* developer's actual keychain. The real feature (seal/unseal) never passes
|
|
82
|
+
* this — it always targets the default keychain, which is the whole point.
|
|
83
|
+
*/
|
|
84
|
+
function store(account, secret, keychainFile) {
|
|
85
|
+
if (process.platform === "darwin") {
|
|
86
|
+
// -U updates the entry in place if `account` already exists, rather
|
|
87
|
+
// than erroring on a name collision.
|
|
88
|
+
const kf = keychainFile || testKeychainFile();
|
|
89
|
+
const args = ["add-generic-password", "-a", account, "-s", SERVICE, "-w", secret, "-U"];
|
|
90
|
+
if (kf) args.push(kf);
|
|
91
|
+
execFileSync("security", args, { stdio: "ignore" });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (process.platform === "linux") {
|
|
95
|
+
// secret-tool reads the secret from stdin, never a CLI argument, so it
|
|
96
|
+
// never appears in a process listing or shell history.
|
|
97
|
+
execFileSync("secret-tool", [
|
|
98
|
+
"store", "--label", "residoo sealed vault key", "service", SERVICE, "account", account,
|
|
99
|
+
], { input: secret, stdio: ["pipe", "ignore", "ignore"] });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
throw new Error(unsupportedReason());
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Retrieve a secret previously stored under `account`. Throws if not found or unsupported. See store() re: keychainFile. */
|
|
106
|
+
function retrieve(account, keychainFile) {
|
|
107
|
+
if (process.platform === "darwin") {
|
|
108
|
+
const kf = keychainFile || testKeychainFile();
|
|
109
|
+
const args = ["find-generic-password", "-a", account, "-s", SERVICE, "-w"];
|
|
110
|
+
if (kf) args.push(kf);
|
|
111
|
+
return execFileSync("security", args, { encoding: "utf8" }).trim();
|
|
112
|
+
}
|
|
113
|
+
if (process.platform === "linux") {
|
|
114
|
+
return execFileSync("secret-tool", [
|
|
115
|
+
"lookup", "service", SERVICE, "account", account,
|
|
116
|
+
], { encoding: "utf8" }).trim();
|
|
117
|
+
}
|
|
118
|
+
throw new Error(unsupportedReason());
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Remove a previously stored secret. Not used by seal/unseal (a vault's
|
|
123
|
+
* keychain entry is meant to outlive the command that created it); exists
|
|
124
|
+
* for callers that manage a keychain entry's lifecycle themselves, and for
|
|
125
|
+
* this project's own tests to clean up after a real round-trip check
|
|
126
|
+
* without leaving entries behind. See store() re: keychainFile.
|
|
127
|
+
*/
|
|
128
|
+
function remove(account, keychainFile) {
|
|
129
|
+
if (process.platform === "darwin") {
|
|
130
|
+
const kf = keychainFile || testKeychainFile();
|
|
131
|
+
const args = ["delete-generic-password", "-a", account, "-s", SERVICE];
|
|
132
|
+
if (kf) args.push(kf);
|
|
133
|
+
execFileSync("security", args, { stdio: "ignore" });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (process.platform === "linux") {
|
|
137
|
+
execFileSync("secret-tool", ["clear", "service", SERVICE, "account", account], { stdio: "ignore" });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
throw new Error(unsupportedReason());
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = { isSupported, unsupportedReason, store, retrieve, remove };
|
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/report.js
CHANGED
|
@@ -318,4 +318,75 @@ function renderJson(result, integrity = null, rotation = null) {
|
|
|
318
318
|
);
|
|
319
319
|
}
|
|
320
320
|
|
|
321
|
-
|
|
321
|
+
// confidence -> SARIF level. "high"/"medium" map to the two levels GitHub's
|
|
322
|
+
// code-scanning UI treats as real alerts ("error" surfaces most
|
|
323
|
+
// prominently); "low" only ever appears with --include-suppressed (a
|
|
324
|
+
// placeholder/example match) and maps to "note", SARIF's own tier for
|
|
325
|
+
// exactly that: worth showing, not worth alarming over.
|
|
326
|
+
const SARIF_LEVEL = { high: "error", medium: "warning", low: "note" };
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* SARIF 2.1.0 output (--sarif): the format GitHub's code-scanning Security
|
|
330
|
+
* tab, and inline pull-request annotations, both consume. residoo already
|
|
331
|
+
* ships a GitHub Action and a pre-commit hook, so not emitting the one
|
|
332
|
+
* format that plugs a scan straight into GitHub's native UI was a real gap
|
|
333
|
+
* for exactly the CI audience those two things target.
|
|
334
|
+
*
|
|
335
|
+
* Scoped to secret findings only (result.findings), not the separate
|
|
336
|
+
* integrity checks (planted hooks, droppers): those don't share the same
|
|
337
|
+
* per-line, per-file location shape, and forcing them into one schema badly
|
|
338
|
+
* would be worse than a stated, honest scope limit. --json remains the
|
|
339
|
+
* format that carries everything (findings, integrity, rotation) together.
|
|
340
|
+
*
|
|
341
|
+
* partialFingerprints carries residoo's own stable fingerprint
|
|
342
|
+
* (fingerprintFinding, already proven stable across line-number and
|
|
343
|
+
* directory changes, see tests/smoke.js) under a versioned key, so GitHub's
|
|
344
|
+
* own alert-dedup logic can track one finding across reruns without
|
|
345
|
+
* depending on line numbers moving, exactly the property SARIF's
|
|
346
|
+
* fingerprinting is designed around.
|
|
347
|
+
*/
|
|
348
|
+
function renderSarif(result) {
|
|
349
|
+
const { version } = require("../package.json");
|
|
350
|
+
const rules = new Map();
|
|
351
|
+
const results = result.findings.map((f) => {
|
|
352
|
+
if (!rules.has(f.ruleId)) {
|
|
353
|
+
rules.set(f.ruleId, {
|
|
354
|
+
id: f.ruleId,
|
|
355
|
+
name: f.label,
|
|
356
|
+
shortDescription: { text: f.label },
|
|
357
|
+
properties: { "security-severity": f.confidence === "high" ? "9.0" : f.confidence === "medium" ? "6.0" : "3.0" },
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
ruleId: f.ruleId,
|
|
362
|
+
level: SARIF_LEVEL[f.confidence] || "warning",
|
|
363
|
+
message: { text: `${f.label} (redacted: ${f.preview})` },
|
|
364
|
+
locations: [{
|
|
365
|
+
physicalLocation: {
|
|
366
|
+
artifactLocation: { uri: f.relFile },
|
|
367
|
+
...(Number.isInteger(f.line) ? { region: { startLine: f.line } } : {}),
|
|
368
|
+
},
|
|
369
|
+
}],
|
|
370
|
+
partialFingerprints: { "residooFingerprint/v1": fingerprintFinding(f) },
|
|
371
|
+
properties: { source: f.source, confidence: f.confidence },
|
|
372
|
+
};
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
return JSON.stringify({
|
|
376
|
+
$schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
|
|
377
|
+
version: "2.1.0",
|
|
378
|
+
runs: [{
|
|
379
|
+
tool: {
|
|
380
|
+
driver: {
|
|
381
|
+
name: "residoo",
|
|
382
|
+
version,
|
|
383
|
+
informationUri: "https://github.com/dandovdub/residoo",
|
|
384
|
+
rules: [...rules.values()],
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
results,
|
|
388
|
+
}],
|
|
389
|
+
}, null, 2);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
module.exports = { render, renderIntegrity, renderRotationSection, renderJson, renderSarif };
|
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
|
}
|