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 +1 -1
- package/src/cli.js +19 -3
- package/src/mcpTools.js +22 -8
- package/src/pii.js +96 -0
- package/src/report.js +9 -6
- package/src/rotation.js +36 -0
- package/src/scan.js +37 -1
- 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.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
|
|
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
|
-
/**
|
|
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/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
|
|
538
|
-
// findings). `encoding` names how the value was wrapped
|
|
539
|
-
// "base64url"); `spanLines` names the adjacent line
|
|
540
|
-
// was reconstructed across; `ocr` means the
|
|
541
|
-
// text at all -- it was read out of a
|
|
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) });
|