residoo 0.14.0 → 0.16.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/package.json +1 -1
- package/src/cli.js +4 -2
- package/src/integrity.js +73 -0
- package/src/mcpTools.js +22 -8
- package/src/watch.js +5 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "residoo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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
|
@@ -175,7 +175,8 @@ Watch:
|
|
|
175
175
|
--verify same opt-in vendor check as scan --verify,
|
|
176
176
|
applied to each newly found credential once,
|
|
177
177
|
never to one already seen
|
|
178
|
-
--include-noisy, --include-suppressed, --no-color
|
|
178
|
+
--include-noisy, --include-suppressed, --include-pii, --no-color
|
|
179
|
+
same meaning as scan
|
|
179
180
|
Ctrl+C stops cleanly and prints a session summary (skipped with --json,
|
|
180
181
|
where the same information is one final NDJSON event).
|
|
181
182
|
|
|
@@ -663,6 +664,7 @@ async function runWatch(args) {
|
|
|
663
664
|
const includeSuppressed = args.includes("--include-suppressed");
|
|
664
665
|
const verify = args.includes("--verify");
|
|
665
666
|
const noColor = args.includes("--no-color");
|
|
667
|
+
const includePii = args.includes("--include-pii");
|
|
666
668
|
|
|
667
669
|
let intervalSeconds = 5;
|
|
668
670
|
const intervalArg = argValue(args, "--interval");
|
|
@@ -688,7 +690,7 @@ async function runWatch(args) {
|
|
|
688
690
|
|
|
689
691
|
const { promise, stop } = startWatch({
|
|
690
692
|
sources,
|
|
691
|
-
options: { includeNoisy, includeSuppressed, verify, noColor, json: wantsJson, pollMs: intervalSeconds * 1000 },
|
|
693
|
+
options: { includeNoisy, includeSuppressed, verify, noColor, includePii, json: wantsJson, pollMs: intervalSeconds * 1000 },
|
|
692
694
|
});
|
|
693
695
|
|
|
694
696
|
const printFinalSummary = (stats) => {
|
package/src/integrity.js
CHANGED
|
@@ -677,6 +677,79 @@ function checkIntegrity({ home = os.homedir(), cwd = process.cwd(), projectMode
|
|
|
677
677
|
}
|
|
678
678
|
}
|
|
679
679
|
|
|
680
|
+
// ---- 5. credential-vault file permissions (HOME only, POSIX only) ------
|
|
681
|
+
// GitGuardian's "State of Secrets Sprawl 2026" (blog.gitguardian.com,
|
|
682
|
+
// published 2026-03-17) found 24,008 unique secrets in MCP-related config
|
|
683
|
+
// files on public GitHub, 8.8% of them still live -- these files
|
|
684
|
+
// routinely hold real, working credentials. This does not scan their
|
|
685
|
+
// CONTENT: the files below are the exact "credential VAULTS ... deliberately
|
|
686
|
+
// NOT read" set from agent-configs.js's own header comment, files whose
|
|
687
|
+
// whole documented job is holding the user's own keys/tokens -- reporting
|
|
688
|
+
// their content re-reports what the user put there on purpose. What's
|
|
689
|
+
// checked instead is narrower and complementary: whether the file's own OS
|
|
690
|
+
// permission bits leak that live credential to every other account on the
|
|
691
|
+
// machine. Anthropic's own docs (code.claude.com/docs/en/authentication,
|
|
692
|
+
// "Credential management", fetched 2026-09-04) state Claude Code writes
|
|
693
|
+
// .credentials.json with file mode 0600 on Linux and as the macOS
|
|
694
|
+
// Keychain-write-failure fallback, and that Windows inherits its user
|
|
695
|
+
// profile directory's own access controls. Kiro's own security guidance
|
|
696
|
+
// separately recommends "chmod 600" on its global mcp.json -- the
|
|
697
|
+
// vendor's own admission it holds secrets (already cited in
|
|
698
|
+
// agent-configs.js). Nothing here disputes that these tools do the right
|
|
699
|
+
// thing by default; the point is drift AFTER that -- a WSL DrvFs mount, a
|
|
700
|
+
// naive backup restore, or a shared-volume container mount can each
|
|
701
|
+
// silently widen a file's mode without the tool that wrote it ever
|
|
702
|
+
// knowing, the same way SSH itself checks id_rsa's permissions rather
|
|
703
|
+
// than trusting whoever created it. Windows is skipped entirely, not
|
|
704
|
+
// approximated: Node's fs.Stats.mode on Windows does not reflect NTFS
|
|
705
|
+
// ACLs, so a POSIX-style bit check there would be meaningless rather than
|
|
706
|
+
// merely imprecise. Project mode is skipped too -- none of these are ever
|
|
707
|
+
// project-scoped files by any vendor's own design, so there is no
|
|
708
|
+
// project-relative equivalent to check.
|
|
709
|
+
if (!projectMode && process.platform !== "win32") {
|
|
710
|
+
const geminiDir = process.env.GEMINI_CLI_HOME
|
|
711
|
+
? path.join(process.env.GEMINI_CLI_HOME, ".gemini")
|
|
712
|
+
: path.join(home, ".gemini");
|
|
713
|
+
const credentialVaultFiles = [
|
|
714
|
+
{
|
|
715
|
+
file: path.join(process.env.CLAUDE_CONFIG_DIR || path.join(home, ".claude"), ".credentials.json"),
|
|
716
|
+
note: "Claude Code's OAuth login (code.claude.com/docs/en/authentication documents file mode 0600)",
|
|
717
|
+
},
|
|
718
|
+
{
|
|
719
|
+
file: path.join(process.env.CODEX_HOME || path.join(home, ".codex"), "auth.json"),
|
|
720
|
+
note: "Codex CLI's own auth store",
|
|
721
|
+
},
|
|
722
|
+
{ file: path.join(geminiDir, "oauth_creds.json"), note: "Gemini CLI's own OAuth store" },
|
|
723
|
+
{ file: path.join(geminiDir, ".env"), note: "Gemini CLI's own credential env file" },
|
|
724
|
+
{
|
|
725
|
+
file: path.join(home, ".kiro", "settings", "mcp.json"),
|
|
726
|
+
note: "Kiro's own security guidance recommends chmod 600 on this file",
|
|
727
|
+
},
|
|
728
|
+
];
|
|
729
|
+
for (const { file, note } of credentialVaultFiles) {
|
|
730
|
+
const r = path.resolve(file);
|
|
731
|
+
if (seen.has(r)) continue;
|
|
732
|
+
seen.add(r);
|
|
733
|
+
let stat;
|
|
734
|
+
try { stat = fs.statSync(file); }
|
|
735
|
+
catch (err) {
|
|
736
|
+
mark(file, err && err.code === "ENOENT" ? "absent" : "unreadable");
|
|
737
|
+
if (!(err && err.code === "ENOENT")) {
|
|
738
|
+
add("warn", "unreadable-config", file, "credential file exists but its permissions could not be checked (stat failed): unverified, not clean");
|
|
739
|
+
}
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
mark(file, "checked");
|
|
743
|
+
const openBits = stat.mode & 0o077;
|
|
744
|
+
if (openBits !== 0) {
|
|
745
|
+
const octal = (stat.mode & 0o777).toString(8).padStart(3, "0");
|
|
746
|
+
const shown = display(file);
|
|
747
|
+
add("warn", "insecure-credential-permissions", file,
|
|
748
|
+
`${note}; current mode ${octal} grants group and/or other accounts on this machine access to a live credential. Fix: chmod 600 "${shown}"`);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
680
753
|
return {
|
|
681
754
|
findings,
|
|
682
755
|
filesChecked,
|
package/src/mcpTools.js
CHANGED
|
@@ -50,11 +50,20 @@ function rejectUnknownKeys(args, allowed) {
|
|
|
50
50
|
return errs;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Shared arg shape for residoo_scan/residoo_check: includeNoisy,
|
|
55
|
+
* includeSuppressed, includePii, maxEntries. includePii is exposed here
|
|
56
|
+
* (unlike ocr or verify, see this file's own header comment on verify's
|
|
57
|
+
* exclusion) because it is architecturally identical to includeNoisy --
|
|
58
|
+
* local-only, no network call, no external process, just a different
|
|
59
|
+
* detection category (see pii.js) -- not the network/live-secret trust
|
|
60
|
+
* boundary verify's own exclusion is specifically about.
|
|
61
|
+
*/
|
|
54
62
|
function validateSweepArgs(args, allowedKeys) {
|
|
55
63
|
const errs = rejectUnknownKeys(args, allowedKeys);
|
|
56
64
|
if (args.includeNoisy !== undefined && typeof args.includeNoisy !== "boolean") errs.push("includeNoisy must be a boolean");
|
|
57
65
|
if (args.includeSuppressed !== undefined && typeof args.includeSuppressed !== "boolean") errs.push("includeSuppressed must be a boolean");
|
|
66
|
+
if (args.includePii !== undefined && typeof args.includePii !== "boolean") errs.push("includePii must be a boolean");
|
|
58
67
|
let maxEntries = 25;
|
|
59
68
|
if (args.maxEntries !== undefined) {
|
|
60
69
|
if (typeof args.maxEntries !== "number" || !Number.isInteger(args.maxEntries) || args.maxEntries < 1 || args.maxEntries > 200) {
|
|
@@ -63,7 +72,10 @@ function validateSweepArgs(args, allowedKeys) {
|
|
|
63
72
|
maxEntries = args.maxEntries;
|
|
64
73
|
}
|
|
65
74
|
}
|
|
66
|
-
return {
|
|
75
|
+
return {
|
|
76
|
+
errs, includeNoisy: args.includeNoisy === true, includeSuppressed: args.includeSuppressed === true,
|
|
77
|
+
includePii: args.includePii === true, maxEntries,
|
|
78
|
+
};
|
|
67
79
|
}
|
|
68
80
|
|
|
69
81
|
/** Drop the full step-by-step runbook (redundant once per shared rule id across many entries -- call residoo_explain for that) and any null-valued optional field. */
|
|
@@ -122,8 +134,8 @@ function buildTools({ sources }) {
|
|
|
122
134
|
let checkStarted = false;
|
|
123
135
|
|
|
124
136
|
async function handleScan(args) {
|
|
125
|
-
const SCAN_KEYS = new Set(["projectDir", "includeNoisy", "includeSuppressed", "maxEntries"]);
|
|
126
|
-
const { errs, includeNoisy, includeSuppressed, maxEntries } = validateSweepArgs(args, SCAN_KEYS);
|
|
137
|
+
const SCAN_KEYS = new Set(["projectDir", "includeNoisy", "includeSuppressed", "includePii", "maxEntries"]);
|
|
138
|
+
const { errs, includeNoisy, includeSuppressed, includePii, maxEntries } = validateSweepArgs(args, SCAN_KEYS);
|
|
127
139
|
if (args.projectDir !== undefined && typeof args.projectDir !== "string") errs.push("projectDir must be a string");
|
|
128
140
|
if (errs.length) return errorResult(`Invalid arguments: ${errs.join("; ")}`);
|
|
129
141
|
|
|
@@ -140,7 +152,7 @@ function buildTools({ sources }) {
|
|
|
140
152
|
scanSources = sources;
|
|
141
153
|
}
|
|
142
154
|
|
|
143
|
-
const result = await scan({ sources: scanSources, includeNoisy, includeSuppressed, verify: false, noColor: true });
|
|
155
|
+
const result = await scan({ sources: scanSources, includeNoisy, includeSuppressed, includePii, verify: false, noColor: true });
|
|
144
156
|
const acks = loadAcks();
|
|
145
157
|
const dismissed = loadDismissed();
|
|
146
158
|
const rotation = renderRotation(result.findings, acks, dismissed);
|
|
@@ -167,8 +179,8 @@ function buildTools({ sources }) {
|
|
|
167
179
|
}
|
|
168
180
|
|
|
169
181
|
async function handleCheck(args) {
|
|
170
|
-
const CHECK_KEYS = new Set(["includeNoisy", "includeSuppressed", "maxEntries"]);
|
|
171
|
-
const { errs, includeNoisy, includeSuppressed, maxEntries } = validateSweepArgs(args, CHECK_KEYS);
|
|
182
|
+
const CHECK_KEYS = new Set(["includeNoisy", "includeSuppressed", "includePii", "maxEntries"]);
|
|
183
|
+
const { errs, includeNoisy, includeSuppressed, includePii, maxEntries } = validateSweepArgs(args, CHECK_KEYS);
|
|
172
184
|
if (errs.length) return errorResult(`Invalid arguments: ${errs.join("; ")}`);
|
|
173
185
|
|
|
174
186
|
const firstCheckThisSession = !checkStarted;
|
|
@@ -179,7 +191,7 @@ function buildTools({ sources }) {
|
|
|
179
191
|
const emit = (e) => events.push(e);
|
|
180
192
|
const stats = await sweepOnce({
|
|
181
193
|
sources, tracked: checkTracked, seen: checkSeen, ledger,
|
|
182
|
-
options: { includeNoisy, includeSuppressed, verify: false, noColor: true }, emit,
|
|
194
|
+
options: { includeNoisy, includeSuppressed, includePii, verify: false, noColor: true }, emit,
|
|
183
195
|
});
|
|
184
196
|
|
|
185
197
|
const allNew = events.filter((e) => e.type === "finding");
|
|
@@ -353,6 +365,7 @@ function buildTools({ sources }) {
|
|
|
353
365
|
projectDir: { type: "string", description: "Absolute path to a project/repo directory to scan instead of the machine-wide transcript stores (same as `residoo scan --project <dir>`). Omit for the default machine-wide scan." },
|
|
354
366
|
includeNoisy: { type: "boolean", default: false, description: "Also run residoo's two low-confidence heuristic rules (generic password/secret assignments) -- catches more, false-positives more. Off by default." },
|
|
355
367
|
includeSuppressed: { type: "boolean", default: false, description: "Include matches normally hidden because they look like vendor-documented example values or placeholder text. Off by default." },
|
|
368
|
+
includePii: { type: "boolean", default: false, description: "Also scan for PII (US Social Security Numbers, Luhn-validated credit card numbers, checksum-validated IBANs) -- a different risk category from a credential, not a lower confidence bar. Off by default; residoo is deliberately credentials-only otherwise." },
|
|
356
369
|
maxEntries: { type: "integer", minimum: 1, maximum: 200, default: 25, description: "Cap on distinct findings returned in full detail, pending-first. Counts in the response are always exact even when the entry list is truncated." },
|
|
357
370
|
},
|
|
358
371
|
required: [],
|
|
@@ -368,6 +381,7 @@ function buildTools({ sources }) {
|
|
|
368
381
|
properties: {
|
|
369
382
|
includeNoisy: { type: "boolean", default: false, description: "Same meaning as residoo_scan." },
|
|
370
383
|
includeSuppressed: { type: "boolean", default: false, description: "Same meaning as residoo_scan." },
|
|
384
|
+
includePii: { type: "boolean", default: false, description: "Same meaning as residoo_scan." },
|
|
371
385
|
maxEntries: { type: "integer", minimum: 1, maximum: 200, default: 25, description: "Cap on new findings / re-exposures returned in full detail. Counts are always exact even when truncated." },
|
|
372
386
|
},
|
|
373
387
|
required: [],
|
package/src/watch.js
CHANGED
|
@@ -266,14 +266,14 @@ function makeSyntheticSource(realId, batchesByFile) {
|
|
|
266
266
|
* `verify` is always forced off here: seeding a dedup cache must never be
|
|
267
267
|
* the reason a live vendor API gets hit.
|
|
268
268
|
*/
|
|
269
|
-
async function baselineSeed(source, sourceId, file, sizeBytes, mtimeMs, seen, includeNoisy, includeSuppressed, noColor) {
|
|
269
|
+
async function baselineSeed(source, sourceId, file, sizeBytes, mtimeMs, seen, includeNoisy, includeSuppressed, noColor, includePii) {
|
|
270
270
|
const batch = await readWholeFile(source, file, sizeBytes, mtimeMs);
|
|
271
271
|
if (!batch) return;
|
|
272
272
|
let result;
|
|
273
273
|
try {
|
|
274
274
|
result = await scan({
|
|
275
275
|
sources: [makeSyntheticSource(sourceId, new Map([[file, batch]]))],
|
|
276
|
-
includeNoisy, includeSuppressed, verify: false, noColor,
|
|
276
|
+
includeNoisy, includeSuppressed, verify: false, noColor, includePii,
|
|
277
277
|
});
|
|
278
278
|
} catch {
|
|
279
279
|
return; // best-effort: a failure here just leaves this file's dedup
|
|
@@ -299,7 +299,7 @@ async function baselineSeed(source, sourceId, file, sizeBytes, mtimeMs, seen, in
|
|
|
299
299
|
* `dismiss` takes effect without a restart.
|
|
300
300
|
*/
|
|
301
301
|
async function sweepOnce({ sources, tracked, seen, ledger, options, emit }) {
|
|
302
|
-
const { includeNoisy, includeSuppressed, verify, noColor } = options || {};
|
|
302
|
+
const { includeNoisy, includeSuppressed, verify, noColor, includePii } = options || {};
|
|
303
303
|
let loud = 0;
|
|
304
304
|
let quiet = 0;
|
|
305
305
|
let suppressedByLedger = 0;
|
|
@@ -363,7 +363,7 @@ async function sweepOnce({ sources, tracked, seen, ledger, options, emit }) {
|
|
|
363
363
|
contentHash: tailable ? null : wholeFileHash(file),
|
|
364
364
|
});
|
|
365
365
|
if (!tailable) {
|
|
366
|
-
await baselineSeed(source, sourceId, file, sizeBytes, mtimeMs, seen, includeNoisy, includeSuppressed, noColor);
|
|
366
|
+
await baselineSeed(source, sourceId, file, sizeBytes, mtimeMs, seen, includeNoisy, includeSuppressed, noColor, includePii);
|
|
367
367
|
}
|
|
368
368
|
continue;
|
|
369
369
|
}
|
|
@@ -432,7 +432,7 @@ async function sweepOnce({ sources, tracked, seen, ledger, options, emit }) {
|
|
|
432
432
|
try {
|
|
433
433
|
result = await scan({
|
|
434
434
|
sources: [makeSyntheticSource(sourceId, batchesByFile)],
|
|
435
|
-
includeNoisy, includeSuppressed, verify, noColor,
|
|
435
|
+
includeNoisy, includeSuppressed, verify, noColor, includePii,
|
|
436
436
|
});
|
|
437
437
|
} catch (err) {
|
|
438
438
|
emit({ type: "watch-error", at: new Date(), source: sourceId, detail: "scan failed: " + (err && err.message) });
|