residoo 0.12.0 → 0.14.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "residoo",
3
- "version": "0.12.0",
3
+ "version": "0.14.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
@@ -150,6 +150,14 @@ Scan options:
150
150
  similar characters (0/O, Y/*) can be misread,
151
151
  which breaks an exact-format match, so this is
152
152
  best-effort additional coverage, not a guarantee.
153
+ --include-pii also scan for PII: US Social Security Numbers
154
+ (dashed format only), credit card numbers
155
+ (Luhn-validated), and IBANs (checksum-validated).
156
+ A different RISK CATEGORY, not a lower confidence
157
+ bar -- residoo is deliberately credentials-only
158
+ by default. Deliberately excludes bare email/
159
+ phone (too common in ordinary text to meet this
160
+ project's own high-confidence bar even opt-in).
153
161
 
154
162
  Watch:
155
163
  residoo watch continuous scanning instead of one snapshot:
@@ -918,6 +926,12 @@ async function main(argv) {
918
926
  // exact confirmed image shape this looks for and its honest accuracy
919
927
  // limitations.
920
928
  const wantsOcr = args.includes("--ocr");
929
+ // --include-pii: a different RISK CATEGORY, not a lower confidence bar
930
+ // (see pii.js) -- residoo is deliberately credentials-only by default;
931
+ // this opts into three checksum-validated categories (SSN, Luhn-valid
932
+ // card numbers, IBAN) rather than the shape-only, much noisier
933
+ // categories (bare email, phone) some competitors also ship.
934
+ const wantsPii = args.includes("--include-pii");
921
935
 
922
936
  // --project [dir]: the dir is optional (CI passes ".", a bare --project
923
937
  // means the current directory). null means machine mode.
@@ -1029,7 +1043,7 @@ async function main(argv) {
1029
1043
 
1030
1044
  const progress = makeProgressReporter(noColor);
1031
1045
  const result = await scan({
1032
- sources, includeNoisy, includeSuppressed, verify, noColor, ocr: wantsOcr,
1046
+ sources, includeNoisy, includeSuppressed, verify, noColor, ocr: wantsOcr, includePii: wantsPii,
1033
1047
  onProgress: progress.onProgress,
1034
1048
  // Clears the spinner's last frame before --verify's own stderr lines
1035
1049
  // print; without this the last spinner line sits uncleared on screen
package/src/guard.js CHANGED
@@ -153,13 +153,14 @@ const GUARDED_TOOL_NAMES = new Set(["Bash", "Read"]);
153
153
  */
154
154
  function evaluateToolInput(toolName, toolInput) {
155
155
  if (!GUARDED_TOOL_NAMES.has(toolName) || !toolInput || typeof toolInput !== "object") {
156
- return { block: false, reason: null };
156
+ return { block: false, label: null, reason: null };
157
157
  }
158
158
  const candidate = toolName === "Bash" ? toolInput.command : toolInput.file_path;
159
159
  const label = matchSensitivePath(candidate);
160
- if (!label) return { block: false, reason: null };
160
+ if (!label) return { block: false, label: null, reason: null };
161
161
  return {
162
162
  block: true,
163
+ label,
163
164
  reason: `residoo guard: this looks like a read of ${label}. Blocked before it could be written to the session transcript. ` +
164
165
  `If this is intentional and safe, ask the human to read it themselves, or disable this hook in .claude/settings.json.`,
165
166
  };
@@ -183,20 +184,44 @@ const PROMPT_GUARD_RULES = PATTERNS.filter((r) => r.confidence === "high");
183
184
  * evaluateToolInput above, not just a copy of the same bar.
184
185
  */
185
186
  function evaluatePromptText(promptText) {
186
- if (typeof promptText !== "string" || !promptText) return { block: false, reason: null };
187
+ if (typeof promptText !== "string" || !promptText) return { block: false, label: null, preview: null, reason: null };
187
188
  for (const rule of PROMPT_GUARD_RULES) {
188
189
  rule.re.lastIndex = 0;
189
190
  const m = rule.re.exec(promptText);
190
191
  if (!m) continue;
191
192
  const value = m[0];
192
193
  if (VENDOR_EXAMPLE_VALUES.has(value) || zeroEntropyTail(value)) continue;
194
+ const preview = redact(value);
193
195
  return {
194
196
  block: true,
195
- reason: `residoo guard: this prompt looks like it contains ${rule.label} (${redact(value)}). ` +
197
+ label: rule.label,
198
+ preview,
199
+ reason: `residoo guard: this prompt looks like it contains ${rule.label} (${preview}). ` +
196
200
  `Blocked before it could be sent. If this is a false positive, rephrase or remove it, or disable this hook in .claude/settings.json.`,
197
201
  };
198
202
  }
199
- return { block: false, reason: null };
203
+ return { block: false, label: null, preview: null, reason: null };
204
+ }
205
+
206
+ /**
207
+ * Writes one structured audit line to stderr for a block decision --
208
+ * CONTRIBUTING.md's own hard rule (rule 3) names `~/.residoo/rotations.json`
209
+ * as "the only file residoo ever writes outside an explicit --seal...
210
+ * nothing else may claim this carve-out," so this is NOT a new file, the
211
+ * same choice `cred`'s own audit trail already made for the same reason
212
+ * (see src/credRun.js). Durability is the operator's choice: redirect the
213
+ * hook's own stderr at launch if you want it kept, same as `cred`.
214
+ * Never the raw matched value -- `preview` is already redact()'d by the
215
+ * caller (rule 4: no raw value in any log line, ever), and PreToolUse
216
+ * decisions carry no value at all, only a path-pattern label.
217
+ */
218
+ function logAuditLine(errOutput, { event, label, preview, sessionId, cwd }) {
219
+ try {
220
+ errOutput.write(JSON.stringify({
221
+ ts: new Date().toISOString(), tool: "residoo guard", event, decision: "block",
222
+ label, ...(preview ? { preview } : {}), sessionId: sessionId || null, cwd: cwd || null,
223
+ }) + "\n");
224
+ } catch { /* stderr write failing is never a reason to fail the hook decision itself */ }
200
225
  }
201
226
 
202
227
  /**
@@ -207,9 +232,11 @@ function evaluatePromptText(promptText) {
207
232
  * response protocol to `output` (default stdout) -- exit code is the
208
233
  * caller's job (bin/residoo.js), this returns the intended process exit
209
234
  * code instead of calling process.exit itself, matching every other run*
210
- * function in cli.js.
235
+ * function in cli.js. Every BLOCK decision also gets one structured line
236
+ * on `errOutput` (default stderr) -- see logAuditLine's own docstring for
237
+ * why stderr, never a file.
211
238
  */
212
- async function runGuard({ input = process.stdin, output = process.stdout } = {}) {
239
+ async function runGuard({ input = process.stdin, output = process.stdout, errOutput = process.stderr } = {}) {
213
240
  const chunks = [];
214
241
  for await (const chunk of input) chunks.push(chunk);
215
242
  const raw = Buffer.concat(chunks.map((c) => (Buffer.isBuffer(c) ? c : Buffer.from(c)))).toString("utf-8");
@@ -231,6 +258,10 @@ async function runGuard({ input = process.stdin, output = process.stdout } = {})
231
258
  if (payload.hook_event_name === "UserPromptSubmit") {
232
259
  const decision = evaluatePromptText(payload.prompt);
233
260
  if (!decision.block) return 0;
261
+ logAuditLine(errOutput, {
262
+ event: "UserPromptSubmit", label: decision.label, preview: decision.preview,
263
+ sessionId: payload.session_id, cwd: payload.cwd,
264
+ });
234
265
  output.write(JSON.stringify({ decision: "block", reason: decision.reason }) + "\n");
235
266
  return 0;
236
267
  }
@@ -238,6 +269,10 @@ async function runGuard({ input = process.stdin, output = process.stdout } = {})
238
269
  const decision = evaluateToolInput(payload.tool_name, payload.tool_input);
239
270
  if (!decision.block) return 0;
240
271
 
272
+ logAuditLine(errOutput, {
273
+ event: "PreToolUse", label: decision.label,
274
+ sessionId: payload.session_id, cwd: payload.cwd,
275
+ });
241
276
  output.write(JSON.stringify({
242
277
  hookSpecificOutput: {
243
278
  hookEventName: "PreToolUse",
package/src/pii.js ADDED
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Opt-in PII detection (--include-pii). Named directly by this session's
5
+ * own competitive research into funded AI-DLP vendors (Strac, Cyberhaven,
6
+ * Nightfall) as one of the few concrete, buildable things residoo could
7
+ * adopt without becoming a hosted service -- and independently
8
+ * corroborated by two direct competitors' own shipped detector lists
9
+ * (DidILeak, Medusa), both of which cover PII alongside credentials.
10
+ *
11
+ * Kept entirely separate from PATTERNS/NOISY_PATTERNS in patterns.js on
12
+ * purpose: residoo's stated identity elsewhere in this project is
13
+ * "deliberately credentials-only" (see docs/comparison.md's DidILeak
14
+ * section), and this module doesn't change that default -- it's a
15
+ * separate, explicitly opt-in category a user has to ask for, the same
16
+ * relationship NOISY_PATTERNS already has to the default rule set, just
17
+ * for a different reason (a different RISK CATEGORY, not a lower
18
+ * confidence bar).
19
+ *
20
+ * Only three detectors, deliberately: DidILeak's own shipped list also
21
+ * includes bare email addresses and phone numbers, but both are far too
22
+ * common in ordinary, non-sensitive text (a support email in a comment, a
23
+ * phone number in an error message) to meet this project's own
24
+ * "high-confidence only, a security tool that cries wolf gets
25
+ * uninstalled" bar, opt-in or not -- DidILeak itself rates them "low"/
26
+ * "info" severity for the same reason. The three included here all have a
27
+ * REAL mathematical validator, not just a shape match, which is what
28
+ * keeps false-positive risk low enough to ship even as an additive
29
+ * category: Luhn for card numbers, ISO 7064 MOD 97-10 for IBAN, and the
30
+ * Social Security Administration's own published invalid-range rules for
31
+ * SSNs (no checksum exists for SSNs, hence "medium" confidence there, not
32
+ * "high" -- disclosed, not smoothed over).
33
+ */
34
+
35
+ /** Luhn checksum (ISO/IEC 7812-1): the standard validator for payment card numbers. `digits` must already be digits-only. */
36
+ function luhnValid(digits) {
37
+ let sum = 0;
38
+ let alt = false;
39
+ for (let i = digits.length - 1; i >= 0; i--) {
40
+ let d = digits.charCodeAt(i) - 48;
41
+ if (alt) { d *= 2; if (d > 9) d -= 9; }
42
+ sum += d;
43
+ alt = !alt;
44
+ }
45
+ return sum % 10 === 0;
46
+ }
47
+
48
+ /**
49
+ * ISO 7064 MOD 97-10 (the IBAN checksum): move the first 4 characters to
50
+ * the end, map letters to numbers (A=10..Z=35), and the whole numeral must
51
+ * be congruent to 1 mod 97. Computed digit-by-digit since the numeral is
52
+ * far larger than any JS integer.
53
+ *
54
+ * Scope, disclosed rather than assumed complete: this checks the generic
55
+ * ISO 13616 structural rule and the checksum, not each of the ~70
56
+ * IBAN-issuing countries' own exact fixed length (a German IBAN is always
57
+ * 22 characters, a French one always 27) -- a real per-country length
58
+ * table would need to be built and kept current; the checksum alone
59
+ * already rejects the overwhelming majority of non-IBAN digit/letter runs.
60
+ */
61
+ function ibanValid(iban) {
62
+ if (iban.length < 15 || iban.length > 34) return false;
63
+ const rearranged = iban.slice(4) + iban.slice(0, 4);
64
+ let mod = 0;
65
+ for (const ch of rearranged) {
66
+ const code = ch.charCodeAt(0);
67
+ const value = code >= 65 && code <= 90 ? code - 55 : code - 48; // A-Z -> 10-35, else digit
68
+ if (value < 0 || value > 35) return false;
69
+ const digits = value >= 10 ? String(value) : ch;
70
+ for (const d of digits) mod = (mod * 10 + (d.charCodeAt(0) - 48)) % 97;
71
+ }
72
+ return mod === 1;
73
+ }
74
+
75
+ const PII_PATTERNS = [
76
+ // Dashed format only -- a bare 9-digit run is indistinguishable from
77
+ // countless other numbers in a coding-agent transcript (ports, PIDs,
78
+ // timestamps) and would make this rule pure noise. Invalid-range
79
+ // exclusions are the Social Security Administration's own published
80
+ // rules: area 000, 666, and 900-999 were never issued; group 00 and
81
+ // serial 0000 are never valid within an otherwise-real-shaped number.
82
+ { id: "us_ssn", label: "US Social Security Number", confidence: "medium",
83
+ re: /\b(?!000|666|9\d{2})\d{3}-(?!00)\d{2}-(?!0000)\d{4}\b/g },
84
+ // Candidate digit runs (13-19 digits, ISO/IEC 7812's own real-world
85
+ // range, optionally space- or dash-separated the way a human actually
86
+ // types a card number) are Luhn-validated below before ever being
87
+ // reported -- the regex alone is not the detector.
88
+ { id: "credit_card_number", label: "Credit card number (Luhn-validated)", confidence: "high",
89
+ re: /\b\d(?:[ -]?\d){12,18}\b/g,
90
+ validate: (m) => { const d = m.replace(/[ -]/g, ""); return d.length >= 13 && d.length <= 19 && luhnValid(d); } },
91
+ { id: "iban", label: "IBAN (checksum-validated)", confidence: "high",
92
+ re: /\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b/g,
93
+ validate: (m) => ibanValid(m) },
94
+ ];
95
+
96
+ module.exports = { PII_PATTERNS, luhnValid, ibanValid };
package/src/report.js CHANGED
@@ -534,15 +534,18 @@ function renderJson(result, integrity = null, rotation = null) {
534
534
  rule: f.ruleId, label: f.label, confidence: f.confidence,
535
535
  source: f.source, file: f.relFile, line: f.line, preview: f.preview,
536
536
  fileMTimeMs: f.fileMTimeMs,
537
- // Markers for the decode/reconstruct/OCR passes (absent on ordinary
538
- // findings). `encoding` names how the value was wrapped ("base64" /
539
- // "base64url"); `spanLines` names the adjacent line pair a split value
540
- // was reconstructed across; `ocr` means the value was never plain
541
- // text at all -- it was read out of a pasted or tool-returned image
542
- // (see ocr.js).
537
+ // Markers for the decode/reconstruct/OCR/PII passes (absent on
538
+ // ordinary findings). `encoding` names how the value was wrapped
539
+ // ("base64" / "base64url"); `spanLines` names the adjacent line
540
+ // pair a split value was reconstructed across; `ocr` means the
541
+ // value was never plain text at all -- it was read out of a
542
+ // pasted or tool-returned image (see ocr.js); `pii` means this is
543
+ // a --include-pii finding, a different risk category from a
544
+ // credential, not a rule from the default set (see pii.js).
543
545
  ...(f.encoding ? { encoding: f.encoding } : {}),
544
546
  ...(f.spanLines ? { spanLines: f.spanLines } : {}),
545
547
  ...(f.ocr ? { ocr: true } : {}),
548
+ ...(f.pii ? { pii: true } : {}),
546
549
  fingerprint: fingerprintFinding(f),
547
550
  // Only present on an --include-suppressed run: says WHY this finding
548
551
  // is low-confidence, so a JSON consumer doesn't have to guess.
package/src/rotation.js CHANGED
@@ -1146,6 +1146,42 @@ const ROTATION_GUIDANCE = {
1146
1146
  revokeNote: "Revocation is immediate; anything still using the old token starts failing authentication at once.",
1147
1147
  },
1148
1148
 
1149
+ // ── PII (only reachable via --include-pii) ─────────────────────────────
1150
+ // Framed deliberately differently from every entry above: PII has no
1151
+ // issuer to revoke a credential with, no console to rotate it in. The
1152
+ // real action is "should this data exist in this file at all" and, if it
1153
+ // was genuinely exposed to someone who shouldn't have it, who to tell.
1154
+ us_ssn: {
1155
+ label: "US Social Security Number",
1156
+ consolePath: "There is no vendor console for this -- an SSN can't be rotated the way a credential can.",
1157
+ steps: [
1158
+ "Confirm this is a real SSN and not a placeholder/test value (no checksum exists for SSNs, so this rule has no mathematical validator behind it, unlike the two below)",
1159
+ "Remove it from the transcript file if it doesn't need to be there (residoo scan --seal quarantines the whole file without deleting anything, if you want a reversible first step)",
1160
+ "If it was genuinely exposed to someone who shouldn't have it, that's an identity-theft exposure, not an account compromise -- the affected person (you, a customer, an employee) may want to consider a credit freeze or fraud alert with the credit bureaus, not \"rotate a key\"",
1161
+ ],
1162
+ revokeNote: "Unlike every credential rule in this file, there is no revoke/rotate action available at all -- the number itself doesn't change.",
1163
+ },
1164
+ credit_card_number: {
1165
+ label: "Credit card number (Luhn-validated)",
1166
+ consolePath: "The card issuer's own fraud/support line, or your payment processor's dashboard if this is a customer's card, not your own.",
1167
+ steps: [
1168
+ "If it's your own card, most issuers let you freeze or reissue it from their app without waiting for a physical replacement",
1169
+ "If it's a customer's or someone else's card, tell them directly -- they need to contact their own issuer, you can't act on their behalf",
1170
+ "Remove it from the transcript file if it doesn't need to be there",
1171
+ ],
1172
+ revokeNote: "This passed a real Luhn checksum, so it is very unlikely to be a random-looking placeholder -- treat it as a real card number until shown otherwise.",
1173
+ },
1174
+ iban: {
1175
+ label: "IBAN (checksum-validated)",
1176
+ consolePath: "The account holder's own bank.",
1177
+ steps: [
1178
+ "An IBAN alone (without the account holder's separate authentication, e.g. a SEPA mandate) can't usually initiate a transfer by itself, but it's still a real bank account identifier -- treat exposure as a privacy issue even where it isn't immediately a fraud one",
1179
+ "If it's your own account and you're concerned, your bank can advise on IBAN-specific fraud monitoring",
1180
+ "Remove it from the transcript file if it doesn't need to be there",
1181
+ ],
1182
+ revokeNote: "This passed the real ISO 7064 MOD 97-10 checksum every valid IBAN must satisfy, so it is very unlikely to be a random-looking placeholder.",
1183
+ },
1184
+
1149
1185
  // ── NOISY_PATTERNS (only reachable via --include-noisy) ───────────────
1150
1186
  generic_password_assignment: {
1151
1187
  label: "Password assignment (noisy rule)",
package/src/scan.js CHANGED
@@ -4,6 +4,7 @@ const path = require("path");
4
4
  const { PATTERNS, NOISY_PATTERNS, redact } = require("./patterns");
5
5
  const { findDecodedMatches, findBoundaryMatches, contentProjection } = require("./decode");
6
6
  const { isTesseractAvailable, extractImageBlocks, ocrImageBase64 } = require("./ocr");
7
+ const { PII_PATTERNS } = require("./pii");
7
8
  const { findPairedSecret, findNearbyCandidate } = require("./pairing");
8
9
  const { looksRandom } = require("./rarity");
9
10
  const { decodeJwtExpiryMs } = require("./jwtExpiry");
@@ -272,7 +273,7 @@ function localTimestamp(d) {
272
273
  * absolute path can itself carry a username or a project name the rest of
273
274
  * this report is careful never to print.
274
275
  */
275
- async function scan({ sources, includeNoisy = false, includeSuppressed = false, onProgress = null, verify = false, verifyOnlyFingerprint = null, onBeforeVerify = null, noColor = false, ocr = false } = {}) {
276
+ async function scan({ sources, includeNoisy = false, includeSuppressed = false, onProgress = null, verify = false, verifyOnlyFingerprint = null, onBeforeVerify = null, noColor = false, ocr = false, includePii = false } = {}) {
276
277
  const rules = includeNoisy ? PATTERNS.concat(NOISY_PATTERNS) : PATTERNS;
277
278
  // --ocr: checked once, not per line/image -- isTesseractAvailable shells
278
279
  // out, and this scan can touch thousands of lines. ocrRequestedButMissing
@@ -600,6 +601,34 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
600
601
  }
601
602
  };
602
603
 
604
+ // --include-pii: a completely separate pass from every rule above, on
605
+ // purpose -- PII (see pii.js) is a different RISK CATEGORY from a
606
+ // credential, not just a lower-confidence version of one, so it gets its
607
+ // own opt-in flag and its own pass rather than being folded into `rules`.
608
+ // None of matchLine's AWS-pairing/verification machinery applies to PII
609
+ // at all, so this mirrors decodeLine/ocrLine's simpler shape, not
610
+ // matchLine's. `validate` (Luhn, IBAN's MOD 97-10) runs before a
611
+ // candidate is even considered for suppression -- an invalid checksum
612
+ // is not a "placeholder," it's simply not a match.
613
+ const piiLine = (line, file, relFile, lineNo, mtimeMs) => {
614
+ if (!includePii) return;
615
+ for (const rule of PII_PATTERNS) {
616
+ rule.re.lastIndex = 0;
617
+ let m;
618
+ while ((m = rule.re.exec(line)) !== null) {
619
+ if (rule.validate && !rule.validate(m[0])) continue;
620
+ const before = line.slice(Math.max(0, m.index - CONTEXT_WINDOW), m.index);
621
+ const suppressedReason = suppressionReason(m[0], before, rule.id);
622
+ if (suppressedReason && !includeSuppressed) {
623
+ suppressedCount++;
624
+ continue;
625
+ }
626
+ record(rule, m[0], relFile, file, lineNo, mtimeMs,
627
+ suppressedReason ? "low" : rule.confidence, suppressedReason, { pii: true });
628
+ }
629
+ }
630
+ };
631
+
603
632
  // Feature 2: split-line boundary join. A finding here means one credential
604
633
  // was split across this line and the next and is contiguous on neither. It
605
634
  // is recorded against BOTH contributing lines (each holds a fragment of the
@@ -717,6 +746,13 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
717
746
  flagFailed();
718
747
  }
719
748
  }
749
+ if (includePii) {
750
+ try {
751
+ piiLine(line, file, relFile, i + 1, mtimeMs);
752
+ } catch (err) {
753
+ flagFailed();
754
+ }
755
+ }
720
756
  try {
721
757
  const content = contentProjection(line);
722
758
  // Boundary join with the previous line (2-way splits only; see