claude-memory-lint 0.1.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/CHANGELOG.md +40 -0
- package/LICENSE +21 -0
- package/README.md +340 -0
- package/bin/memory-lint.js +136 -0
- package/lib/collector.js +205 -0
- package/lib/config.js +73 -0
- package/lib/detectors/budget.js +127 -0
- package/lib/detectors/frontmatter.js +96 -0
- package/lib/detectors/honesty.js +351 -0
- package/lib/detectors/perishable.js +356 -0
- package/lib/detectors/pii.js +511 -0
- package/lib/detectors/provenance.js +668 -0
- package/lib/index.js +13 -0
- package/lib/locate-archive.js +44 -0
- package/lib/mask-code.js +87 -0
- package/lib/pii-categories.json +403 -0
- package/memory-lint.config.json +19 -0
- package/package.json +31 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// PII detector: personal data and secrets that should never sit in a
|
|
3
|
+
// long-lived agent memory file, checked against the ONE list this package
|
|
4
|
+
// ships — `lib/pii-categories.json`.
|
|
5
|
+
//
|
|
6
|
+
// -- THE LIST LIVES IN ONE PLACE ---------------------------------------------
|
|
7
|
+
//
|
|
8
|
+
// This file declares no category. No PII pattern, no carve-out, no clinical
|
|
9
|
+
// word appears here — all of it comes from `pii-categories.json`, compiled by
|
|
10
|
+
// `compileCategories`. A category in two places becomes two categories that
|
|
11
|
+
// drift, and the one that drifts is always the one the gate reads. If a
|
|
12
|
+
// pattern is wrong, the fix is in the JSON, and the test breaks with it.
|
|
13
|
+
//
|
|
14
|
+
// -- THE THREE `gate` VALUES --------------------------------------------------
|
|
15
|
+
//
|
|
16
|
+
// block -> an occurrence exits 1 (nationalId, bankAccount, health, credential)
|
|
17
|
+
// warn -> an occurrence is counted and reported, never exits 1 (minor,
|
|
18
|
+
// specialCategoryOther)
|
|
19
|
+
// doctrine -> not detectable by form; `patterns: []`, never enters code
|
|
20
|
+
// (falseAnonymity). The detector REFUSES a category that
|
|
21
|
+
// declares `doctrine` and still carries a pattern — that would
|
|
22
|
+
// be doctrine becoming a gate through the back door.
|
|
23
|
+
//
|
|
24
|
+
// -- MENTION IS NOT USE -------------------------------------------------------
|
|
25
|
+
//
|
|
26
|
+
// The category list's `globalExemptions` (backtick span, fenced block) are
|
|
27
|
+
// NOT reimplemented here: they are exactly `maskCode` (lib/mask-code.js),
|
|
28
|
+
// shared with the other detectors in this package. A note that teaches this
|
|
29
|
+
// list needs to be able to show the phrase it catches without tripping the
|
|
30
|
+
// gate on itself.
|
|
31
|
+
//
|
|
32
|
+
// -- DECLARED LOW RECALL ------------------------------------------------------
|
|
33
|
+
//
|
|
34
|
+
// `health` ships with `precision: closed-vocabulary` and a declared
|
|
35
|
+
// `coverage: declared-low` right in the category record. It is the pattern
|
|
36
|
+
// most likely to miss a real case: prose describing a condition in the
|
|
37
|
+
// person's own words will not match a closed vocabulary, and tightening the
|
|
38
|
+
// regex is not the fix — declaring the gap is. The test suite reflects this:
|
|
39
|
+
// it proves what the list catches, and separately plants a case it does NOT
|
|
40
|
+
// catch, instead of implying coverage that doesn't exist.
|
|
41
|
+
//
|
|
42
|
+
// -- WHAT THIS DETECTOR DOES NOT DO -------------------------------------------
|
|
43
|
+
//
|
|
44
|
+
// It does not fix or delete anything. A hit in someone's own memory archive
|
|
45
|
+
// is a FINDING TO REPORT, not something this tool edits on its own — changing
|
|
46
|
+
// someone else's recorded material is a decision for the person who owns it.
|
|
47
|
+
//
|
|
48
|
+
// Exit: 0 nothing blocking | 1 a `block`-gate category matched | 2 the
|
|
49
|
+
// category list is unreadable (not checked — a detector with no list would
|
|
50
|
+
// otherwise go green over an archive it never actually read)
|
|
51
|
+
|
|
52
|
+
const fs = require('fs');
|
|
53
|
+
const path = require('path');
|
|
54
|
+
const { readCollection, INDEX_NAME } = require('../collector');
|
|
55
|
+
const { locateArchive } = require('../locate-archive');
|
|
56
|
+
const { maskCode } = require('../mask-code');
|
|
57
|
+
|
|
58
|
+
const DEFAULT_CATEGORIES_FILE = path.join(__dirname, '..', 'pii-categories.json');
|
|
59
|
+
const GATES = new Set(['block', 'warn', 'doctrine']);
|
|
60
|
+
|
|
61
|
+
// Checksum validators a pattern can opt into via `"checksum": "<name>"` in
|
|
62
|
+
// the JSON. This is generic math, not PII data — the LIST of which pattern
|
|
63
|
+
// uses which checksum still lives entirely in pii-categories.json, per the
|
|
64
|
+
// single-source-of-truth rule at the top of this file.
|
|
65
|
+
const CHECKSUMS = {
|
|
66
|
+
// Standard Luhn (mod-10) check, used by every major card scheme. A random
|
|
67
|
+
// 13-19 digit platform ID (an ad account, a page ID) passes this by
|
|
68
|
+
// coincidence roughly 1 time in 10 — far better than the 10-in-10 false
|
|
69
|
+
// positive rate of "any run of 13-19 digits".
|
|
70
|
+
luhn(digits) {
|
|
71
|
+
let sum = 0;
|
|
72
|
+
let alt = false;
|
|
73
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
74
|
+
let d = digits.charCodeAt(i) - 48;
|
|
75
|
+
if (d < 0 || d > 9) return false;
|
|
76
|
+
if (alt) {
|
|
77
|
+
d *= 2;
|
|
78
|
+
if (d > 9) d -= 9;
|
|
79
|
+
}
|
|
80
|
+
sum += d;
|
|
81
|
+
alt = !alt;
|
|
82
|
+
}
|
|
83
|
+
return digits.length > 0 && sum % 10 === 0;
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// --- pure: the list -> compiled categories ----------------------------------
|
|
88
|
+
// Takes the ALREADY-PARSED object (disk reading and the CLI are the only
|
|
89
|
+
// callers that touch fs). Throws with the reason if the list is broken — a
|
|
90
|
+
// broken list must not silently become a green gate.
|
|
91
|
+
function compileCategories(doc) {
|
|
92
|
+
if (!doc || !Array.isArray(doc.categories) || doc.categories.length === 0) {
|
|
93
|
+
throw new Error('category list has no non-empty `categories` array');
|
|
94
|
+
}
|
|
95
|
+
const compilePatterns = (list, catId) =>
|
|
96
|
+
(Array.isArray(list) ? list : []).map((p) => {
|
|
97
|
+
try {
|
|
98
|
+
if (p.checksum && !CHECKSUMS[p.checksum]) {
|
|
99
|
+
throw new Error(`unknown checksum "${p.checksum}"`);
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
id: p.id,
|
|
103
|
+
description: p.description || null,
|
|
104
|
+
re: new RegExp(p.regex, 'i'),
|
|
105
|
+
checksum: p.checksum || null,
|
|
106
|
+
};
|
|
107
|
+
} catch (e) {
|
|
108
|
+
throw new Error(`category ${catId}: pattern "${p.id}" does not compile — ${e.message}`);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
const compileExemptions = (list, catId) =>
|
|
112
|
+
(Array.isArray(list) ? list : []).map((e) => {
|
|
113
|
+
try {
|
|
114
|
+
return {
|
|
115
|
+
id: e.id,
|
|
116
|
+
description: e.description || null,
|
|
117
|
+
re: new RegExp(e.regex, 'i'),
|
|
118
|
+
// `appliesTo`: pattern ids this exemption is allowed to neutralize.
|
|
119
|
+
// Absent/empty means "every pattern in this category" (the old,
|
|
120
|
+
// coarser default) — set it to scope a carve-out meant for one
|
|
121
|
+
// pattern (e.g. "last four digits" only makes sense for a card
|
|
122
|
+
// number, never for a full IBAN in the same category).
|
|
123
|
+
appliesTo: Array.isArray(e.appliesTo) ? e.appliesTo : null,
|
|
124
|
+
// `nullifiedBy`: pattern ids that, if any of them ALSO matches in
|
|
125
|
+
// the same clause as the match this exemption would otherwise
|
|
126
|
+
// excuse, void the exemption. This is how an exemption whose
|
|
127
|
+
// own wording names the category ("special category", "what not
|
|
128
|
+
// to store") stops laundering a real disclosure sitting in the
|
|
129
|
+
// same clause — including when the disclosure IS the very pattern
|
|
130
|
+
// the exemption would apply to.
|
|
131
|
+
nullifiedBy: Array.isArray(e.nullifiedBy) ? e.nullifiedBy : null,
|
|
132
|
+
};
|
|
133
|
+
} catch (err) {
|
|
134
|
+
throw new Error(`category ${catId}: exemption "${e.id}" does not compile — ${err.message}`);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
const globalExemptions = compileExemptions(doc.globalExemptions, '(global)');
|
|
138
|
+
const categories = doc.categories.map((c) => {
|
|
139
|
+
if (!c.id || !c.name) throw new Error(`category with no id/name: ${JSON.stringify(c).slice(0, 80)}`);
|
|
140
|
+
if (!GATES.has(c.gate)) throw new Error(`category ${c.id}: gate "${c.gate}" is not one of block|warn|doctrine`);
|
|
141
|
+
const patterns = Array.isArray(c.patterns) ? c.patterns : [];
|
|
142
|
+
if (c.gate === 'doctrine' && patterns.length) {
|
|
143
|
+
throw new Error(`category ${c.id}: gate "doctrine" carries ${patterns.length} pattern(s) — doctrine does not become a gate by form`);
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
id: c.id,
|
|
147
|
+
name: c.name,
|
|
148
|
+
gate: c.gate,
|
|
149
|
+
precision: c.precision || null,
|
|
150
|
+
coverage: c.coverage || null,
|
|
151
|
+
// Why THIS category sits at THIS gate, so the report can say it
|
|
152
|
+
// instead of leaving a reader to guess why `minor` is `warn` and
|
|
153
|
+
// `health` is `block` when both are Art. 9 categories.
|
|
154
|
+
gateRationale: c.gateRationale || null,
|
|
155
|
+
carveOut: c.carveOut || null,
|
|
156
|
+
example: c.example || null,
|
|
157
|
+
// Whether pattern matching runs against the line AFTER `maskCode`
|
|
158
|
+
// blanks fenced/backtick spans (true, the old universal behavior), or
|
|
159
|
+
// against the raw line (false). "Mention is not use" is correct
|
|
160
|
+
// doctrine for prose categories a note might cite as an example of
|
|
161
|
+
// itself (health, minor, specialCategoryOther) — it is WRONG for a
|
|
162
|
+
// category whose match IS the leak: a credential or a national ID
|
|
163
|
+
// pasted inside a fenced log block is still a leaked credential or ID.
|
|
164
|
+
// Default true preserves prior behavior for every category that
|
|
165
|
+
// doesn't say otherwise.
|
|
166
|
+
maskCodeSpans: c.maskCodeSpans !== false,
|
|
167
|
+
patterns: compilePatterns(patterns, c.id),
|
|
168
|
+
exemptions: compileExemptions(c.exemptions, c.id),
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
return { categories, globalExemptions };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Splits a line into clauses on `, ; : . ! ?` — the "same neighborhood"
|
|
175
|
+
// the exemption check is fixed down to. A carve-out phrase and the pattern
|
|
176
|
+
// it's supposed to excuse have to share a clause, not just a line: "Call
|
|
177
|
+
// them back: national ID 120385-2399" is one LINE but two clauses, and the
|
|
178
|
+
// phone-number carve-out in the first clause has no business excusing a
|
|
179
|
+
// national ID number that appears after the colon.
|
|
180
|
+
function splitClauses(line) {
|
|
181
|
+
const bounds = [0];
|
|
182
|
+
const seps = /[,;:.!?]/g;
|
|
183
|
+
let m;
|
|
184
|
+
while ((m = seps.exec(line))) bounds.push(m.index + 1);
|
|
185
|
+
bounds.push(line.length + 1);
|
|
186
|
+
const clauses = [];
|
|
187
|
+
for (let i = 0; i < bounds.length - 1; i++) clauses.push({ start: bounds[i], end: bounds[i + 1] - 1 });
|
|
188
|
+
return clauses;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function clauseIndexAt(clauses, pos) {
|
|
192
|
+
for (let i = 0; i < clauses.length; i++) {
|
|
193
|
+
if (pos >= clauses[i].start && pos < clauses[i].end) return i;
|
|
194
|
+
}
|
|
195
|
+
return clauses.length - 1;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Does this exemption excuse THIS pattern match, in THIS line? Both halves of
|
|
199
|
+
// that scoping live here: `appliesTo` scopes WHICH pattern a carve-out can
|
|
200
|
+
// touch, and the clause check scopes WHERE in the line it has to appear.
|
|
201
|
+
function exemptionCovers(exemption, patternId, line, clauses, matchClause) {
|
|
202
|
+
if (exemption.appliesTo && !exemption.appliesTo.includes(patternId)) return false;
|
|
203
|
+
const g = new RegExp(exemption.re.source, exemption.re.flags.includes('g') ? exemption.re.flags : `${exemption.re.flags}g`);
|
|
204
|
+
let m;
|
|
205
|
+
while ((m = g.exec(line))) {
|
|
206
|
+
if (clauseIndexAt(clauses, m.index) === matchClause) return true;
|
|
207
|
+
if (m.index === g.lastIndex) g.lastIndex += 1;
|
|
208
|
+
}
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// --- pure: judge ONE line ----------------------------------------------------
|
|
213
|
+
// `masked` is the line after `maskCode` (a backtick mention or fenced block
|
|
214
|
+
// already turned to spaces); `raw` is the line as written. Which one a
|
|
215
|
+
// category matches against is `c.maskCodeSpans` (see compileCategories) — a
|
|
216
|
+
// credential or national ID pasted inside a fenced block is still a leak,
|
|
217
|
+
// so those categories match against `raw`, not `masked`.
|
|
218
|
+
//
|
|
219
|
+
// A category's exemption no longer blankets the whole line for every
|
|
220
|
+
// pattern: it has to (a) apply to the specific pattern that matched
|
|
221
|
+
// (`appliesTo`) and (b) share a clause with that match (`exemptionCovers`).
|
|
222
|
+
// "card ending in 6467" still passes — carve-out and match are one clause,
|
|
223
|
+
// one pattern.
|
|
224
|
+
function judgeLine(masked, raw, categories) {
|
|
225
|
+
const found = [];
|
|
226
|
+
for (const c of categories) {
|
|
227
|
+
if (!c.patterns.length) continue;
|
|
228
|
+
const line = c.maskCodeSpans ? masked : raw;
|
|
229
|
+
const clauses = splitClauses(line);
|
|
230
|
+
|
|
231
|
+
// First pass: where does each pattern first match, if at all? Needed
|
|
232
|
+
// twice over: to report the occurrence itself, and so a
|
|
233
|
+
// `nullifiedBy` exemption check can ask "did pattern X ALSO match in
|
|
234
|
+
// this clause" without re-running every pattern's regex per exemption.
|
|
235
|
+
const matchClauseByPattern = {};
|
|
236
|
+
for (const p of c.patterns) {
|
|
237
|
+
const idx = line.search(p.re);
|
|
238
|
+
if (idx === -1) continue;
|
|
239
|
+
if (p.checksum) {
|
|
240
|
+
const m = line.match(p.re);
|
|
241
|
+
const digits = m ? m[0].replace(/\D/g, '') : '';
|
|
242
|
+
if (!CHECKSUMS[p.checksum](digits)) continue;
|
|
243
|
+
}
|
|
244
|
+
matchClauseByPattern[p.id] = clauseIndexAt(clauses, idx);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
for (const p of c.patterns) {
|
|
248
|
+
if (!(p.id in matchClauseByPattern)) continue;
|
|
249
|
+
const matchClause = matchClauseByPattern[p.id];
|
|
250
|
+
const exempt = c.exemptions.find((e) => {
|
|
251
|
+
if (!exemptionCovers(e, p.id, line, clauses, matchClause)) return false;
|
|
252
|
+
// An exemption whose text names the category ("special
|
|
253
|
+
// category", "what not to store") is voided when a listed pattern
|
|
254
|
+
// ALSO matches in this same clause — including p itself, so a
|
|
255
|
+
// clinical word can't excuse its own disclosure.
|
|
256
|
+
if (e.nullifiedBy && e.nullifiedBy.some((otherId) => matchClauseByPattern[otherId] === matchClause)) {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
return true;
|
|
260
|
+
});
|
|
261
|
+
found.push({
|
|
262
|
+
category: c.id,
|
|
263
|
+
name: c.name,
|
|
264
|
+
gate: c.gate,
|
|
265
|
+
pattern: p.id,
|
|
266
|
+
exempt: Boolean(exempt),
|
|
267
|
+
exemption: exempt ? exempt.id : null,
|
|
268
|
+
excerpt: String(raw).trim().slice(0, 160),
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// An exempt hit stays exempt: it doesn't count as an occurrence, only shows
|
|
273
|
+
// up in the control report — proof that the carve-out test measures a real
|
|
274
|
+
// exemption, not just absence of detection.
|
|
275
|
+
return found;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// --- pure: judge ONE text (a file, or one section of it) --------------------
|
|
279
|
+
function judgeText(piece, categories) {
|
|
280
|
+
const text = String(piece.text || '');
|
|
281
|
+
const rawLines = text.split('\n');
|
|
282
|
+
const maskedLines = maskCode(text).split('\n');
|
|
283
|
+
const occurrences = [];
|
|
284
|
+
const exempted = [];
|
|
285
|
+
for (let i = 0; i < rawLines.length; i++) {
|
|
286
|
+
for (const f of judgeLine(maskedLines[i], rawLines[i], categories)) {
|
|
287
|
+
const rec = { ...f, line: i + 1 };
|
|
288
|
+
if (f.exempt) exempted.push(rec);
|
|
289
|
+
else occurrences.push(rec);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return { name: piece.name, section: piece.section || 'file', occurrences, exempted };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function auditPii(pieces, categories) {
|
|
296
|
+
return pieces.map((p) => judgeText(p, categories));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// --- pure: summary -----------------------------------------------------------
|
|
300
|
+
function summarizePii(verdicts, categories) {
|
|
301
|
+
const byCategory = {};
|
|
302
|
+
for (const c of categories) {
|
|
303
|
+
byCategory[c.id] = {
|
|
304
|
+
gate: c.gate,
|
|
305
|
+
precision: c.precision,
|
|
306
|
+
gateRationale: c.gateRationale || null,
|
|
307
|
+
// `doctrine` never runs a pattern — see the CLI reporting below,
|
|
308
|
+
// which prints this instead of a count that would read as "checked,
|
|
309
|
+
// found nothing."
|
|
310
|
+
machineCheckable: c.gate !== 'doctrine',
|
|
311
|
+
occurrences: 0,
|
|
312
|
+
exempted: 0,
|
|
313
|
+
files: [],
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
let blocking = 0;
|
|
317
|
+
let warning = 0;
|
|
318
|
+
const filesWithHits = new Set();
|
|
319
|
+
for (const v of verdicts) {
|
|
320
|
+
for (const o of v.occurrences) {
|
|
321
|
+
const target = byCategory[o.category] ||
|
|
322
|
+
(byCategory[o.category] = {
|
|
323
|
+
gate: o.gate,
|
|
324
|
+
precision: null,
|
|
325
|
+
gateRationale: null,
|
|
326
|
+
machineCheckable: o.gate !== 'doctrine',
|
|
327
|
+
occurrences: 0,
|
|
328
|
+
exempted: 0,
|
|
329
|
+
files: [],
|
|
330
|
+
});
|
|
331
|
+
target.occurrences += 1;
|
|
332
|
+
if (!target.files.includes(v.name)) target.files.push(v.name);
|
|
333
|
+
if (o.gate === 'block') blocking += 1;
|
|
334
|
+
else if (o.gate === 'warn') warning += 1;
|
|
335
|
+
filesWithHits.add(v.name);
|
|
336
|
+
}
|
|
337
|
+
for (const o of v.exempted) {
|
|
338
|
+
const target = byCategory[o.category];
|
|
339
|
+
if (target) target.exempted += 1;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
pieces: verdicts.length,
|
|
344
|
+
filesWithHits: filesWithHits.size,
|
|
345
|
+
occurrences: blocking + warning,
|
|
346
|
+
blocking,
|
|
347
|
+
warning,
|
|
348
|
+
byCategory,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// --- I/O: only from here down does this module touch disk -------------------
|
|
353
|
+
|
|
354
|
+
// Turns a collected archive into judgeable pieces: frontmatter and body enter
|
|
355
|
+
// as SEPARATE pieces, so a report's line number is the section's own.
|
|
356
|
+
//
|
|
357
|
+
// The index file (`MEMORY.md`) is out of scope for FILE COUNTS (`total`
|
|
358
|
+
// below stays the note count, matching every other detector's `filesRead`)
|
|
359
|
+
// but it is judged here like any other piece: one index LINE (title +
|
|
360
|
+
// suffix note, e.g. "- [c](c.md) - the client has cancer") is exactly where
|
|
361
|
+
// a person free-typing a one-line summary can paste the same personal data
|
|
362
|
+
// they were careful to keep out of the note body. The index is also the
|
|
363
|
+
// single file every boot reads in full, so a false negative here is the
|
|
364
|
+
// worst possible place for one. Nothing about the line is stripped before
|
|
365
|
+
// judging it — including the `(file.md)` link target — because that target
|
|
366
|
+
// is a local filename, not free prose, and stripping it would be a second
|
|
367
|
+
// unstated carve-out; if a real one is ever needed it belongs declared in
|
|
368
|
+
// `_meta.coverageScope`, not silently trimmed here.
|
|
369
|
+
function collectPieces(dir) {
|
|
370
|
+
const pieces = [];
|
|
371
|
+
const exists = fs.existsSync(dir);
|
|
372
|
+
let total = 0;
|
|
373
|
+
if (exists) {
|
|
374
|
+
const r = readCollection(dir);
|
|
375
|
+
total = r.files.length;
|
|
376
|
+
for (const f of r.files) {
|
|
377
|
+
if (f.frontmatter && f.frontmatter.raw) {
|
|
378
|
+
pieces.push({ name: f.name, section: 'frontmatter', text: f.frontmatter.raw });
|
|
379
|
+
}
|
|
380
|
+
pieces.push({ name: f.name, section: 'body', text: f.body });
|
|
381
|
+
}
|
|
382
|
+
for (const entry of r.indexEntries) {
|
|
383
|
+
pieces.push({ name: INDEX_NAME, section: 'index', text: entry.raw });
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return { pieces, exists, total };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
module.exports = {
|
|
390
|
+
DEFAULT_CATEGORIES_FILE,
|
|
391
|
+
GATES,
|
|
392
|
+
compileCategories,
|
|
393
|
+
judgeLine,
|
|
394
|
+
judgeText,
|
|
395
|
+
auditPii,
|
|
396
|
+
summarizePii,
|
|
397
|
+
collectPieces,
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
// --- CLI ---------------------------------------------------------------
|
|
401
|
+
// Usage: node lib/detectors/pii.js [--json]
|
|
402
|
+
// Exit: 0 nothing in a `block` category | 1 a `block` category matched |
|
|
403
|
+
// 2 archive not found, or the category list is unreadable (not checked).
|
|
404
|
+
if (require.main === module) {
|
|
405
|
+
const { loadConfig } = require('../config');
|
|
406
|
+
const json = process.argv.includes('--json');
|
|
407
|
+
const { config } = loadConfig();
|
|
408
|
+
const listPath =
|
|
409
|
+
process.env.MEMORY_LINT_PII_CATEGORIES || config.pii.categoriesFile || DEFAULT_CATEGORIES_FILE;
|
|
410
|
+
|
|
411
|
+
const notChecked = (state, msg, extra = {}) => {
|
|
412
|
+
if (json) console.log(JSON.stringify({ state, categoriesFile: listPath, summary: null, ...extra }, null, 2));
|
|
413
|
+
else console.log(msg);
|
|
414
|
+
console.error(msg);
|
|
415
|
+
process.exit(2);
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
let categories = null;
|
|
419
|
+
let listVersion = null;
|
|
420
|
+
let coverageScope = null;
|
|
421
|
+
try {
|
|
422
|
+
const doc = JSON.parse(fs.readFileSync(listPath, 'utf8'));
|
|
423
|
+
const compiled = compileCategories(doc);
|
|
424
|
+
categories = compiled.categories;
|
|
425
|
+
listVersion = doc.version || null;
|
|
426
|
+
// The classes this list does NOT scan have to be stated, not just
|
|
427
|
+
// absent. `languageScope` already declares the language limit; this is
|
|
428
|
+
// the same declaration for CLASS of data — printed on every run so
|
|
429
|
+
// "PII: nothing in a blocking category" never reads as "archive is
|
|
430
|
+
// clean of personal data" when whole categories were never checked.
|
|
431
|
+
coverageScope = (doc._meta && Array.isArray(doc._meta.coverageScope)) ? doc._meta.coverageScope : null;
|
|
432
|
+
} catch (e) {
|
|
433
|
+
notChecked(
|
|
434
|
+
'unreadable_list',
|
|
435
|
+
`PII: NOT CHECKED - category list ${listPath} is unreadable or invalid: ${e.message}. ` +
|
|
436
|
+
'There is nothing to detect without the list, and a detector with no list would go green over an archive it never read.'
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const { dir, source } = locateArchive();
|
|
441
|
+
const { pieces, exists, total } = collectPieces(dir);
|
|
442
|
+
|
|
443
|
+
if (!exists) {
|
|
444
|
+
notChecked(
|
|
445
|
+
'archive_absent',
|
|
446
|
+
`PII: NOT CHECKED - ${dir} not found (path from ${source}). Absence is not a clean scan: run this where the archive exists.`
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const verdicts = auditPii(pieces, categories);
|
|
451
|
+
const summary = summarizePii(verdicts, categories);
|
|
452
|
+
const hits = verdicts.filter((v) => v.occurrences.length);
|
|
453
|
+
// `ok` used to cover an archive with a `warn`-gate occurrence
|
|
454
|
+
// sitting in it (specialCategoryOther, minor, directIdentifier, ...) —
|
|
455
|
+
// exit code 0 either way, since only `block` trips exit 1, but the STATE
|
|
456
|
+
// STRING must not say "ok" over something a human still has to look at.
|
|
457
|
+
const state = summary.blocking > 0 ? 'blocked' : summary.warning > 0 ? 'ok_with_warnings' : 'ok';
|
|
458
|
+
|
|
459
|
+
if (json) {
|
|
460
|
+
console.log(
|
|
461
|
+
JSON.stringify(
|
|
462
|
+
{
|
|
463
|
+
state,
|
|
464
|
+
categoriesFile: listPath,
|
|
465
|
+
listVersion,
|
|
466
|
+
coverageScope,
|
|
467
|
+
dir,
|
|
468
|
+
source,
|
|
469
|
+
filesRead: total,
|
|
470
|
+
summary,
|
|
471
|
+
findings: hits.map((v) => ({ name: v.name, section: v.section, occurrences: v.occurrences })),
|
|
472
|
+
},
|
|
473
|
+
null,
|
|
474
|
+
2
|
|
475
|
+
)
|
|
476
|
+
);
|
|
477
|
+
} else {
|
|
478
|
+
console.log(`archive: ${dir} (${source}, ${total} file(s))`);
|
|
479
|
+
console.log(`categories: ${listPath}`);
|
|
480
|
+
console.log(
|
|
481
|
+
`pieces judged: ${summary.pieces} occurrences: ${summary.occurrences} (block ${summary.blocking} / warn ${summary.warning})`
|
|
482
|
+
);
|
|
483
|
+
for (const [id, d] of Object.entries(summary.byCategory)) {
|
|
484
|
+
// `doctrine` (falseAnonymity) never runs a pattern — saying
|
|
485
|
+
// "0 occurrence(s)" reads as "checked, found nothing" for the one
|
|
486
|
+
// category this tool structurally cannot check.
|
|
487
|
+
if (!d.machineCheckable) {
|
|
488
|
+
console.log(` ${id} [${d.gate}]: not machine-checkable — needs a human pass`);
|
|
489
|
+
} else {
|
|
490
|
+
console.log(` ${id} [${d.gate}]: ${d.occurrences} occurrence(s), ${d.exempted} exempt(ed) by carve-out`);
|
|
491
|
+
}
|
|
492
|
+
if (d.gateRationale) console.log(` gate: ${d.gateRationale}`);
|
|
493
|
+
}
|
|
494
|
+
if (coverageScope && coverageScope.length) {
|
|
495
|
+
console.log(`\nNOT scanned by this list (declared limit, not a clean result for these): ${coverageScope.join('; ')}`);
|
|
496
|
+
}
|
|
497
|
+
if (hits.length) {
|
|
498
|
+
console.log('');
|
|
499
|
+
for (const v of hits) {
|
|
500
|
+
for (const o of v.occurrences) {
|
|
501
|
+
console.log(` ${v.name} (${v.section}):${o.line} [${o.category}/${o.pattern}]`);
|
|
502
|
+
console.log(` ... ${o.excerpt} ...`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
console.log('\nThis is a FINDING to report, not something this tool edits on its own.');
|
|
506
|
+
} else {
|
|
507
|
+
console.log('\nPII: nothing in a blocking category.');
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
process.exit(summary.blocking > 0 ? 1 : 0);
|
|
511
|
+
}
|