residoo 0.17.0 → 0.18.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 +13 -3
- package/package.json +1 -1
- package/src/cli.js +81 -3
- package/src/guard.js +51 -1
- 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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "residoo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.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
|
|
|
@@ -292,6 +302,19 @@ Guard:
|
|
|
292
302
|
"hooks":[{"type":"command",
|
|
293
303
|
"command":"residoo guard"}]}]}}
|
|
294
304
|
|
|
305
|
+
residoo guard --print-config
|
|
306
|
+
print that same merged JSON to stdout instead of
|
|
307
|
+
hand-writing it -- reads your existing
|
|
308
|
+
~/.claude/settings.json if present, adds
|
|
309
|
+
whatever residoo guard hooks are missing (never
|
|
310
|
+
duplicates one already there), and prints the
|
|
311
|
+
result. Writes NOTHING to disk: residoo never
|
|
312
|
+
writes any file but its own rotation ledger and
|
|
313
|
+
an explicit --seal vault, so save it yourself:
|
|
314
|
+
residoo guard --print-config > ~/.claude/settings.json
|
|
315
|
+
--project targets ./.claude/settings.json (the
|
|
316
|
+
repo-local config) instead of the home-level one.
|
|
317
|
+
|
|
295
318
|
Rotation:
|
|
296
319
|
residoo explain <rule-id> full rotation runbook for one detection rule
|
|
297
320
|
(where to revoke, steps, what revocation does)
|
|
@@ -688,6 +711,7 @@ async function runWatch(args) {
|
|
|
688
711
|
const verify = args.includes("--verify");
|
|
689
712
|
const noColor = args.includes("--no-color");
|
|
690
713
|
const includePii = args.includes("--include-pii");
|
|
714
|
+
const noNotify = args.includes("--no-notify");
|
|
691
715
|
|
|
692
716
|
let intervalSeconds = 5;
|
|
693
717
|
const intervalArg = argValue(args, "--interval");
|
|
@@ -713,7 +737,7 @@ async function runWatch(args) {
|
|
|
713
737
|
|
|
714
738
|
const { promise, stop } = startWatch({
|
|
715
739
|
sources,
|
|
716
|
-
options: { includeNoisy, includeSuppressed, verify, noColor, includePii, json: wantsJson, pollMs: intervalSeconds * 1000 },
|
|
740
|
+
options: { includeNoisy, includeSuppressed, verify, noColor, includePii, noNotify, json: wantsJson, pollMs: intervalSeconds * 1000 },
|
|
717
741
|
});
|
|
718
742
|
|
|
719
743
|
const printFinalSummary = (stats) => {
|
|
@@ -743,6 +767,57 @@ async function runWatch(args) {
|
|
|
743
767
|
return 0;
|
|
744
768
|
}
|
|
745
769
|
|
|
770
|
+
/**
|
|
771
|
+
* `residoo guard --print-config`: print the merged .claude/settings.json
|
|
772
|
+
* a user would need to register all three guard hooks, without ever
|
|
773
|
+
* writing it -- see guard.js's own buildHookConfig docstring for why this
|
|
774
|
+
* is print-only rather than an auto-installer. Reads the existing file
|
|
775
|
+
* (home-level by default, `--project` for the repo-local one) if present,
|
|
776
|
+
* merges in whatever hook groups are missing, and writes the result to
|
|
777
|
+
* STDOUT ONLY -- every instructional line goes to stderr, so
|
|
778
|
+
* `residoo guard --print-config > ~/.claude/settings.json` redirects
|
|
779
|
+
* exactly the JSON and nothing else.
|
|
780
|
+
*/
|
|
781
|
+
function runGuardPrintConfig(args) {
|
|
782
|
+
const wantsProject = args.includes("--project");
|
|
783
|
+
const targetPath = wantsProject
|
|
784
|
+
? path.join(process.cwd(), ".claude", "settings.json")
|
|
785
|
+
: path.join(os.homedir(), ".claude", "settings.json");
|
|
786
|
+
|
|
787
|
+
let existing = {};
|
|
788
|
+
let existedAlready = false;
|
|
789
|
+
try {
|
|
790
|
+
const raw = fs.readFileSync(targetPath, "utf-8");
|
|
791
|
+
existedAlready = true;
|
|
792
|
+
try { existing = JSON.parse(raw); }
|
|
793
|
+
catch {
|
|
794
|
+
process.stderr.write(
|
|
795
|
+
`residoo guard --print-config: ${targetPath} exists but is not valid JSON. ` +
|
|
796
|
+
`Fix it first -- printing a merge against unparseable JSON would risk losing whatever is already there.\n`
|
|
797
|
+
);
|
|
798
|
+
return 1;
|
|
799
|
+
}
|
|
800
|
+
} catch (err) {
|
|
801
|
+
if (!(err && err.code === "ENOENT")) {
|
|
802
|
+
process.stderr.write(`residoo guard --print-config: could not read ${targetPath}: ${err.message}\n`);
|
|
803
|
+
return 1;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const merged = buildHookConfig(existing);
|
|
808
|
+
process.stderr.write(
|
|
809
|
+
(existedAlready
|
|
810
|
+
? `residoo guard --print-config: merged residoo's three hooks into your existing ${targetPath} below.\n`
|
|
811
|
+
: `residoo guard --print-config: ${targetPath} doesn't exist yet -- here's a new one with residoo's three hooks.\n`) +
|
|
812
|
+
`Nothing was written to disk -- residoo never writes any file but its own rotation ledger and an explicit ` +
|
|
813
|
+
`--seal vault. Save this yourself, e.g.:\n` +
|
|
814
|
+
` residoo guard --print-config${wantsProject ? " --project" : ""} > ${targetPath}\n` +
|
|
815
|
+
(wantsProject ? "" : "(add --project to target ./.claude/settings.json instead of the home-level one)\n")
|
|
816
|
+
);
|
|
817
|
+
process.stdout.write(JSON.stringify(merged, null, 2) + "\n");
|
|
818
|
+
return 0;
|
|
819
|
+
}
|
|
820
|
+
|
|
746
821
|
/**
|
|
747
822
|
* `residoo mcp`: run residoo as an MCP server over stdio. See src/mcp.js
|
|
748
823
|
* for the protocol engine and src/mcpTools.js for the tool catalog; this
|
|
@@ -925,7 +1000,10 @@ async function main(argv) {
|
|
|
925
1000
|
if (cmd === "watch") return runWatch(args);
|
|
926
1001
|
if (cmd === "mcp") return runMcp(args);
|
|
927
1002
|
if (cmd === "cred") return runCred(args);
|
|
928
|
-
if (cmd === "guard")
|
|
1003
|
+
if (cmd === "guard") {
|
|
1004
|
+
if (args.includes("--print-config")) return runGuardPrintConfig(args.slice(1));
|
|
1005
|
+
return runGuardEngine();
|
|
1006
|
+
}
|
|
929
1007
|
if (cmd !== "scan") {
|
|
930
1008
|
process.stderr.write(`Unknown command "${cmd}". Try "residoo --help".\n`);
|
|
931
1009
|
return 2;
|
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/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() {
|