residoo 0.17.0 → 0.19.0
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 +20 -3
- package/package.json +1 -1
- package/src/cli.js +162 -15
- package/src/guard.js +51 -1
- package/src/integrity.js +83 -6
- package/src/keychain.js +116 -9
- package/src/notify.js +53 -0
- package/src/watch.js +13 -1
package/README.md
CHANGED
|
@@ -72,14 +72,24 @@ observed, not just documented. All 8, not just the closest one:
|
|
|
72
72
|
| tool | distinct credentials found | precision | egress during the scan |
|
|
73
73
|
|---|---|---|---|
|
|
74
74
|
| **residoo** | **45/45 (100%)** | **100%** | **none-observed** |
|
|
75
|
-
| agentsweep | 33/42 (79%) |
|
|
75
|
+
| agentsweep | 33/42 (79%) | 89% | none-observed |
|
|
76
76
|
| gitleaks | 32/45 (71%) | 100% | none-observed |
|
|
77
|
-
| betterleaks | 32/45 (71%) |
|
|
77
|
+
| betterleaks | 32/45 (71%) | 95% | none-observed |
|
|
78
78
|
| whatileaked | 28/42 (67%) | 100% | none-observed |
|
|
79
|
-
| kingfisher | 29/45 (64%) |
|
|
79
|
+
| kingfisher | 29/45 (64%) | 97% | attempts calls in default mode |
|
|
80
80
|
| trufflehog | 29/45 (64%) | 97% | attempts calls in default mode |
|
|
81
81
|
| detect-secrets | 25/45 (56%) | 2% | attempts calls in default mode |
|
|
82
82
|
|
|
83
|
+
Precision here counts a flagged vendor-documented example key (a real,
|
|
84
|
+
deliberate suppress-placeholder in the corpus) as a false positive, the
|
|
85
|
+
stricter of the two measures `bench/RESULTS.md` reports throughout —
|
|
86
|
+
several tools above score better on the looser "excluding suppress flags"
|
|
87
|
+
measure (e.g. agentsweep and betterleaks both reach 100% there), but this
|
|
88
|
+
is the one that matches what a user actually experiences: a tool that
|
|
89
|
+
flags AWS's own published example key on every run trains people to
|
|
90
|
+
ignore its output. residoo and gitleaks are the only two tools that hit
|
|
91
|
+
100% either way.
|
|
92
|
+
|
|
83
93
|
"none-observed" is a measured result, not a default assumption: every run
|
|
84
94
|
sits under a live proxy trap and process-tree polling, and a deliberate
|
|
85
95
|
canary connection is fired and confirmed caught *before* each real
|
|
@@ -165,6 +175,13 @@ gap, none catches every case, and all are re-includable with
|
|
|
165
175
|
certainty — true of every tool in this category, including the well-
|
|
166
176
|
established ones.
|
|
167
177
|
|
|
178
|
+
- **No mobile app.** Researched, not assumed: residoo's file-scanning
|
|
179
|
+
approach cannot port to stock iOS under Apple's own sandboxing model,
|
|
180
|
+
and every alternative mechanism checked (a keyboard extension, a local
|
|
181
|
+
VPN content filter) has a specific, disqualifying problem. See
|
|
182
|
+
[docs/platform-scope.md](docs/platform-scope.md) for the full technical
|
|
183
|
+
verdict and what would change it.
|
|
184
|
+
|
|
168
185
|
## Install
|
|
169
186
|
|
|
170
187
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "residoo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
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
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const path = require("path");
|
|
4
4
|
const fs = require("fs");
|
|
5
|
+
const os = require("os");
|
|
5
6
|
const crypto = require("crypto");
|
|
6
7
|
const { availableSources, ALL_SOURCES } = require("./sources");
|
|
7
8
|
const { scan, emptyResult } = require("./scan");
|
|
@@ -13,7 +14,7 @@ const {
|
|
|
13
14
|
const { startWatch, isTailable } = require("./watch");
|
|
14
15
|
const { startMcpServer } = require("./mcp");
|
|
15
16
|
const { buildTools } = require("./mcpTools");
|
|
16
|
-
const { runGuard: runGuardEngine } = require("./guard");
|
|
17
|
+
const { runGuard: runGuardEngine, buildHookConfig } = require("./guard");
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* A source is unavailable for the ordinary reason (not installed — nothing
|
|
@@ -183,6 +184,15 @@ Watch:
|
|
|
183
184
|
never to one already seen
|
|
184
185
|
--include-noisy, --include-suppressed, --include-pii, --no-color
|
|
185
186
|
same meaning as scan
|
|
187
|
+
--no-notify skip the OS desktop notification watch fires for
|
|
188
|
+
each genuinely new finding (macOS via osascript,
|
|
189
|
+
Linux via notify-send if installed; no built-in
|
|
190
|
+
mechanism on Windows -- disclosed, not attempted).
|
|
191
|
+
On by default in human-readable mode: watch's own
|
|
192
|
+
purpose is alerting you, and a background process
|
|
193
|
+
nobody is watching a terminal for needs more than
|
|
194
|
+
a printed line. Never fires for a re-exposure of
|
|
195
|
+
something already seen, and never in --json mode.
|
|
186
196
|
Ctrl+C stops cleanly and prints a session summary (skipped with --json,
|
|
187
197
|
where the same information is one final NDJSON event).
|
|
188
198
|
|
|
@@ -207,7 +217,15 @@ MCP:
|
|
|
207
217
|
Cred:
|
|
208
218
|
residoo cred set <name> --env <ENV_VAR_NAME> [--env <ENV_VAR_NAME_2> ...]
|
|
209
219
|
store a live credential in the OS keychain
|
|
210
|
-
(macOS/Linux only
|
|
220
|
+
(macOS/Linux only -- genuinely unsupported on
|
|
221
|
+
Windows, not just undone yet: this needs a NAMED
|
|
222
|
+
store you can write once and read back later,
|
|
223
|
+
and Windows has none reachable without an extra
|
|
224
|
+
dependency. Different from --seal --keychain
|
|
225
|
+
below, which Windows DOES support via DPAPI --
|
|
226
|
+
that one only ever needs to wrap/unwrap a key
|
|
227
|
+
living inside its own vault directory, never a
|
|
228
|
+
named lookup), one or more env-var names
|
|
211
229
|
mapped to hidden-typed values. Interactive TTY
|
|
212
230
|
only, no scripted entry: a live credential is
|
|
213
231
|
more sensitive than a vault passphrase and
|
|
@@ -292,6 +310,19 @@ Guard:
|
|
|
292
310
|
"hooks":[{"type":"command",
|
|
293
311
|
"command":"residoo guard"}]}]}}
|
|
294
312
|
|
|
313
|
+
residoo guard --print-config
|
|
314
|
+
print that same merged JSON to stdout instead of
|
|
315
|
+
hand-writing it -- reads your existing
|
|
316
|
+
~/.claude/settings.json if present, adds
|
|
317
|
+
whatever residoo guard hooks are missing (never
|
|
318
|
+
duplicates one already there), and prints the
|
|
319
|
+
result. Writes NOTHING to disk: residoo never
|
|
320
|
+
writes any file but its own rotation ledger and
|
|
321
|
+
an explicit --seal vault, so save it yourself:
|
|
322
|
+
residoo guard --print-config > ~/.claude/settings.json
|
|
323
|
+
--project targets ./.claude/settings.json (the
|
|
324
|
+
repo-local config) instead of the home-level one.
|
|
325
|
+
|
|
295
326
|
Rotation:
|
|
296
327
|
residoo explain <rule-id> full rotation runbook for one detection rule
|
|
297
328
|
(where to revoke, steps, what revocation does)
|
|
@@ -315,10 +346,19 @@ Seal options (used with scan):
|
|
|
315
346
|
in the OS keychain instead of a typed passphrase.
|
|
316
347
|
Nothing to remember, and the key's strength no longer
|
|
317
348
|
depends on passphrase choice. macOS today; Linux when
|
|
318
|
-
secret-tool (libsecret) is installed
|
|
319
|
-
|
|
349
|
+
secret-tool (libsecret) is installed; Windows via
|
|
350
|
+
DPAPI (a different mechanism, not a Windows Credential
|
|
351
|
+
Manager entry -- cmdkey.exe is confirmed write-only,
|
|
352
|
+
it can never read a stored password back out). TRADEOFF:
|
|
353
|
+
a keychain-backed vault lives on THIS machine/account
|
|
320
354
|
only, unlike a passphrase, it is not portable to
|
|
321
|
-
another machine.
|
|
355
|
+
another machine. On Windows specifically, the DPAPI-
|
|
356
|
+
wrapped key travels INSIDE the vault directory itself
|
|
357
|
+
(there's no separate OS-level store to put it in,
|
|
358
|
+
unlike macOS/Linux) -- still undecryptable without
|
|
359
|
+
being logged in as the same Windows user on the same
|
|
360
|
+
machine, but a real, disclosed difference from macOS/
|
|
361
|
+
Linux's fully separate keychain entry.
|
|
322
362
|
--vault-dir <dir> where to create the vault (default: ./residoo-vault-<stamp>)
|
|
323
363
|
--upload-cloudroam ALSO upload the sealed vault to CloudRoam. One of two
|
|
324
364
|
opt-in features that touch the network (--verify
|
|
@@ -379,31 +419,70 @@ async function getPassphrase({ confirmNew }) {
|
|
|
379
419
|
* sealFindings/deriveKey path a typed passphrase would use — scrypt on a
|
|
380
420
|
* full 256-bit-entropy input is harmless extra defense, and reusing that
|
|
381
421
|
* already-tested path means no change to sealcrypto.js/sealvault.js at all.
|
|
422
|
+
*
|
|
423
|
+
* `winKeyBlob` is Windows' equivalent of `vaultId`: there is no OS-level
|
|
424
|
+
* named credential store on Windows (see keychain.js's own
|
|
425
|
+
* wrapVaultKeyWindows docstring for exactly why, and why this is a
|
|
426
|
+
* DIFFERENT mechanism from `vaultId`'s macOS/Linux keychain.store(), not a
|
|
427
|
+
* Windows branch inside it), so instead of a lookup id, the caller gets
|
|
428
|
+
* back the actual DPAPI-wrapped key blob to persist itself once the vault
|
|
429
|
+
* directory exists — the one place on Windows with a rule-compliant
|
|
430
|
+
* reason to write it.
|
|
382
431
|
*/
|
|
383
432
|
async function resolveSealSecret(args) {
|
|
384
433
|
if (!args.includes("--keychain")) {
|
|
385
|
-
return { passphrase: await getPassphrase({ confirmNew: true }), vaultId: null };
|
|
434
|
+
return { passphrase: await getPassphrase({ confirmNew: true }), vaultId: null, winKeyBlob: null };
|
|
386
435
|
}
|
|
436
|
+
const passphrase = crypto.randomBytes(32).toString("base64");
|
|
387
437
|
const keychain = require("./keychain");
|
|
438
|
+
if (process.platform === "win32") {
|
|
439
|
+
if (!keychain.isVaultKeySupported()) throw new Error("--keychain: DPAPI is not available on this system.");
|
|
440
|
+
return { passphrase, vaultId: null, winKeyBlob: keychain.wrapVaultKeyWindows(passphrase) };
|
|
441
|
+
}
|
|
388
442
|
if (!keychain.isSupported()) throw new Error(`--keychain: ${keychain.unsupportedReason()}`);
|
|
389
443
|
const vaultId = crypto.randomUUID();
|
|
390
|
-
const passphrase = crypto.randomBytes(32).toString("base64");
|
|
391
444
|
keychain.store(vaultId, passphrase);
|
|
392
|
-
return { passphrase, vaultId };
|
|
445
|
+
return { passphrase, vaultId, winKeyBlob: null };
|
|
393
446
|
}
|
|
394
447
|
|
|
395
|
-
/**
|
|
448
|
+
/**
|
|
449
|
+
* The unsealing secret for `unseal`: a keychain-retrieved key, or a typed
|
|
450
|
+
* passphrase. Checked by which MARKER FILE is actually present in the
|
|
451
|
+
* vault directory, not by the current machine's platform — a vault sealed
|
|
452
|
+
* on Windows carries `.keychain-key-win`, one sealed on macOS/Linux
|
|
453
|
+
* carries `.keychain-id`, and trying to unseal one on the wrong kind of
|
|
454
|
+
* machine should say so clearly rather than silently mis-routing.
|
|
455
|
+
*/
|
|
396
456
|
async function resolveUnsealSecret(args, vaultDir) {
|
|
397
457
|
if (!args.includes("--keychain")) return getPassphrase({ confirmNew: false });
|
|
398
458
|
const keychain = require("./keychain");
|
|
399
|
-
|
|
459
|
+
const winKeyPath = path.join(vaultDir, ".keychain-key-win");
|
|
400
460
|
const idPath = path.join(vaultDir, ".keychain-id");
|
|
461
|
+
|
|
462
|
+
if (fs.existsSync(winKeyPath)) {
|
|
463
|
+
if (!keychain.isVaultKeySupported()) {
|
|
464
|
+
throw new Error(
|
|
465
|
+
`This vault was sealed with --keychain on Windows (DPAPI-backed). DPAPI keys are tied to that ` +
|
|
466
|
+
`Windows user account and cannot be unwrapped on ${process.platform} — unseal it on that same Windows machine.`
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
try {
|
|
470
|
+
return keychain.unwrapVaultKeyWindows(fs.readFileSync(winKeyPath, "utf-8").trim());
|
|
471
|
+
} catch {
|
|
472
|
+
throw new Error(
|
|
473
|
+
"Could not decrypt this vault's key via Windows DPAPI. It may have been sealed under a different " +
|
|
474
|
+
"Windows user account or moved to a different machine — a keychain-backed vault is not portable."
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
401
479
|
if (!fs.existsSync(idPath)) {
|
|
402
480
|
throw new Error(
|
|
403
|
-
`No .keychain-id marker in ${vaultDir}: this vault was not sealed with --keychain, ` +
|
|
481
|
+
`No .keychain-id or .keychain-key-win marker in ${vaultDir}: this vault was not sealed with --keychain, ` +
|
|
404
482
|
`or the marker file was moved separately from the vault. Try unsealing without --keychain.`
|
|
405
483
|
);
|
|
406
484
|
}
|
|
485
|
+
if (!keychain.isSupported()) throw new Error(`--keychain: ${keychain.unsupportedReason()}`);
|
|
407
486
|
const vaultId = fs.readFileSync(idPath, "utf-8").trim();
|
|
408
487
|
try {
|
|
409
488
|
return keychain.retrieve(vaultId);
|
|
@@ -444,7 +523,7 @@ async function runSeal(result, args) {
|
|
|
444
523
|
|
|
445
524
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
446
525
|
const vaultDir = argValue(args, "--vault-dir") || path.resolve(`residoo-vault-${stamp}`);
|
|
447
|
-
const { passphrase, vaultId } = await resolveSealSecret(args);
|
|
526
|
+
const { passphrase, vaultId, winKeyBlob } = await resolveSealSecret(args);
|
|
448
527
|
|
|
449
528
|
process.stdout.write(`\nSealing ${filesWithFindings.length} file(s) with findings into ${vaultDir}\n`);
|
|
450
529
|
const { entries } = await sealFindings({
|
|
@@ -456,6 +535,13 @@ async function runSeal(result, args) {
|
|
|
456
535
|
// keychain lookup, never the key itself and never anything about what the
|
|
457
536
|
// vault contains.
|
|
458
537
|
if (vaultId) fs.writeFileSync(path.join(vaultDir, ".keychain-id"), vaultId, { mode: 0o600 });
|
|
538
|
+
// Windows' equivalent marker -- unlike .keychain-id, this DOES hold
|
|
539
|
+
// sensitive material (the DPAPI-wrapped key itself, not a lookup id),
|
|
540
|
+
// which is exactly the disclosed security-property trade-off
|
|
541
|
+
// keychain.js's wrapVaultKeyWindows docstring names: on Windows the
|
|
542
|
+
// wrapped key travels with the vault, decryptable only by the same
|
|
543
|
+
// Windows user account, rather than living in a fully separate store.
|
|
544
|
+
if (winKeyBlob) fs.writeFileSync(path.join(vaultDir, ".keychain-key-win"), winKeyBlob, { mode: 0o600 });
|
|
459
545
|
const totalPlain = entries.reduce((s, e) => s + e.plainBytes, 0);
|
|
460
546
|
const totalSealed = entries.reduce((s, e) => s + e.sealedBytes, 0);
|
|
461
547
|
process.stdout.write(
|
|
@@ -688,6 +774,7 @@ async function runWatch(args) {
|
|
|
688
774
|
const verify = args.includes("--verify");
|
|
689
775
|
const noColor = args.includes("--no-color");
|
|
690
776
|
const includePii = args.includes("--include-pii");
|
|
777
|
+
const noNotify = args.includes("--no-notify");
|
|
691
778
|
|
|
692
779
|
let intervalSeconds = 5;
|
|
693
780
|
const intervalArg = argValue(args, "--interval");
|
|
@@ -713,7 +800,7 @@ async function runWatch(args) {
|
|
|
713
800
|
|
|
714
801
|
const { promise, stop } = startWatch({
|
|
715
802
|
sources,
|
|
716
|
-
options: { includeNoisy, includeSuppressed, verify, noColor, includePii, json: wantsJson, pollMs: intervalSeconds * 1000 },
|
|
803
|
+
options: { includeNoisy, includeSuppressed, verify, noColor, includePii, noNotify, json: wantsJson, pollMs: intervalSeconds * 1000 },
|
|
717
804
|
});
|
|
718
805
|
|
|
719
806
|
const printFinalSummary = (stats) => {
|
|
@@ -743,6 +830,57 @@ async function runWatch(args) {
|
|
|
743
830
|
return 0;
|
|
744
831
|
}
|
|
745
832
|
|
|
833
|
+
/**
|
|
834
|
+
* `residoo guard --print-config`: print the merged .claude/settings.json
|
|
835
|
+
* a user would need to register all three guard hooks, without ever
|
|
836
|
+
* writing it -- see guard.js's own buildHookConfig docstring for why this
|
|
837
|
+
* is print-only rather than an auto-installer. Reads the existing file
|
|
838
|
+
* (home-level by default, `--project` for the repo-local one) if present,
|
|
839
|
+
* merges in whatever hook groups are missing, and writes the result to
|
|
840
|
+
* STDOUT ONLY -- every instructional line goes to stderr, so
|
|
841
|
+
* `residoo guard --print-config > ~/.claude/settings.json` redirects
|
|
842
|
+
* exactly the JSON and nothing else.
|
|
843
|
+
*/
|
|
844
|
+
function runGuardPrintConfig(args) {
|
|
845
|
+
const wantsProject = args.includes("--project");
|
|
846
|
+
const targetPath = wantsProject
|
|
847
|
+
? path.join(process.cwd(), ".claude", "settings.json")
|
|
848
|
+
: path.join(os.homedir(), ".claude", "settings.json");
|
|
849
|
+
|
|
850
|
+
let existing = {};
|
|
851
|
+
let existedAlready = false;
|
|
852
|
+
try {
|
|
853
|
+
const raw = fs.readFileSync(targetPath, "utf-8");
|
|
854
|
+
existedAlready = true;
|
|
855
|
+
try { existing = JSON.parse(raw); }
|
|
856
|
+
catch {
|
|
857
|
+
process.stderr.write(
|
|
858
|
+
`residoo guard --print-config: ${targetPath} exists but is not valid JSON. ` +
|
|
859
|
+
`Fix it first -- printing a merge against unparseable JSON would risk losing whatever is already there.\n`
|
|
860
|
+
);
|
|
861
|
+
return 1;
|
|
862
|
+
}
|
|
863
|
+
} catch (err) {
|
|
864
|
+
if (!(err && err.code === "ENOENT")) {
|
|
865
|
+
process.stderr.write(`residoo guard --print-config: could not read ${targetPath}: ${err.message}\n`);
|
|
866
|
+
return 1;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
const merged = buildHookConfig(existing);
|
|
871
|
+
process.stderr.write(
|
|
872
|
+
(existedAlready
|
|
873
|
+
? `residoo guard --print-config: merged residoo's three hooks into your existing ${targetPath} below.\n`
|
|
874
|
+
: `residoo guard --print-config: ${targetPath} doesn't exist yet -- here's a new one with residoo's three hooks.\n`) +
|
|
875
|
+
`Nothing was written to disk -- residoo never writes any file but its own rotation ledger and an explicit ` +
|
|
876
|
+
`--seal vault. Save this yourself, e.g.:\n` +
|
|
877
|
+
` residoo guard --print-config${wantsProject ? " --project" : ""} > ${targetPath}\n` +
|
|
878
|
+
(wantsProject ? "" : "(add --project to target ./.claude/settings.json instead of the home-level one)\n")
|
|
879
|
+
);
|
|
880
|
+
process.stdout.write(JSON.stringify(merged, null, 2) + "\n");
|
|
881
|
+
return 0;
|
|
882
|
+
}
|
|
883
|
+
|
|
746
884
|
/**
|
|
747
885
|
* `residoo mcp`: run residoo as an MCP server over stdio. See src/mcp.js
|
|
748
886
|
* for the protocol engine and src/mcpTools.js for the tool catalog; this
|
|
@@ -925,7 +1063,10 @@ async function main(argv) {
|
|
|
925
1063
|
if (cmd === "watch") return runWatch(args);
|
|
926
1064
|
if (cmd === "mcp") return runMcp(args);
|
|
927
1065
|
if (cmd === "cred") return runCred(args);
|
|
928
|
-
if (cmd === "guard")
|
|
1066
|
+
if (cmd === "guard") {
|
|
1067
|
+
if (args.includes("--print-config")) return runGuardPrintConfig(args.slice(1));
|
|
1068
|
+
return runGuardEngine();
|
|
1069
|
+
}
|
|
929
1070
|
if (cmd !== "scan") {
|
|
930
1071
|
process.stderr.write(`Unknown command "${cmd}". Try "residoo --help".\n`);
|
|
931
1072
|
return 2;
|
|
@@ -1110,4 +1251,10 @@ async function main(argv) {
|
|
|
1110
1251
|
return failOnFind && (secretGate || integrityWarnCount(integrity) > 0) ? 1 : 0;
|
|
1111
1252
|
}
|
|
1112
1253
|
|
|
1113
|
-
|
|
1254
|
+
// resolveSealSecret/resolveUnsealSecret are exported alongside `main`
|
|
1255
|
+
// specifically so tests/smoke.js can exercise their Windows (DPAPI) branch
|
|
1256
|
+
// directly: a spawned subprocess (this file's usual CLI-testing precedent)
|
|
1257
|
+
// always runs on the REAL host platform, so it can never exercise
|
|
1258
|
+
// win32-only logic on a non-Windows build machine -- calling these two
|
|
1259
|
+
// in-process, with process.platform mocked, is the only way to.
|
|
1260
|
+
module.exports = { main, resolveSealSecret, resolveUnsealSecret };
|
package/src/guard.js
CHANGED
|
@@ -70,6 +70,56 @@
|
|
|
70
70
|
const { PATTERNS, redact } = require("./patterns");
|
|
71
71
|
const { VENDOR_EXAMPLE_VALUES, zeroEntropyTail } = require("./scan");
|
|
72
72
|
|
|
73
|
+
const GUARD_COMMAND = "residoo guard";
|
|
74
|
+
|
|
75
|
+
// matcher: null means Claude Code's own hook-config schema omits the field
|
|
76
|
+
// entirely for an event with no matcher support (UserPromptSubmit) --
|
|
77
|
+
// distinct from an empty string, which would mean "match everything" for
|
|
78
|
+
// an event that DOES support matchers.
|
|
79
|
+
const HOOK_EVENT_SPECS = [
|
|
80
|
+
{ event: "PreToolUse", matcher: "Bash|Read" },
|
|
81
|
+
{ event: "UserPromptSubmit", matcher: null },
|
|
82
|
+
{ event: "PostToolUse", matcher: "Bash" },
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Pure function: given a parsed Claude Code settings.json object (or `{}`
|
|
87
|
+
* for a fresh one), returns a NEW object with a "residoo guard" hook group
|
|
88
|
+
* appended to each of PreToolUse/UserPromptSubmit/PostToolUse's hook
|
|
89
|
+
* arrays -- unless a "residoo guard" command already exists ANYWHERE in
|
|
90
|
+
* that event's groups, in which case that event is left untouched
|
|
91
|
+
* (idempotent: running this against its own prior output changes
|
|
92
|
+
* nothing). Never mutates `existing`; every other key, every other tool's
|
|
93
|
+
* hook, every unrelated setting is carried through byte-for-byte.
|
|
94
|
+
*
|
|
95
|
+
* This function only COMPUTES a value -- it never touches a file, on
|
|
96
|
+
* purpose. CONTRIBUTING.md's own hard rule (rule 3) names
|
|
97
|
+
* `~/.residoo/rotations.json` as "the only file residoo ever writes
|
|
98
|
+
* outside an explicit --seal ... nothing else may claim this carve-out."
|
|
99
|
+
* An auto-installing `guard --install` that edited `.claude/settings.json`
|
|
100
|
+
* directly would violate that rule outright, so the CLI wraps this in
|
|
101
|
+
* `--print-config` instead (see cli.js's runGuardPrintConfig): print the
|
|
102
|
+
* merged JSON to stdout, let the human decide whether and where to save it.
|
|
103
|
+
*/
|
|
104
|
+
function buildHookConfig(existing) {
|
|
105
|
+
const settings = existing && typeof existing === "object" && !Array.isArray(existing)
|
|
106
|
+
? JSON.parse(JSON.stringify(existing)) : {};
|
|
107
|
+
if (!settings.hooks || typeof settings.hooks !== "object" || Array.isArray(settings.hooks)) settings.hooks = {};
|
|
108
|
+
|
|
109
|
+
for (const { event, matcher } of HOOK_EVENT_SPECS) {
|
|
110
|
+
const groups = Array.isArray(settings.hooks[event]) ? settings.hooks[event] : [];
|
|
111
|
+
const alreadyPresent = groups.some((g) =>
|
|
112
|
+
g && typeof g === "object" && Array.isArray(g.hooks) &&
|
|
113
|
+
g.hooks.some((h) => h && typeof h === "object" && h.type === "command" && h.command === GUARD_COMMAND));
|
|
114
|
+
if (alreadyPresent) { settings.hooks[event] = groups; continue; }
|
|
115
|
+
const newGroup = matcher
|
|
116
|
+
? { matcher, hooks: [{ type: "command", command: GUARD_COMMAND }] }
|
|
117
|
+
: { hooks: [{ type: "command", command: GUARD_COMMAND }] };
|
|
118
|
+
settings.hooks[event] = [...groups, newGroup];
|
|
119
|
+
}
|
|
120
|
+
return settings;
|
|
121
|
+
}
|
|
122
|
+
|
|
73
123
|
// A matched path fragment must be preceded by a path separator or the start
|
|
74
124
|
// of the string, and followed by either the end of the string (the common
|
|
75
125
|
// case for Read's file_path) or a shell metacharacter/whitespace (the case
|
|
@@ -402,5 +452,5 @@ async function runGuard({ input = process.stdin, output = process.stdout, errOut
|
|
|
402
452
|
|
|
403
453
|
module.exports = {
|
|
404
454
|
evaluateToolInput, matchSensitivePath, evaluatePromptText, evaluatePostToolUse,
|
|
405
|
-
runGuard, SENSITIVE_PATH_PATTERNS,
|
|
455
|
+
runGuard, buildHookConfig, SENSITIVE_PATH_PATTERNS,
|
|
406
456
|
};
|
package/src/integrity.js
CHANGED
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
const fs = require("fs");
|
|
4
4
|
const path = require("path");
|
|
5
5
|
const os = require("os");
|
|
6
|
+
// Not destructured: kept as `cp.execFileSync(...)` at every call site so a
|
|
7
|
+
// test can monkey-patch `require("child_process").execFileSync` (the same
|
|
8
|
+
// shared module object) the way tests/smoke.js's notify.js tests already
|
|
9
|
+
// do -- a destructured `const { execFileSync } = ...` would copy the
|
|
10
|
+
// reference at import time and never see a later patch.
|
|
11
|
+
const cp = require("child_process");
|
|
6
12
|
const { PATTERNS, redact } = require("./patterns");
|
|
7
13
|
|
|
8
14
|
/**
|
|
@@ -677,7 +683,59 @@ function checkIntegrity({ home = os.homedir(), cwd = process.cwd(), projectMode
|
|
|
677
683
|
}
|
|
678
684
|
}
|
|
679
685
|
|
|
680
|
-
//
|
|
686
|
+
// Windows NTFS ACL check for one credential-vault file, the win32
|
|
687
|
+
// counterpart to the POSIX `stat.mode` check below -- Node's fs.Stats.mode
|
|
688
|
+
// on Windows does not reflect NTFS ACLs at all, so this shells out to
|
|
689
|
+
// PowerShell's Get-Acl instead, the same "invoke the OS's own tool"
|
|
690
|
+
// pattern keychain.js already uses for macOS's `security` and Linux's
|
|
691
|
+
// `secret-tool`. Verified against Microsoft's own Get-Acl documentation
|
|
692
|
+
// (learn.microsoft.com/powershell/module/microsoft.powershell.security/get-acl)
|
|
693
|
+
// and cross-checked against independent write-ups, not live-tested
|
|
694
|
+
// against a real Windows install -- disclosed the same way copilot-cli.js's
|
|
695
|
+
// own header discloses "corroborated but unverified against a real
|
|
696
|
+
// install" for a source built without one available. icacls was
|
|
697
|
+
// considered and rejected: research found it has no `/findsid` switch
|
|
698
|
+
// and its text output is not reliably parseable, unlike Get-Acl's typed
|
|
699
|
+
// object model.
|
|
700
|
+
//
|
|
701
|
+
// Every field is projected through an explicit PSCustomObject (never the
|
|
702
|
+
// raw IdentityReference/FileSystemRights/AccessControlType objects) so
|
|
703
|
+
// JSON serialization is forced to plain strings regardless of how those
|
|
704
|
+
// .NET types would otherwise serialize -- and the result is wrapped in an
|
|
705
|
+
// array via `-InputObject` (not piped) specifically because ConvertTo-Json
|
|
706
|
+
// collapses a single-item collection into a bare object rather than a
|
|
707
|
+
// one-element array when piped, a well-known PowerShell gotcha that would
|
|
708
|
+
// otherwise break parsing on the (common) case of a file with exactly one
|
|
709
|
+
// relevant ACE.
|
|
710
|
+
//
|
|
711
|
+
// English-locale principal names only (Everyone, BUILTIN\Users, NT
|
|
712
|
+
// AUTHORITY\Authenticated Users) -- a disclosed, not silent, gap: a
|
|
713
|
+
// non-English Windows install localizes these names (e.g. "Jeder" for
|
|
714
|
+
// Everyone in German) and would not match here, the same kind of named
|
|
715
|
+
// scope limit ibanValid's per-country-length gap and the OCR module's
|
|
716
|
+
// English-only wordlist already carry elsewhere in this project.
|
|
717
|
+
function checkWindowsCredentialAcl(file) {
|
|
718
|
+
const escaped = file.replace(/'/g, "''");
|
|
719
|
+
const script =
|
|
720
|
+
"$ErrorActionPreference='Stop'; " +
|
|
721
|
+
`$acl = Get-Acl -LiteralPath '${escaped}'; ` +
|
|
722
|
+
"$rules = @($acl.Access | ForEach-Object { [PSCustomObject]@{ " +
|
|
723
|
+
"Identity = $_.IdentityReference.Value; Rights = $_.FileSystemRights.ToString(); Type = $_.AccessControlType.ToString() } }); " +
|
|
724
|
+
"ConvertTo-Json -InputObject $rules -Compress";
|
|
725
|
+
const out = cp.execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf-8" });
|
|
726
|
+
let rules;
|
|
727
|
+
try { rules = JSON.parse(out); } catch { return null; }
|
|
728
|
+
if (!Array.isArray(rules)) rules = [rules];
|
|
729
|
+
const BROAD_PRINCIPAL = /^(everyone|builtin\\users|nt authority\\authenticated users|users)$/i;
|
|
730
|
+
const READ_CAPABLE_RIGHTS = /fullcontrol|modify|read/i;
|
|
731
|
+
const hits = rules.filter((r) =>
|
|
732
|
+
r && typeof r.Identity === "string" && typeof r.Type === "string" && typeof r.Rights === "string" &&
|
|
733
|
+
r.Type === "Allow" && BROAD_PRINCIPAL.test(r.Identity.trim()) && READ_CAPABLE_RIGHTS.test(r.Rights));
|
|
734
|
+
if (hits.length === 0) return { tooOpen: false };
|
|
735
|
+
return { tooOpen: true, detail: hits.map((h) => `${h.Identity} (${h.Rights})`).join(", ") };
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// ---- 5. credential-vault file permissions (HOME only, POSIX and win32) -
|
|
681
739
|
// GitGuardian's "State of Secrets Sprawl 2026" (blog.gitguardian.com,
|
|
682
740
|
// published 2026-03-17) found 24,008 unique secrets in MCP-related config
|
|
683
741
|
// files on public GitHub, 8.8% of them still live -- these files
|
|
@@ -700,13 +758,14 @@ function checkIntegrity({ home = os.homedir(), cwd = process.cwd(), projectMode
|
|
|
700
758
|
// naive backup restore, or a shared-volume container mount can each
|
|
701
759
|
// silently widen a file's mode without the tool that wrote it ever
|
|
702
760
|
// knowing, the same way SSH itself checks id_rsa's permissions rather
|
|
703
|
-
// than trusting whoever created it. Windows
|
|
704
|
-
//
|
|
705
|
-
//
|
|
706
|
-
//
|
|
761
|
+
// than trusting whoever created it. Windows gets its own NTFS-ACL-based
|
|
762
|
+
// check (checkWindowsCredentialAcl above), not a POSIX-bit approximation
|
|
763
|
+
// -- Node's fs.Stats.mode on Windows does not reflect NTFS ACLs at all,
|
|
764
|
+
// so pretending otherwise would be meaningless, not merely imprecise.
|
|
765
|
+
// Project mode is skipped on every platform -- none of these are ever
|
|
707
766
|
// project-scoped files by any vendor's own design, so there is no
|
|
708
767
|
// project-relative equivalent to check.
|
|
709
|
-
if (!projectMode
|
|
768
|
+
if (!projectMode) {
|
|
710
769
|
const geminiDir = process.env.GEMINI_CLI_HOME
|
|
711
770
|
? path.join(process.env.GEMINI_CLI_HOME, ".gemini")
|
|
712
771
|
: path.join(home, ".gemini");
|
|
@@ -739,6 +798,24 @@ function checkIntegrity({ home = os.homedir(), cwd = process.cwd(), projectMode
|
|
|
739
798
|
}
|
|
740
799
|
continue;
|
|
741
800
|
}
|
|
801
|
+
|
|
802
|
+
if (process.platform === "win32") {
|
|
803
|
+
let acl = null;
|
|
804
|
+
try { acl = checkWindowsCredentialAcl(file); } catch { acl = null; }
|
|
805
|
+
if (!acl) {
|
|
806
|
+
mark(file, "unreadable");
|
|
807
|
+
add("warn", "unreadable-config", file, "credential file exists but its NTFS permissions could not be checked (Get-Acl failed): unverified, not clean");
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
mark(file, "checked");
|
|
811
|
+
if (acl.tooOpen) {
|
|
812
|
+
const shown = display(file);
|
|
813
|
+
add("warn", "insecure-credential-permissions", file,
|
|
814
|
+
`${note}; NTFS permissions grant ${acl.detail} access to a live credential on this machine. Fix: right-click "${shown}" > Properties > Security > Advanced, and remove any group other than your own account and the system/administrators.`);
|
|
815
|
+
}
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
|
|
742
819
|
mark(file, "checked");
|
|
743
820
|
const openBits = stat.mode & 0o077;
|
|
744
821
|
if (openBits !== 0) {
|
package/src/keychain.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// Not destructured: kept as `cp.execFileSync(...)` at every call site so a
|
|
4
|
+
// test can monkey-patch `require("child_process").execFileSync` (the same
|
|
5
|
+
// shared module object) the way tests/smoke.js's Windows-path tests do for
|
|
6
|
+
// both this file and integrity.js -- a destructured `const { execFileSync
|
|
7
|
+
// } = ...` would copy the reference at import time and never see a patch.
|
|
8
|
+
const cp = require("child_process");
|
|
4
9
|
|
|
5
10
|
/**
|
|
6
11
|
* OS-native secure credential storage for `scan --seal --keychain` and
|
|
@@ -64,7 +69,7 @@ function isSupported() {
|
|
|
64
69
|
if (process.platform === "darwin") return true;
|
|
65
70
|
if (process.platform === "linux") {
|
|
66
71
|
try {
|
|
67
|
-
execFileSync("which", ["secret-tool"], { stdio: "ignore" });
|
|
72
|
+
cp.execFileSync("which", ["secret-tool"], { stdio: "ignore" });
|
|
68
73
|
return true;
|
|
69
74
|
} catch {
|
|
70
75
|
return false;
|
|
@@ -78,9 +83,108 @@ function unsupportedReason() {
|
|
|
78
83
|
if (process.platform === "linux") {
|
|
79
84
|
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.";
|
|
80
85
|
}
|
|
86
|
+
if (process.platform === "win32") {
|
|
87
|
+
// Specifically about THIS by-name store/retrieve/remove API (residoo
|
|
88
|
+
// cred's only use of it) -- `--seal --keychain` works on Windows via a
|
|
89
|
+
// different, vault-relative mechanism (wrapVaultKeyWindows/
|
|
90
|
+
// unwrapVaultKeyWindows below), so this message must not read as "no
|
|
91
|
+
// --keychain support at all on Windows," which would be wrong.
|
|
92
|
+
return "residoo cred needs a named credential store, and Windows has none reachable without an extra dependency " +
|
|
93
|
+
"(cmdkey.exe can store a credential but never reads its password back -- confirmed against Microsoft's own docs). " +
|
|
94
|
+
"--seal --keychain works on Windows through a different mechanism; this specific store is what's unavailable.";
|
|
95
|
+
}
|
|
81
96
|
return `--keychain is not supported on ${process.platform} yet. Omit --keychain to use a passphrase instead.`;
|
|
82
97
|
}
|
|
83
98
|
|
|
99
|
+
/**
|
|
100
|
+
* True when wrapVaultKeyWindows/unwrapVaultKeyWindows (below) can run --
|
|
101
|
+
* Windows only, since DPAPI is a Windows-specific API. Deliberately
|
|
102
|
+
* SEPARATE from isSupported() above: that function is about the by-name
|
|
103
|
+
* store()/retrieve()/remove() API (false on Windows -- no OS-level named
|
|
104
|
+
* credential store is reachable there without an extra dependency, since
|
|
105
|
+
* cmdkey.exe is confirmed write/list-only, never returning a stored
|
|
106
|
+
* password). This one is about the vault-relative wrap/unwrap pair, which
|
|
107
|
+
* exists only because `--seal --keychain` has a legitimate place (the
|
|
108
|
+
* vault directory itself) to put a DPAPI-wrapped blob -- `residoo cred`
|
|
109
|
+
* has no such place and does not use this.
|
|
110
|
+
*/
|
|
111
|
+
function isVaultKeySupported() {
|
|
112
|
+
return process.platform === "win32";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Windows-only DPAPI (Data Protection API) wrap/unwrap for `--seal
|
|
117
|
+
* --keychain`'s vault key, via a PowerShell shell-out -- the same
|
|
118
|
+
* "invoke the OS's own tool" pattern as macOS's `security` and Linux's
|
|
119
|
+
* `secret-tool` above, but a materially different SHAPE, and why these
|
|
120
|
+
* two functions exist separately from store()/retrieve() rather than as a
|
|
121
|
+
* Windows branch inside them.
|
|
122
|
+
*
|
|
123
|
+
* Windows has no OS-level "store this under a name, fetch it back by that
|
|
124
|
+
* name later" service the way macOS Keychain/secret-tool do. Verified
|
|
125
|
+
* directly, not assumed: `cmdkey.exe` (Windows Credential Manager's own
|
|
126
|
+
* CLI) can WRITE a generic credential but Microsoft's own documentation
|
|
127
|
+
* states plainly "Passwords are not displayed after they're stored," and
|
|
128
|
+
* `/list` only ever surfaces target names and usernames -- cmdkey is
|
|
129
|
+
* write/list-only, and cannot serve a store-then-retrieve flow at all.
|
|
130
|
+
* DPAPI (`System.Security.Cryptography.ProtectedData`, confirmed reachable
|
|
131
|
+
* from stock PowerShell 5.1 via `Add-Type -AssemblyName System.Security`
|
|
132
|
+
* with zero extra installs -- learn.microsoft.com/dotnet/standard/security/
|
|
133
|
+
* how-to-use-data-protection) is the real built-in alternative, but it is
|
|
134
|
+
* a stateless encrypt/decrypt PRIMITIVE, not a named registry: something
|
|
135
|
+
* still has to decide where the encrypted bytes are persisted.
|
|
136
|
+
*
|
|
137
|
+
* That is why wrapVaultKeyWindows only WRAPS a secret and hands the
|
|
138
|
+
* encrypted blob straight back to its caller -- this module never decides
|
|
139
|
+
* where it lives. `--seal --keychain` (cli.js's resolveSealSecret/
|
|
140
|
+
* resolveUnsealSecret) is the one caller with a rule-compliant place to
|
|
141
|
+
* put it: the vault directory itself, already within `--seal`'s own
|
|
142
|
+
* carve-out under CONTRIBUTING.md's hard rule (residoo writes nothing
|
|
143
|
+
* outside `~/.residoo/rotations.json` and an explicit `--seal`). `residoo
|
|
144
|
+
* cred` stores a long-lived credential with no vault of its own, so there
|
|
145
|
+
* is no rule-compliant place to write a DPAPI blob for it -- it remains
|
|
146
|
+
* genuinely unsupported on Windows (isSupported() above, unchanged), not
|
|
147
|
+
* worked around by squeezing it through this pair too.
|
|
148
|
+
*
|
|
149
|
+
* Disclosed security-property difference, not glossed over: on macOS/
|
|
150
|
+
* Linux, the key lives in a genuinely separate OS-managed store, entirely
|
|
151
|
+
* absent from the vault directory -- copying just the vault gets an
|
|
152
|
+
* attacker nothing at all. On Windows, the wrapped blob travels WITH the
|
|
153
|
+
* vault directory (inside it), so copying the whole vault also copies the
|
|
154
|
+
* blob. DPAPI's CurrentUser-scope encryption still means that blob is
|
|
155
|
+
* only decryptable by the same Windows user account on the same Windows
|
|
156
|
+
* installation -- not portable, the same end-user guarantee --keychain
|
|
157
|
+
* already documents -- but a threat model where an attacker can read the
|
|
158
|
+
* vault directory's files without being able to run code as that same
|
|
159
|
+
* user gets less protection here than macOS/Linux's physically-separate
|
|
160
|
+
* store provides. Verified against Microsoft's own DPAPI documentation;
|
|
161
|
+
* not live-tested against a real Windows install.
|
|
162
|
+
*/
|
|
163
|
+
function wrapVaultKeyWindows(secret) {
|
|
164
|
+
if (process.platform !== "win32") throw new Error("wrapVaultKeyWindows is Windows-only.");
|
|
165
|
+
const escaped = String(secret).replace(/'/g, "''");
|
|
166
|
+
const script =
|
|
167
|
+
"$ErrorActionPreference='Stop'; " +
|
|
168
|
+
"Add-Type -AssemblyName System.Security; " +
|
|
169
|
+
`$bytes = [System.Text.Encoding]::UTF8.GetBytes('${escaped}'); ` +
|
|
170
|
+
"$enc = [Security.Cryptography.ProtectedData]::Protect($bytes, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser); " +
|
|
171
|
+
"[Convert]::ToBase64String($enc)";
|
|
172
|
+
return cp.execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf-8" }).trim();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The other half of wrapVaultKeyWindows -- see its docstring for the full design. */
|
|
176
|
+
function unwrapVaultKeyWindows(blob) {
|
|
177
|
+
if (process.platform !== "win32") throw new Error("unwrapVaultKeyWindows is Windows-only.");
|
|
178
|
+
const escaped = String(blob).replace(/'/g, "''");
|
|
179
|
+
const script =
|
|
180
|
+
"$ErrorActionPreference='Stop'; " +
|
|
181
|
+
"Add-Type -AssemblyName System.Security; " +
|
|
182
|
+
`$enc = [Convert]::FromBase64String('${escaped}'); ` +
|
|
183
|
+
"$dec = [Security.Cryptography.ProtectedData]::Unprotect($enc, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser); " +
|
|
184
|
+
"[System.Text.Encoding]::UTF8.GetString($dec)";
|
|
185
|
+
return cp.execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf-8" }).trim();
|
|
186
|
+
}
|
|
187
|
+
|
|
84
188
|
/**
|
|
85
189
|
* Store `secret` (a string) under `account` in the OS keychain, for later
|
|
86
190
|
* retrieve(account). `keychainFile`, macOS only, is an escape hatch used
|
|
@@ -97,13 +201,13 @@ function store(account, secret, keychainFile, service = SERVICE) {
|
|
|
97
201
|
const kf = keychainFile || testKeychainFile();
|
|
98
202
|
const args = ["add-generic-password", "-a", account, "-s", service, "-w", secret, "-U"];
|
|
99
203
|
if (kf) args.push(kf);
|
|
100
|
-
execFileSync("security", args, { stdio: "ignore" });
|
|
204
|
+
cp.execFileSync("security", args, { stdio: "ignore" });
|
|
101
205
|
return;
|
|
102
206
|
}
|
|
103
207
|
if (process.platform === "linux") {
|
|
104
208
|
// secret-tool reads the secret from stdin, never a CLI argument, so it
|
|
105
209
|
// never appears in a process listing or shell history.
|
|
106
|
-
execFileSync("secret-tool", [
|
|
210
|
+
cp.execFileSync("secret-tool", [
|
|
107
211
|
"store", "--label", "residoo sealed vault key", "service", service, "account", account,
|
|
108
212
|
], { input: secret, stdio: ["pipe", "ignore", "ignore"] });
|
|
109
213
|
return;
|
|
@@ -120,10 +224,10 @@ function retrieve(account, keychainFile, service = SERVICE) {
|
|
|
120
224
|
const kf = keychainFile || testKeychainFile();
|
|
121
225
|
const args = ["find-generic-password", "-a", account, "-s", service, "-w"];
|
|
122
226
|
if (kf) args.push(kf);
|
|
123
|
-
return execFileSync("security", args, { encoding: "utf8" }).trim();
|
|
227
|
+
return cp.execFileSync("security", args, { encoding: "utf8" }).trim();
|
|
124
228
|
}
|
|
125
229
|
if (process.platform === "linux") {
|
|
126
|
-
return execFileSync("secret-tool", [
|
|
230
|
+
return cp.execFileSync("secret-tool", [
|
|
127
231
|
"lookup", "service", service, "account", account,
|
|
128
232
|
], { encoding: "utf8" }).trim();
|
|
129
233
|
}
|
|
@@ -142,14 +246,17 @@ function remove(account, keychainFile, service = SERVICE) {
|
|
|
142
246
|
const kf = keychainFile || testKeychainFile();
|
|
143
247
|
const args = ["delete-generic-password", "-a", account, "-s", service];
|
|
144
248
|
if (kf) args.push(kf);
|
|
145
|
-
execFileSync("security", args, { stdio: "ignore" });
|
|
249
|
+
cp.execFileSync("security", args, { stdio: "ignore" });
|
|
146
250
|
return;
|
|
147
251
|
}
|
|
148
252
|
if (process.platform === "linux") {
|
|
149
|
-
execFileSync("secret-tool", ["clear", "service", service, "account", account], { stdio: "ignore" });
|
|
253
|
+
cp.execFileSync("secret-tool", ["clear", "service", service, "account", account], { stdio: "ignore" });
|
|
150
254
|
return;
|
|
151
255
|
}
|
|
152
256
|
throw new Error(unsupportedReason());
|
|
153
257
|
}
|
|
154
258
|
|
|
155
|
-
module.exports = {
|
|
259
|
+
module.exports = {
|
|
260
|
+
isSupported, unsupportedReason, store, retrieve, remove, CRED_SERVICE,
|
|
261
|
+
isVaultKeySupported, wrapVaultKeyWindows, unwrapVaultKeyWindows,
|
|
262
|
+
};
|
package/src/notify.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const cp = require("child_process");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Best-effort OS desktop notification for `residoo watch`. macOS via
|
|
7
|
+
* `osascript` (always present, no new dependency -- the same shell-out
|
|
8
|
+
* precedent `keychain.js`'s `security` and `ocr.js`'s `tesseract` already
|
|
9
|
+
* set), Linux via `notify-send` (commonly present on a desktop session,
|
|
10
|
+
* NOT guaranteed -- `watch` also runs on headless/server machines with no
|
|
11
|
+
* notification daemon at all).
|
|
12
|
+
*
|
|
13
|
+
* Windows: no built-in, dependency-free mechanism was found that doesn't
|
|
14
|
+
* either need an external module (BurntToast) or pop a blocking, modal
|
|
15
|
+
* MessageBox in front of a background process -- a disclosed scope limit,
|
|
16
|
+
* not silently assumed covered, the same posture `keychain.js` already
|
|
17
|
+
* takes for its own Windows refusal.
|
|
18
|
+
*
|
|
19
|
+
* Decoration, never the report itself: `watch`'s own `emit()` already
|
|
20
|
+
* writes every finding to stdout/stderr before this is ever called, so a
|
|
21
|
+
* missing binary, no display server, or a spawn error here must never
|
|
22
|
+
* throw, block, or affect the caller in any way -- it can only make an
|
|
23
|
+
* already-reported finding easier to notice sooner.
|
|
24
|
+
*
|
|
25
|
+
* `cp.spawn` (not destructured at module load) so a test can monkey-patch
|
|
26
|
+
* `require("child_process").spawn` directly and restore it after, without
|
|
27
|
+
* this module needing its own injectable-dependency parameter.
|
|
28
|
+
*/
|
|
29
|
+
function notifyDesktop(title, message) {
|
|
30
|
+
try {
|
|
31
|
+
if (process.platform === "darwin") {
|
|
32
|
+
// osascript's -e takes one AppleScript source string; spawn (no
|
|
33
|
+
// shell:true) passes it as a single argv entry, so there is no shell
|
|
34
|
+
// to inject into -- but the string still has to be valid AppleScript
|
|
35
|
+
// source, so its own quote/backslash characters need escaping or a
|
|
36
|
+
// stray one just breaks the script into a harmless no-op.
|
|
37
|
+
const esc = (s) => String(s).replace(/[\\"]/g, "\\$&");
|
|
38
|
+
const script = `display notification "${esc(message)}" with title "${esc(title)}"`;
|
|
39
|
+
const child = cp.spawn("osascript", ["-e", script], { stdio: "ignore" });
|
|
40
|
+
child.on("error", () => {}); // binary missing or spawn failed: never throw
|
|
41
|
+
child.unref();
|
|
42
|
+
} else if (process.platform === "linux") {
|
|
43
|
+
const child = cp.spawn("notify-send", [String(title), String(message)], { stdio: "ignore" });
|
|
44
|
+
child.on("error", () => {});
|
|
45
|
+
child.unref();
|
|
46
|
+
}
|
|
47
|
+
// Windows and anything else: no-op, disclosed above, not attempted.
|
|
48
|
+
} catch {
|
|
49
|
+
// Never let a notification failure affect the caller.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { notifyDesktop };
|
package/src/watch.js
CHANGED
|
@@ -6,6 +6,7 @@ const crypto = require("crypto");
|
|
|
6
6
|
const { scan } = require("./scan");
|
|
7
7
|
const { guidanceFor, fingerprintFinding, loadAcks, loadDismissed, statePath } = require("./rotation");
|
|
8
8
|
const { c, makePaint } = require("./color");
|
|
9
|
+
const { notifyDesktop } = require("./notify");
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* `residoo watch`: continuous, near-real-time scanning of the same
|
|
@@ -563,7 +564,7 @@ function reloadLedgerIfChanged(prev) {
|
|
|
563
564
|
* backstop and nothing riding on top of it. `options.fsWatch` is accepted
|
|
564
565
|
* and ignored, reserved for when a future version adds real hints.
|
|
565
566
|
*/
|
|
566
|
-
function startWatch({ sources, options = {}, out = process.stdout, errOut = process.stderr } = {}) {
|
|
567
|
+
function startWatch({ sources, options = {}, out = process.stdout, errOut = process.stderr, notify = notifyDesktop } = {}) {
|
|
567
568
|
const paint = makePaint(options.noColor, out);
|
|
568
569
|
const tracked = new Map();
|
|
569
570
|
const seen = new Map();
|
|
@@ -582,6 +583,17 @@ function startWatch({ sources, options = {}, out = process.stdout, errOut = proc
|
|
|
582
583
|
}
|
|
583
584
|
const line = renderHumanLine(event, paint);
|
|
584
585
|
if (line !== null) out.write(line + "\n");
|
|
586
|
+
// Desktop notification is decoration on top of the line just written
|
|
587
|
+
// above, never a substitute for it -- JSON mode is for programmatic
|
|
588
|
+
// consumption (a pipe, a log shipper), not a human sitting in front of
|
|
589
|
+
// the terminal, so it's excluded the same way `options.noColor` only
|
|
590
|
+
// applies to the human-line path. Only a genuinely NEW finding notifies
|
|
591
|
+
// -- a `reexposure` (the same secret seen again) or a `watch-error`
|
|
592
|
+
// would otherwise turn a quiet, healthy watch into a notification
|
|
593
|
+
// spamming machine.
|
|
594
|
+
if (event.type === "finding" && !options.json && !options.noNotify) {
|
|
595
|
+
notify("residoo: new secret found", `${event.label} in ${event.relFile}:${event.line} (${event.preview})`);
|
|
596
|
+
}
|
|
585
597
|
}
|
|
586
598
|
|
|
587
599
|
async function tick() {
|