residoo 0.13.0 → 0.15.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.13.0",
3
+ "version": "0.15.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:
@@ -167,7 +175,8 @@ Watch:
167
175
  --verify same opt-in vendor check as scan --verify,
168
176
  applied to each newly found credential once,
169
177
  never to one already seen
170
- --include-noisy, --include-suppressed, --no-color same meaning as scan
178
+ --include-noisy, --include-suppressed, --include-pii, --no-color
179
+ same meaning as scan
171
180
  Ctrl+C stops cleanly and prints a session summary (skipped with --json,
172
181
  where the same information is one final NDJSON event).
173
182
 
@@ -655,6 +664,7 @@ async function runWatch(args) {
655
664
  const includeSuppressed = args.includes("--include-suppressed");
656
665
  const verify = args.includes("--verify");
657
666
  const noColor = args.includes("--no-color");
667
+ const includePii = args.includes("--include-pii");
658
668
 
659
669
  let intervalSeconds = 5;
660
670
  const intervalArg = argValue(args, "--interval");
@@ -680,7 +690,7 @@ async function runWatch(args) {
680
690
 
681
691
  const { promise, stop } = startWatch({
682
692
  sources,
683
- options: { includeNoisy, includeSuppressed, verify, noColor, json: wantsJson, pollMs: intervalSeconds * 1000 },
693
+ options: { includeNoisy, includeSuppressed, verify, noColor, includePii, json: wantsJson, pollMs: intervalSeconds * 1000 },
684
694
  });
685
695
 
686
696
  const printFinalSummary = (stats) => {
@@ -918,6 +928,12 @@ async function main(argv) {
918
928
  // exact confirmed image shape this looks for and its honest accuracy
919
929
  // limitations.
920
930
  const wantsOcr = args.includes("--ocr");
931
+ // --include-pii: a different RISK CATEGORY, not a lower confidence bar
932
+ // (see pii.js) -- residoo is deliberately credentials-only by default;
933
+ // this opts into three checksum-validated categories (SSN, Luhn-valid
934
+ // card numbers, IBAN) rather than the shape-only, much noisier
935
+ // categories (bare email, phone) some competitors also ship.
936
+ const wantsPii = args.includes("--include-pii");
921
937
 
922
938
  // --project [dir]: the dir is optional (CI passes ".", a bare --project
923
939
  // means the current directory). null means machine mode.
@@ -1029,7 +1045,7 @@ async function main(argv) {
1029
1045
 
1030
1046
  const progress = makeProgressReporter(noColor);
1031
1047
  const result = await scan({
1032
- sources, includeNoisy, includeSuppressed, verify, noColor, ocr: wantsOcr,
1048
+ sources, includeNoisy, includeSuppressed, verify, noColor, ocr: wantsOcr, includePii: wantsPii,
1033
1049
  onProgress: progress.onProgress,
1034
1050
  // Clears the spinner's last frame before --verify's own stderr lines
1035
1051
  // print; without this the last spinner line sits uncleared on screen
package/src/mcpTools.js CHANGED
@@ -50,11 +50,20 @@ function rejectUnknownKeys(args, allowed) {
50
50
  return errs;
51
51
  }
52
52
 
53
- /** Shared arg shape for residoo_scan/residoo_check: includeNoisy, includeSuppressed, maxEntries. */
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 { errs, includeNoisy: args.includeNoisy === true, includeSuppressed: args.includeSuppressed === true, maxEntries };
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/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
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) });