@link-foundation/sensitive-data-sanitizer 0.0.0-stage → 0.12.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.
Files changed (78) hide show
  1. package/CHANGELOG.md +578 -0
  2. package/LICENSE +24 -0
  3. package/README.md +184 -2
  4. package/bin/legacy-example-package-name.js +95 -0
  5. package/bin/sensitive-data-sanitizer.js +430 -0
  6. package/bin/stream.js +56 -0
  7. package/docs/API.md +336 -0
  8. package/docs/COVERAGE.md +136 -0
  9. package/docs/HISTORY-REMEDIATION.md +41 -0
  10. package/examples/combined-scanners.mjs +23 -0
  11. package/examples/presidio-bridge.py +32 -0
  12. package/examples/realistic-fakes.mjs +18 -0
  13. package/examples/sanitize-session.mjs +27 -0
  14. package/examples/shared-tool-logs.mjs +21 -0
  15. package/examples/stream-jsonl.mjs +17 -0
  16. package/package.json +124 -4
  17. package/src/adapters.js +204 -0
  18. package/src/arithmetic.js +11 -0
  19. package/src/confidence.js +65 -0
  20. package/src/context.js +424 -0
  21. package/src/credentials.js +54 -0
  22. package/src/detection.js +293 -0
  23. package/src/encoded.js +233 -0
  24. package/src/engines.js +348 -0
  25. package/src/entities.js +166 -0
  26. package/src/escape-spans.js +20 -0
  27. package/src/eslint-plugin.d.ts +10 -0
  28. package/src/eslint-plugin.js +193 -0
  29. package/src/external.js +169 -0
  30. package/src/fake-names.js +222 -0
  31. package/src/fake.js +524 -0
  32. package/src/files.js +131 -0
  33. package/src/gitleaks.js +194 -0
  34. package/src/history-rewrite.js +339 -0
  35. package/src/history.js +103 -0
  36. package/src/identity.js +274 -0
  37. package/src/index.d.ts +321 -0
  38. package/src/index.js +40 -0
  39. package/src/jsonl-stream.js +198 -0
  40. package/src/keywords.js +45 -0
  41. package/src/known.js +164 -0
  42. package/src/legacy.d.ts +3 -0
  43. package/src/legacy.js +2 -0
  44. package/src/locales.js +217 -0
  45. package/src/log-bytes.js +85 -0
  46. package/src/names.js +65 -0
  47. package/src/national-checks.js +129 -0
  48. package/src/national.js +128 -0
  49. package/src/outbound.js +28 -0
  50. package/src/personal-context.js +85 -0
  51. package/src/personal.js +248 -0
  52. package/src/projection.js +126 -0
  53. package/src/propagation.js +163 -0
  54. package/src/public-verifier.js +77 -0
  55. package/src/public.js +153 -0
  56. package/src/record-worker.js +20 -0
  57. package/src/rules.js +210 -0
  58. package/src/sanitizer.js +794 -0
  59. package/src/specimens.js +34 -0
  60. package/src/stream-identity.js +129 -0
  61. package/src/stream-worker.js +18 -0
  62. package/src/stream.js +449 -0
  63. package/src/structured.js +278 -0
  64. package/src/transforms.js +192 -0
  65. package/src/transliteration.js +75 -0
  66. package/src/utf8.js +60 -0
  67. package/src/vendor/entities/aws.json +42 -0
  68. package/src/vendor/entities/azure.json +182 -0
  69. package/src/vendor/entities/google.json +219 -0
  70. package/src/vendor/gitleaks/LICENSE +21 -0
  71. package/src/vendor/gitleaks/provenance.json +8 -0
  72. package/src/vendor/gitleaks/rules-0.json +268 -0
  73. package/src/vendor/gitleaks/rules-1.json +1794 -0
  74. package/src/vendor/gitleaks/rules-2.json +292 -0
  75. package/src/vendor/gitleaks/rules-3.json +263 -0
  76. package/src/vendor/gitleaks/rules-4.json +277 -0
  77. package/src/vendor/gitleaks/rules-5.json +154 -0
  78. package/src/worker-options.js +27 -0
package/src/engines.js ADDED
@@ -0,0 +1,348 @@
1
+ import { actionable } from './confidence.js';
2
+ import {
3
+ auditFaked,
4
+ redactAgain,
5
+ inspect,
6
+ redactResultAsync,
7
+ validateOptions,
8
+ validateInput,
9
+ sanitizeFinding,
10
+ validatePublic,
11
+ inspectFindingsOnly,
12
+ reviewFindings,
13
+ markFake,
14
+ applyProfile,
15
+ } from './sanitizer.js';
16
+ import { failure, internalFailure } from './detection.js';
17
+ import { projectText } from './projection.js';
18
+ import { decodedRuns } from './encoded.js';
19
+ import {
20
+ structuredBatches,
21
+ mapStructuredFindings,
22
+ verifyStructured,
23
+ } from './structured.js';
24
+
25
+ let upstream;
26
+ const configurations = new WeakMap();
27
+ const streamPreparers = new WeakMap();
28
+ export const sanitizerConfiguration = (engine) => configurations.get(engine);
29
+ export const prepareStreamEngine = (engine, text, nativeFindings) =>
30
+ streamPreparers.get(engine)?.(text, nativeFindings);
31
+ async function secretlintDetect(text) {
32
+ upstream ??= Promise.all([
33
+ import('@secretlint/core'),
34
+ import('@secretlint/secretlint-rule-preset-recommend'),
35
+ import('@secretlint/profiler'),
36
+ ]);
37
+ const [core, preset, profiler] = await upstream;
38
+ profiler.secretLintProfiler.setEnabled(false);
39
+ // Log text is untrusted: embedded disable comments cannot suppress results.
40
+ const rules = preset.rules.filter(
41
+ (rule) => rule.meta.id !== '@secretlint/secretlint-rule-filter-comments'
42
+ );
43
+ const result = await core.lintSource({
44
+ source: {
45
+ filePath: '/virtual/input.txt',
46
+ content: text,
47
+ contentType: 'text',
48
+ },
49
+ options: {
50
+ noPhysicFilePath: true,
51
+ maskSecrets: true,
52
+ config: { rules: rules.map((rule) => ({ id: rule.meta.id, rule })) },
53
+ },
54
+ });
55
+ return result.messages.map((message) => ({
56
+ start: message.range?.[0],
57
+ end: message.range?.[1],
58
+ category: 'credential',
59
+ type: 'SECRET',
60
+ rule: 'secretlint',
61
+ }));
62
+ }
63
+
64
+ async function detect(engine, text, limit) {
65
+ try {
66
+ const found = await engine.detect(text);
67
+ if (!Array.isArray(found)) {
68
+ throw failure('ERR_ENGINE');
69
+ }
70
+ if (found.length > limit) {
71
+ throw failure('ERR_LIMIT');
72
+ }
73
+ return found.map((finding) => sanitizeFinding(finding, text.length));
74
+ } catch {
75
+ throw failure('ERR_ENGINE');
76
+ }
77
+ }
78
+
79
+ async function engineFindings(engine, projection, options, emit) {
80
+ const limit = options.maxFindings ?? 100000;
81
+ if (projection.changed) {
82
+ for (const f of await detect(engine, projection.text, limit)) {
83
+ emit({
84
+ ...f,
85
+ start: projection.starts[f.start],
86
+ end: projection.ends[f.end - 1],
87
+ });
88
+ }
89
+ }
90
+ if (options.decode === false) {
91
+ return;
92
+ }
93
+ for (const run of decodedRuns(projection.text)) {
94
+ if (run.limited) {
95
+ continue;
96
+ }
97
+ const view = projectText(run.text).text;
98
+ const decoded = markFake(
99
+ view,
100
+ await detect(engine, view, limit),
101
+ options
102
+ ).filter((f) => actionable(f, options));
103
+ if (!decoded.length) {
104
+ continue;
105
+ }
106
+ emit({
107
+ start: projection.changed ? projection.starts[run.start] : run.start,
108
+ end: projection.changed ? projection.ends[run.end - 1] : run.end,
109
+ type: 'ENCODED_SENSITIVE',
110
+ category: decoded.some((f) => f.category === 'credential')
111
+ ? 'credential'
112
+ : 'personal',
113
+ rule: 'encoded-engine',
114
+ });
115
+ }
116
+ }
117
+
118
+ async function analyzeStructured(
119
+ text,
120
+ initial,
121
+ options,
122
+ nativeFindings,
123
+ analyze
124
+ ) {
125
+ const findings = [];
126
+ for (const batch of structuredBatches(text, options)) {
127
+ for (const finding of mapStructuredFindings(
128
+ batch,
129
+ await analyze(
130
+ batch.text,
131
+ [],
132
+ true,
133
+ options,
134
+ nativeFindings === undefined ? undefined : []
135
+ ),
136
+ options
137
+ )) {
138
+ findings.push(finding);
139
+ }
140
+ }
141
+ // Native structured inspection validates, locates and reapplies the
142
+ // same fake-value/public policy to source offsets from all engines.
143
+ return inspectFindingsOnly(text, [...findings, ...(nativeFindings ?? [])], {
144
+ ...options,
145
+ findings: initial,
146
+ });
147
+ }
148
+
149
+ export function createSanitizer(options = {}) {
150
+ options = applyProfile(options);
151
+ validateOptions(options);
152
+ for (const detector of options.detectors ?? []) {
153
+ if (!detector || typeof detector.detect !== 'function') {
154
+ throw failure('ERR_CONFIG');
155
+ }
156
+ }
157
+ const engines =
158
+ options.secretlint === false ? [] : [{ detect: secretlintDetect }];
159
+ engines.push(...(options.detectors ?? []));
160
+ let prepared;
161
+ async function analyze(
162
+ text,
163
+ initial = options.findings ?? [],
164
+ plain = false,
165
+ scopedOptions = options,
166
+ nativeFindings
167
+ ) {
168
+ const options = scopedOptions;
169
+ validateInput(text, options);
170
+ if (options.structured && !plain) {
171
+ return analyzeStructured(text, initial, options, nativeFindings, analyze);
172
+ }
173
+ const findings = [];
174
+ const emit = (finding) => {
175
+ if (findings.length >= (options.maxFindings ?? 100000)) {
176
+ throw failure('ERR_LIMIT');
177
+ }
178
+ findings.push(sanitizeFinding(finding, text.length));
179
+ };
180
+ for (const finding of initial) {
181
+ emit(finding);
182
+ }
183
+ const projection = projectText(text);
184
+ for (const engine of engines) {
185
+ for (const finding of await detect(
186
+ engine,
187
+ text,
188
+ options.maxFindings ?? 100000
189
+ )) {
190
+ emit(finding);
191
+ }
192
+ await engineFindings(engine, projection, options, emit);
193
+ }
194
+ const detected =
195
+ nativeFindings === undefined
196
+ ? inspect(text, { ...options, structured: undefined, findings })
197
+ : reviewFindings(text, [...nativeFindings, ...findings], options);
198
+ return options.verifyPublic
199
+ ? verifyFindings(text, detected, options.verifyPublic)
200
+ : detected;
201
+ }
202
+
203
+ const sanitizer = {
204
+ inspect: async (...args) => {
205
+ try {
206
+ return await analyze(...args);
207
+ } catch (error) {
208
+ throw internalFailure(error);
209
+ }
210
+ },
211
+ sanitize: async (...args) => {
212
+ try {
213
+ return await sanitizeText(...args);
214
+ } catch (error) {
215
+ throw internalFailure(error);
216
+ }
217
+ },
218
+ sanitizeJsonl: (text) =>
219
+ createSanitizer({ ...options, structured: 'jsonl' }).sanitize(text),
220
+ };
221
+ configurations.set(sanitizer, options);
222
+ streamPreparers.set(sanitizer, async (text, nativeFindings) => {
223
+ const findings = await analyze(
224
+ text,
225
+ nativeFindings === undefined ? (options.findings ?? []) : [],
226
+ false,
227
+ options,
228
+ nativeFindings
229
+ );
230
+ prepared = { text, findings };
231
+ return findings;
232
+ });
233
+ return sanitizer;
234
+ async function sanitizeText(
235
+ text,
236
+ depth = 0,
237
+ fullRedaction,
238
+ plain,
239
+ scopedOptions = options,
240
+ round = 0
241
+ ) {
242
+ const options = scopedOptions;
243
+ const findings =
244
+ depth === 0 && prepared?.text === text
245
+ ? prepared.findings
246
+ : await analyze(text, options.findings ?? [], plain, options);
247
+ if (depth === 0) {
248
+ prepared = undefined;
249
+ }
250
+ const fullOptions = {
251
+ ...options,
252
+ transformation: undefined,
253
+ transformations: undefined,
254
+ identityMask: false,
255
+ preserveEncoding: false,
256
+ };
257
+ const result = await redactResultAsync(
258
+ text,
259
+ findings,
260
+ fullRedaction ? fullOptions : options,
261
+ (decoded, recursiveOptions) =>
262
+ depth < 2
263
+ ? sanitizeText(
264
+ decoded,
265
+ depth + 1,
266
+ fullRedaction,
267
+ true,
268
+ recursiveOptions
269
+ )
270
+ : Promise.resolve({ text: '[REDACTED]' })
271
+ );
272
+ const sanitized = fullRedaction
273
+ ? result.text
274
+ : (
275
+ await redactResultAsync(
276
+ text,
277
+ findings,
278
+ fullOptions,
279
+ (decoded, recursiveOptions) =>
280
+ depth < 2
281
+ ? sanitizeText(decoded, depth + 1, true, true, recursiveOptions)
282
+ : Promise.resolve({ text: '[REDACTED]' })
283
+ )
284
+ ).text;
285
+ // Verification at the publication boundary runs every enabled engine.
286
+ if (
287
+ (await analyze(sanitized, [], plain, options)).some((f) =>
288
+ actionable(f, options)
289
+ )
290
+ ) {
291
+ return redactAgain(result, findings, options, round, (next, nextRound) =>
292
+ sanitizeText(result.text, depth, fullRedaction, plain, next, nextRound)
293
+ );
294
+ }
295
+ if (options.structured && !plain) {
296
+ verifyStructured(result.text, options);
297
+ }
298
+ return { ...result, findings: auditFaked(findings, options, result) };
299
+ }
300
+ }
301
+
302
+ function eligiblePublic(text, finding, findings) {
303
+ return (
304
+ finding.category !== 'credential' &&
305
+ !['known-personal', 'confirmed-personal'].includes(finding.rule) &&
306
+ ['PERSON', 'ORGANIZATION', 'EMAIL'].includes(finding.type) &&
307
+ !findings.some(
308
+ (f) =>
309
+ (f.category === 'credential' ||
310
+ ['known-personal', 'confirmed-personal'].includes(f.rule)) &&
311
+ f.start < finding.end &&
312
+ f.end > finding.start
313
+ ) &&
314
+ !/(?:patient|customer|employee|my name|пациент|клиент)\s*[:=]?\s*$/iu.test(
315
+ text.slice(Math.max(0, finding.start - 64), finding.start)
316
+ )
317
+ );
318
+ }
319
+ async function verifyFindings(text, findings, verifier) {
320
+ const retained = [];
321
+ for (const finding of findings) {
322
+ if (!eligiblePublic(text, finding, findings)) {
323
+ retained.push(finding);
324
+ continue;
325
+ }
326
+ let verified;
327
+ try {
328
+ verified = await verifier({
329
+ type: finding.type,
330
+ value: text.slice(finding.start, finding.end),
331
+ });
332
+ } catch {
333
+ throw failure('ERR_PUBLIC_VERIFICATION');
334
+ }
335
+ if (!verified) {
336
+ retained.push(finding);
337
+ continue;
338
+ }
339
+ validatePublic(verified);
340
+ if (
341
+ verified.type !== finding.type ||
342
+ verified.value !== text.slice(finding.start, finding.end)
343
+ ) {
344
+ throw failure('ERR_PUBLIC_VERIFICATION');
345
+ }
346
+ }
347
+ return retained;
348
+ }
@@ -0,0 +1,166 @@
1
+ import google from './vendor/entities/google.json' with { type: 'json' };
2
+ import azure from './vendor/entities/azure.json' with { type: 'json' };
3
+ import aws from './vendor/entities/aws.json' with { type: 'json' };
4
+ import { escapePattern, collectMatches } from './detection.js';
5
+ import { numericNoise, epochNumber } from './confidence.js';
6
+ import { valueRange, personalValueAllowed } from './context.js';
7
+ export const entityCatalogs = { google, azure, aws };
8
+ const canonical = (name) =>
9
+ name.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase();
10
+ const entries = new Map();
11
+ for (const [vendor, catalog] of Object.entries(entityCatalogs)) {
12
+ for (const name of catalog.names) {
13
+ const normalized = canonical(name);
14
+ const type =
15
+ {
16
+ NAME: 'PERSON',
17
+ EMAIL_ADDRESS: 'EMAIL',
18
+ PHONE_NUMBER: 'PHONE',
19
+ PERSON_NAME: 'PERSON',
20
+ USER_NAME: 'USERNAME',
21
+ }[normalized] ?? normalized;
22
+ if (type.length > 64) {
23
+ continue;
24
+ }
25
+ const entry = {
26
+ type,
27
+ rule: `catalog/${vendor}`,
28
+ category:
29
+ /PASSWORD|SECRET|TOKEN|AUTH|ACCESS_KEY|PRIVATE_KEY|CREDENTIAL|CONNECTION.*STRING|^PIN$|CVV|CVC/.test(
30
+ type
31
+ )
32
+ ? 'credential'
33
+ : 'personal',
34
+ };
35
+ entries.set(normalized, entry);
36
+ entries.set(name.toUpperCase(), entry);
37
+ }
38
+ }
39
+ // All documented names are native labelled recognizers; automatic format
40
+ // recognizers are separate and do not claim the vendors' model behavior.
41
+ const prefix = new RegExp(
42
+ `(?<![\\p{L}\\p{N}_])(?:["'])?(${[...entries.keys()].map(escapePattern).join('|')})(?:["'])?[ \\t]*[:=:][ \\t]*`,
43
+ 'giu'
44
+ );
45
+ export function detectCatalog(text, emit) {
46
+ // Every catalog label requires one of these separators. Avoid running the
47
+ // large Unicode label alternation across ordinary unlabelled log records.
48
+ if (/[:=:]/.test(text)) {
49
+ detectCatalogLabels(text, emit);
50
+ }
51
+ detectFormats(text, emit);
52
+ }
53
+ function detectCatalogLabels(text, emit) {
54
+ for (const match of text.matchAll(prefix)) {
55
+ const entry = entries.get(match[1].toUpperCase());
56
+ if (entry.type === 'DATE') {
57
+ continue;
58
+ }
59
+ const mode = /[?&]$/.test(text.slice(0, match.index))
60
+ ? 'query'
61
+ : match[0].includes('=') || /["']\s*[::]/.test(match[0])
62
+ ? 'token'
63
+ : 'line';
64
+ const range = valueRange(
65
+ text,
66
+ match.index + match[0].length,
67
+ mode,
68
+ entry.category === 'personal' ? 512 : Infinity
69
+ );
70
+ if (
71
+ range &&
72
+ (entry.category === 'credential' ||
73
+ personalValueAllowed(text, range, entry.type, match.index)) &&
74
+ text.slice(range.start, range.end).trim() &&
75
+ !text.slice(range.start, range.end).startsWith('[REDACTED]')
76
+ ) {
77
+ emit({ ...range, ...entry, confidence: 0.95 });
78
+ }
79
+ }
80
+ }
81
+ function detectFormats(text, emit) {
82
+ for (const [type, pattern, accept] of formatRules) {
83
+ collectMatches(
84
+ text,
85
+ pattern,
86
+ (finding) => {
87
+ if (type === 'US_HEALTHCARE_NPI') {
88
+ const context = /\b(?:NPI|provider|healthcare|patient)\b/i.test(
89
+ text.slice(Math.max(0, finding.start - 64), finding.end + 32)
90
+ );
91
+ if (
92
+ !context &&
93
+ (numericNoise(text, finding.start, finding.end) ||
94
+ epochNumber(text.slice(finding.start, finding.end)))
95
+ ) {
96
+ return;
97
+ }
98
+ finding.confidence = context ? 0.95 : 0.35;
99
+ }
100
+ emit(finding);
101
+ },
102
+ { type, category: 'personal', rule: 'entity-format', confidence: 0.85 },
103
+ 0,
104
+ accept
105
+ );
106
+ }
107
+ }
108
+ function cpf(value) {
109
+ const d = value.replace(/\D/g, '');
110
+ if (/^(\d)\1+$/.test(d)) {
111
+ return false;
112
+ }
113
+ for (const n of [9, 10]) {
114
+ let sum = 0;
115
+ for (let i = 0; i < n; i++) {
116
+ sum += Number(d[i]) * (n + 1 - i);
117
+ }
118
+ if (((sum * 10) % 11) % 10 !== Number(d[n])) {
119
+ return false;
120
+ }
121
+ }
122
+ return true;
123
+ }
124
+ function npi(value) {
125
+ if (!/^[12]\d{9}$/.test(value)) {
126
+ return false;
127
+ }
128
+ const digits = `80840${value}`;
129
+ let sum = 0;
130
+ for (
131
+ let i = digits.length - 1, doubled = false;
132
+ i >= 0;
133
+ i--, doubled = !doubled
134
+ ) {
135
+ let d = Number(digits[i]);
136
+ if (doubled) {
137
+ d = d * 2 > 9 ? d * 2 - 9 : d * 2;
138
+ }
139
+ sum += d;
140
+ }
141
+ return sum % 10 === 0;
142
+ }
143
+ const formatRules = [
144
+ ['BRAZIL_CPF_NUMBER', /\b\d{3}\.\d{3}\.\d{3}-\d{2}\b/g, cpf],
145
+ [
146
+ 'INDIA_PAN_INDIVIDUAL',
147
+ /\b[A-Z]{3}[ABCFGHLJPT][A-Z]\d{4}[A-Z]\b/g,
148
+ () => true,
149
+ ],
150
+ [
151
+ 'UK_NATIONAL_INSURANCE_NUMBER',
152
+ /\b(?!BG|GB|NK|KN|TN|NT|ZZ)[A-CEGHJ-PR-TW-Z]{2}[ ]?\d{2}[ ]?\d{2}[ ]?\d{2}[ ]?[A-D]\b/g,
153
+ () => true,
154
+ ],
155
+ [
156
+ 'SPAIN_DNI_NUMBER',
157
+ /\b\d{8}[A-Z]\b/g,
158
+ (v) => 'TRWAGMYFPDXBNJZSQVHLCKE'[Number(v.slice(0, 8)) % 23] === v.at(-1),
159
+ ],
160
+ [
161
+ 'CRYPTO_WALLET',
162
+ /\b(?:0x[a-fA-F0-9]{40}|[13][a-km-zA-HJ-NP-Z1-9]{25,34}|bc1[ac-hj-np-z02-9]{25,62})\b/g,
163
+ () => true,
164
+ ],
165
+ ['US_HEALTHCARE_NPI', /\b[12]\d{9}\b/g, npi],
166
+ ];
@@ -0,0 +1,20 @@
1
+ // Never cut a JSON escape in half, including when plain text contains JSON.
2
+ // Each endpoint is visited once after sorting, even for overlapping findings.
3
+ export function alignEscapes(text, findings) {
4
+ const result = findings.map((finding) => ({ ...finding }));
5
+ for (const boundary of ['start', 'end']) {
6
+ const ordered = [...result].sort((a, b) => a[boundary] - b[boundary]);
7
+ let index = 0;
8
+ for (const match of text.matchAll(/\\(?:u[\da-fA-F]{4}|["\\/bfnrt])/g)) {
9
+ const start = match.index,
10
+ end = start + match[0].length;
11
+ while (index < ordered.length && ordered[index][boundary] <= start) {
12
+ index++;
13
+ }
14
+ while (index < ordered.length && ordered[index][boundary] < end) {
15
+ ordered[index++][boundary] = boundary === 'start' ? start : end;
16
+ }
17
+ }
18
+ }
19
+ return result;
20
+ }
@@ -0,0 +1,10 @@
1
+ declare const plugin: {
2
+ rules: Record<
3
+ string,
4
+ {
5
+ meta: object;
6
+ create(context: any): Record<string, (...args: any[]) => void>;
7
+ }
8
+ >;
9
+ };
10
+ export default plugin;