@link-foundation/sensitive-data-sanitizer 0.19.3 → 0.20.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 (46) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +8 -1
  3. package/bin/sensitive-data-sanitizer.js +49 -28
  4. package/docs/API.md +192 -1
  5. package/docs/COVERAGE.md +3 -3
  6. package/docs/RULES.md +217 -0
  7. package/package.json +1 -1
  8. package/src/allow-list.js +93 -0
  9. package/src/confidence.js +24 -9
  10. package/src/context.js +121 -25
  11. package/src/credentials.js +44 -0
  12. package/src/crypto-addresses.js +74 -0
  13. package/src/detection.js +21 -6
  14. package/src/document-person-cues.js +1 -1
  15. package/src/encrypt.js +95 -0
  16. package/src/entities.js +4 -3
  17. package/src/fake.js +29 -1
  18. package/src/files.js +57 -12
  19. package/src/gitleaks.js +82 -27
  20. package/src/identifier-detector.js +36 -0
  21. package/src/identity.js +14 -0
  22. package/src/index.d.ts +116 -1
  23. package/src/index.js +7 -0
  24. package/src/national-checks.js +209 -0
  25. package/src/national.js +310 -92
  26. package/src/pattern-detector.js +219 -0
  27. package/src/personal-context.js +3 -2
  28. package/src/personal-evidence.js +233 -0
  29. package/src/personal.js +42 -10
  30. package/src/projection.js +85 -26
  31. package/src/rule-catalog.js +424 -0
  32. package/src/sanitizer.js +48 -3
  33. package/src/sarif.js +127 -0
  34. package/src/token-checksum.js +49 -0
  35. package/src/transforms.js +108 -25
  36. package/src/vendor/betterleaks/LICENSE +21 -0
  37. package/src/vendor/betterleaks/provenance.json +11 -0
  38. package/src/vendor/betterleaks/rules-0.json +463 -0
  39. package/src/vendor/betterleaks/rules-1.json +454 -0
  40. package/src/vendor/betterleaks/rules-2.json +461 -0
  41. package/src/vendor/betterleaks/rules-3.json +455 -0
  42. package/src/vendor/betterleaks/rules-4.json +483 -0
  43. package/src/vendor/betterleaks/rules-5.json +35 -0
  44. package/src/vendor/entities/aws.json +1 -1
  45. package/src/vendor/entities/azure.json +6 -2
  46. package/src/vendor/entities/google.json +2 -1
@@ -0,0 +1,74 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ // Offline checksum validation for Bitcoin addresses (Presidio parity): legacy
4
+ // Base58Check (P2PKH/P2SH) and SegWit Bech32/Bech32m. Ethereum addresses carry
5
+ // no case-insensitive checksum and stay format-only.
6
+ const base58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
7
+ const sha256 = (bytes) => createHash('sha256').update(bytes).digest();
8
+
9
+ export function base58Check(value) {
10
+ let number = 0n;
11
+ for (const char of value) {
12
+ const digit = base58.indexOf(char);
13
+ if (digit < 0) {
14
+ return false;
15
+ }
16
+ number = number * 58n + BigInt(digit);
17
+ }
18
+ const hex = number.toString(16);
19
+ const body = Buffer.from(hex.length % 2 ? `0${hex}` : hex, 'hex');
20
+ const zeros = /^1*/.exec(value)[0].length;
21
+ const bytes = Buffer.concat([Buffer.alloc(zeros), body]);
22
+ if (bytes.length !== 25) {
23
+ return false;
24
+ }
25
+ const check = sha256(sha256(bytes.subarray(0, 21))).subarray(0, 4);
26
+ return check.equals(bytes.subarray(21));
27
+ }
28
+
29
+ const bech32Charset = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
30
+ function polymod(values) {
31
+ const generator = [
32
+ 0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3,
33
+ ];
34
+ let checksum = 1;
35
+ for (const value of values) {
36
+ const top = checksum >>> 25;
37
+ checksum = ((checksum & 0x1ffffff) << 5) ^ value;
38
+ for (let i = 0; i < 5; i++) {
39
+ if ((top >>> i) & 1) {
40
+ checksum ^= generator[i];
41
+ }
42
+ }
43
+ }
44
+ return checksum >>> 0;
45
+ }
46
+ export function bech32Address(value) {
47
+ if (value !== value.toLowerCase() && value !== value.toUpperCase()) {
48
+ return false;
49
+ }
50
+ const lower = value.toLowerCase();
51
+ const separator = lower.lastIndexOf('1');
52
+ const prefix = lower.slice(0, separator);
53
+ const data = [...lower.slice(separator + 1)].map((char) =>
54
+ bech32Charset.indexOf(char)
55
+ );
56
+ if (!['bc', 'tb'].includes(prefix) || data.length < 7 || data.includes(-1)) {
57
+ return false;
58
+ }
59
+ const expanded = [
60
+ ...[...prefix].map((char) => char.charCodeAt(0) >> 5),
61
+ 0,
62
+ ...[...prefix].map((char) => char.charCodeAt(0) & 31),
63
+ ...data,
64
+ ];
65
+ const constant = polymod(expanded);
66
+ return constant === 1 || constant === 0x2bc830a3;
67
+ }
68
+
69
+ export function cryptoAddress(value) {
70
+ if (/^0x[a-fA-F0-9]{40}$/.test(value)) {
71
+ return true;
72
+ }
73
+ return /^(?:bc|tb)1/i.test(value) ? bech32Address(value) : base58Check(value);
74
+ }
package/src/detection.js CHANGED
@@ -1,4 +1,9 @@
1
- import { cardNetwork, cardEvidence } from './confidence.js';
1
+ import {
2
+ cardNetwork,
3
+ cardEvidence,
4
+ cardContext,
5
+ uatpCard,
6
+ } from './confidence.js';
2
7
  import { isIP } from 'node:net';
3
8
  import { serviceRules } from './rules.js';
4
9
  import { detectContext, valueRange, authorizationRange } from './context.js';
@@ -8,6 +13,7 @@ import { detectCatalog } from './entities.js';
8
13
  import { detectIdentity } from './identity.js';
9
14
  import { detectNational } from './national.js';
10
15
  import { detectPersonalContext } from './personal-context.js';
16
+ import { detectPersonalEvidence } from './personal-evidence.js';
11
17
  import {
12
18
  detectCredentialFormats,
13
19
  credentialPlaceholder,
@@ -18,6 +24,7 @@ import { detectDocumentEntities } from './document-entities.js';
18
24
  import { detectProseAndDocuments } from './proper-names.js';
19
25
  import { builtInEnabled } from './policy.js';
20
26
  import { detectStrict } from './strict.js';
27
+ import { tokenChecksum } from './token-checksum.js';
21
28
 
22
29
  export const REDACTED = '[REDACTED]';
23
30
  export const failure = (code = 'ERR_SANITIZATION', location = {}) =>
@@ -176,8 +183,14 @@ export function nativeDetect(text, options, emit) {
176
183
  ? (details) => options.debug({ event: 'gitleaks-rule', ...details })
177
184
  : undefined
178
185
  );
186
+ const checked = (finding) =>
187
+ emit(
188
+ tokenChecksum(text.slice(finding.start, finding.end))
189
+ ? { ...finding, confidence: 0.99 }
190
+ : finding
191
+ );
179
192
  for (const [rule, pattern] of serviceRules) {
180
- collectMatches(text, pattern, emit, { ...credential, rule });
193
+ collectMatches(text, pattern, checked, { ...credential, rule });
181
194
  }
182
195
  detectContext(text, emit, options.credentialsOnly);
183
196
  if (!options.credentialsOnly) {
@@ -187,6 +200,7 @@ export function nativeDetect(text, options, emit) {
187
200
  detectIdentity(text, emit);
188
201
  detectNational(text, emit);
189
202
  detectPersonalContext(text, emit);
203
+ detectPersonalEvidence(text, emit);
190
204
  detectStrict(text, emit, options);
191
205
  }
192
206
  detectCredentialFormats(text, emit);
@@ -311,20 +325,21 @@ function detectPersonalFormats(text, options, emit) {
311
325
  0,
312
326
  (value, match) =>
313
327
  luhn(value) &&
314
- cardNetwork(value) &&
328
+ (cardNetwork(value) ||
329
+ (uatpCard(value) && cardContext(text, match.index, value))) &&
315
330
  cardEvidence(text, match.index, value)
316
331
  );
317
332
  collectMatches(
318
333
  text,
319
- /\b\d{3}-\d{2}-\d{4}\b/g,
334
+ /(?<![\w.-])\d{3}([-.])\d{2}\1\d{4}(?![\w-]|\.\d)/g,
320
335
  emit,
321
336
  { type: 'ID', category: 'personal', rule: 'us-ssn' },
322
337
  0,
323
- (v) => !/^(?:000|666|9\d\d)-|^\d{3}-00-|0000$/.test(v)
338
+ (v) => !/^(?:000|666|9\d\d)\D|^\d{3}\D00\D|0000$/.test(v)
324
339
  );
325
340
  collectMatches(
326
341
  text,
327
- /(?<![\w.])(?:\d{1,3}\.){3}\d{1,3}(?![\w.])/g,
342
+ /(?<![\w.])(?:\d{1,3}\.){3}\d{1,3}(?!\w|\.\d)/g,
328
343
  emit,
329
344
  { type: 'IP_ADDRESS', category: 'personal', rule: 'ipv4' },
330
345
  0,
@@ -8,7 +8,7 @@ const name = "[A-Z][a-z]{1,47}(?:['’-][A-Z]?[a-z]{1,47})?";
8
8
  // unit in logs and lowercase `dr` is usually a word or path fragment. A
9
9
  // capitalized `Ms` right after a number is still a unit (`took 30 Ms Retry`).
10
10
  const cue =
11
- '(?:[Cc]andidate|[Rr]ecruiter|[Aa]pplicant|[Dd]ear|[Hh]i|Mr\\.?|(?<!\\d[ \\t]*)Ms\\.?|Dr\\.?|[Mm]essage[ \\t]+from)';
11
+ '(?:[Cc]andidate|[Rr]ecruiter|[Aa]pplicant|[Dd]ear|[Hh]i|Mr\\.?|Ms(?<!\\d[ \\t]*Ms)\\.?|Dr\\.?|[Mm]essage[ \\t]+from)';
12
12
  const pattern = new RegExp(
13
13
  `(?<!${boundary})${cue}[ \\t]+(${name})(?!${boundary})`,
14
14
  'gu'
package/src/encrypt.js ADDED
@@ -0,0 +1,95 @@
1
+ import {
2
+ createCipheriv,
3
+ createDecipheriv,
4
+ createHmac,
5
+ hkdfSync,
6
+ } from 'node:crypto';
7
+ import { failure } from './detection.js';
8
+
9
+ // Deterministic authenticated encryption (synthetic IV), comparable to
10
+ // Google's CryptoDeterministicConfig (AES-SIV) and Presidio's encrypt
11
+ // operator. Equal values of one type produce equal tokens, so the output
12
+ // still supports joins and counts, and decrypt() recovers the value. The
13
+ // derived IV is an HMAC of the type and value, so it never repeats for
14
+ // different plaintexts under one key.
15
+ const ivBytes = 12,
16
+ tagBytes = 16;
17
+ const token = /\[ENCRYPTED:([A-Z][A-Z0-9_]{0,63}):([A-Za-z0-9_-]{38,10000})\]/g;
18
+ const keys = new Map();
19
+
20
+ function derived(key) {
21
+ if (typeof key !== 'string' || Buffer.byteLength(key) < 16) {
22
+ throw failure('ERR_CONFIG');
23
+ }
24
+ let entry = keys.get(key);
25
+ if (!entry) {
26
+ const material = Buffer.from(
27
+ hkdfSync(
28
+ 'sha256',
29
+ key,
30
+ 'sensitive-data-sanitizer',
31
+ 'deterministic-encryption-v1',
32
+ 64
33
+ )
34
+ );
35
+ entry = { cipher: material.subarray(0, 32), mac: material.subarray(32) };
36
+ if (keys.size >= 16) {
37
+ keys.clear();
38
+ }
39
+ keys.set(key, entry);
40
+ }
41
+ return entry;
42
+ }
43
+
44
+ export function encryptValue(value, type, key) {
45
+ const { cipher, mac } = derived(key);
46
+ const iv = createHmac('sha256', mac)
47
+ .update(`${type}\0${value}`)
48
+ .digest()
49
+ .subarray(0, ivBytes);
50
+ const engine = createCipheriv('aes-256-gcm', cipher, iv);
51
+ engine.setAAD(Buffer.from(type));
52
+ const body = Buffer.concat([engine.update(value, 'utf8'), engine.final()]);
53
+ const payload = Buffer.concat([iv, body, engine.getAuthTag()]);
54
+ return `[ENCRYPTED:${type}:${payload.toString('base64url')}]`;
55
+ }
56
+
57
+ function decryptToken(type, encoded, key) {
58
+ const { cipher } = derived(key);
59
+ const payload = Buffer.from(encoded, 'base64url');
60
+ if (payload.length < ivBytes + tagBytes) {
61
+ throw failure('ERR_MAP');
62
+ }
63
+ try {
64
+ const engine = createDecipheriv(
65
+ 'aes-256-gcm',
66
+ cipher,
67
+ payload.subarray(0, ivBytes)
68
+ );
69
+ engine.setAAD(Buffer.from(type));
70
+ engine.setAuthTag(payload.subarray(-tagBytes));
71
+ return Buffer.concat([
72
+ engine.update(payload.subarray(ivBytes, -tagBytes)),
73
+ engine.final(),
74
+ ]).toString('utf8');
75
+ } catch {
76
+ throw failure('ERR_MAP');
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Restores `[ENCRYPTED:TYPE:…]` tokens produced by `{mode: 'encrypt', key}`.
82
+ * A token that fails authentication under `key` throws ERR_MAP; nothing is
83
+ * partially restored. With `json: true` restored values are JSON-escaped for
84
+ * tokens inside JSON string literals.
85
+ */
86
+ export function decrypt(text, key, { json = false } = {}) {
87
+ if (typeof text !== 'string') {
88
+ throw failure('ERR_INPUT');
89
+ }
90
+ derived(key);
91
+ return text.replace(token, (_, type, encoded) => {
92
+ const value = decryptToken(type, encoded, key);
93
+ return json ? JSON.stringify(value).slice(1, -1) : value;
94
+ });
95
+ }
package/src/entities.js CHANGED
@@ -3,6 +3,7 @@ import azure from './vendor/entities/azure.json' with { type: 'json' };
3
3
  import aws from './vendor/entities/aws.json' with { type: 'json' };
4
4
  import { escapePattern, collectMatches } from './detection.js';
5
5
  import { numericNoise, epochNumber } from './confidence.js';
6
+ import { cryptoAddress } from './crypto-addresses.js';
6
7
  import { valueRange, personalValueAllowed } from './context.js';
7
8
  export const entityCatalogs = { google, azure, aws };
8
9
  const canonical = (name) =>
@@ -161,13 +162,13 @@ const formatRules = [
161
162
  ],
162
163
  [
163
164
  'SPAIN_DNI_NUMBER',
164
- /\b\d{8}[A-Z]\b/g,
165
+ /\b\d{8}-?[A-Z]\b/g,
165
166
  (v) => 'TRWAGMYFPDXBNJZSQVHLCKE'[Number(v.slice(0, 8)) % 23] === v.at(-1),
166
167
  ],
167
168
  [
168
169
  'CRYPTO_WALLET',
169
- /\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,
170
- () => true,
170
+ /\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}|BC1[AC-HJ-NP-Z02-9]{25,62})\b/g,
171
+ cryptoAddress,
171
172
  ],
172
173
  ['US_HEALTHCARE_NPI', /\b[12]\d{9}\b/g, npi],
173
174
  ];
package/src/fake.js CHANGED
@@ -116,6 +116,18 @@ const validators = {
116
116
  SPAIN_DNI_NUMBER: (v) =>
117
117
  'TRWAGMYFPDXBNJZSQVHLCKE'[Number(v.slice(0, 8)) % 23] === v.at(-1),
118
118
  BRAZIL_CPF_NUMBER: cpf,
119
+ ABA_ROUTING_NUMBER: checks.abaRouting,
120
+ ES_NIE: checks.spanishNie,
121
+ IT_VAT_CODE: checks.shortLuhn,
122
+ AU_ACN: checks.acn,
123
+ IN_AADHAAR: checks.aadhaar,
124
+ TH_TNIN: checks.thaiId,
125
+ MEDICAL_LICENSE: checks.dea,
126
+ IMEI: checks.shortLuhn,
127
+ DE_TAX_ID: checks.germanTaxId,
128
+ AE_EMIRATES_ID: checks.emiratesId,
129
+ SA_NATIONAL_ID: checks.saudiId,
130
+ IL_ID_NUMBER: checks.israeliId,
119
131
  };
120
132
  function cpf(d) {
121
133
  for (const n of [9, 10]) {
@@ -226,9 +238,24 @@ function idShape(compact, candidate, type) {
226
238
  candidate =
227
239
  candidate.slice(0, 10) + compact.slice(10, 12) + candidate.slice(12);
228
240
  }
229
- if (type === 'SG_NRIC_FIN') {
241
+ if (
242
+ [
243
+ 'SG_NRIC_FIN',
244
+ 'ES_NIE',
245
+ 'IN_AADHAAR',
246
+ 'TH_TNIN',
247
+ 'DE_TAX_ID',
248
+ 'SA_NATIONAL_ID',
249
+ ].includes(type)
250
+ ) {
230
251
  candidate = compact[0] + candidate.slice(1);
231
252
  }
253
+ if (['ABA_ROUTING_NUMBER', 'MEDICAL_LICENSE'].includes(type)) {
254
+ candidate = compact.slice(0, 2) + candidate.slice(2);
255
+ }
256
+ if (type === 'AE_EMIRATES_ID') {
257
+ candidate = compact.slice(0, 7) + candidate.slice(7);
258
+ }
232
259
  if (type === 'CREDIT_CARD') {
233
260
  candidate = compact.slice(0, 6) + candidate.slice(6);
234
261
  }
@@ -255,6 +282,7 @@ function idChecks(compact, candidate, type) {
255
282
  'SG_NRIC_FIN',
256
283
  'IT_FISCAL_CODE',
257
284
  'SPAIN_DNI_NUMBER',
285
+ 'ES_NIE',
258
286
  ].includes(type);
259
287
  const count =
260
288
  (type === 'RU_INN' && compact.length === 12) ||
package/src/files.js CHANGED
@@ -4,31 +4,76 @@ import { dirname, join, resolve } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { TextDecoder } from 'node:util';
6
6
  import { failure } from './detection.js';
7
- import { decodeLogBytes } from './log-bytes.js';
7
+ import { decodeLogBytes, encodeLogText } from './log-bytes.js';
8
8
 
9
- export function decodeText(
9
+ // UTF-16 text (Windows logs, PowerShell transcripts) is recognised only by its
10
+ // byte order mark; BOM-less UTF-16 remains binary input.
11
+ export function textEncoding(bytes) {
12
+ if (bytes.length >= 2 && bytes.length % 2 === 0) {
13
+ if (bytes[0] === 0xff && bytes[1] === 0xfe) {
14
+ return 'utf-16le';
15
+ }
16
+ if (bytes[0] === 0xfe && bytes[1] === 0xff) {
17
+ return 'utf-16be';
18
+ }
19
+ }
20
+ return 'utf-8';
21
+ }
22
+
23
+ function decodeWith(encoding, bytes) {
24
+ try {
25
+ return new TextDecoder(encoding, { fatal: true, ignoreBOM: true }).decode(
26
+ bytes
27
+ );
28
+ } catch {
29
+ throw failure('ERR_ENCODING');
30
+ }
31
+ }
32
+
33
+ // Returns the decoded text and the encoding needed to write it back.
34
+ export function decodeTextInput(
10
35
  bytes,
11
36
  { lossless = false, allowBinary = false } = {}
12
37
  ) {
13
38
  if (typeof allowBinary !== 'boolean') {
14
39
  throw failure('ERR_CONFIG');
15
40
  }
41
+ const encoding = textEncoding(bytes);
42
+ if (encoding !== 'utf-8') {
43
+ return { text: decodeWith(encoding, bytes), encoding };
44
+ }
16
45
  if (!allowBinary && bytes.includes(0)) {
17
46
  throw failure('ERR_BINARY');
18
47
  }
19
- if (lossless) {
20
- return decodeLogBytes(bytes);
21
- }
22
- try {
23
- return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(
24
- bytes
25
- );
26
- } catch {
27
- throw failure('ERR_ENCODING');
48
+ return {
49
+ text: lossless ? decodeLogBytes(bytes) : decodeWith('utf-8', bytes),
50
+ encoding,
51
+ };
52
+ }
53
+
54
+ export function decodeText(bytes, options) {
55
+ return decodeTextInput(bytes, options).text;
56
+ }
57
+
58
+ // Encode output in the input encoding; UTF-16 keeps its byte order mark
59
+ // because the decoded text retains it.
60
+ export function encodeTextOutput(text, encoding = 'utf-8') {
61
+ if (encoding === 'utf-8') {
62
+ return encodeLogText(text);
28
63
  }
64
+ const bytes = Buffer.from(text, 'utf16le');
65
+ return encoding === 'utf-16be' ? bytes.swap16() : bytes;
29
66
  }
30
67
 
31
68
  export async function readTextFile(path, maxBytes = 10 * 1024 * 1024, options) {
69
+ return (await readTextInput(path, maxBytes, options)).text;
70
+ }
71
+
72
+ export async function readTextInput(
73
+ path,
74
+ maxBytes = 10 * 1024 * 1024,
75
+ options
76
+ ) {
32
77
  let handle;
33
78
  try {
34
79
  const info = await lstat(path);
@@ -52,7 +97,7 @@ export async function readTextFile(path, maxBytes = 10 * 1024 * 1024, options) {
52
97
  if (read > maxBytes || read > opened.size) {
53
98
  throw failure('ERR_LIMIT');
54
99
  }
55
- return decodeText(bytes.subarray(0, read), options);
100
+ return decodeTextInput(bytes.subarray(0, read), options);
56
101
  } catch (error) {
57
102
  if (error.code?.startsWith('ERR_')) {
58
103
  throw error;
package/src/gitleaks.js CHANGED
@@ -7,22 +7,46 @@ import r2 from './vendor/gitleaks/rules-2.json' with { type: 'json' };
7
7
  import r3 from './vendor/gitleaks/rules-3.json' with { type: 'json' };
8
8
  import r4 from './vendor/gitleaks/rules-4.json' with { type: 'json' };
9
9
  import r5 from './vendor/gitleaks/rules-5.json' with { type: 'json' };
10
+ import b0 from './vendor/betterleaks/rules-0.json' with { type: 'json' };
11
+ import b1 from './vendor/betterleaks/rules-1.json' with { type: 'json' };
12
+ import b2 from './vendor/betterleaks/rules-2.json' with { type: 'json' };
13
+ import b3 from './vendor/betterleaks/rules-3.json' with { type: 'json' };
14
+ import b4 from './vendor/betterleaks/rules-4.json' with { type: 'json' };
15
+ import b5 from './vendor/betterleaks/rules-5.json' with { type: 'json' };
10
16
  import { entropy, REDACTED } from './detection.js';
11
17
  import { keywordMatcher } from './keywords.js';
18
+ import { tokenChecksum } from './token-checksum.js';
12
19
 
13
20
  export const gitleaksRules = [...r0, ...r1, ...r2, ...r3, ...r4, ...r5];
21
+ // Betterleaks rules whose ids are absent from the frozen Gitleaks set; see
22
+ // scripts/sync-betterleaks.py for the translated filter subset.
23
+ export const betterleaksRules = [...b0, ...b1, ...b2, ...b3, ...b4, ...b5];
24
+ const upstreamRules = [
25
+ ...gitleaksRules.map((rule) => ({ rule, name: `gitleaks/${rule.id}` })),
26
+ ...betterleaksRules.map((rule) => ({
27
+ rule,
28
+ name: `betterleaks/${rule.id}`,
29
+ })),
30
+ ];
14
31
  const compiled = new Map();
15
32
  const linearPatterns = new Map();
16
33
  const keywordIndex = keywordMatcher(
17
- gitleaksRules.flatMap((r) =>
18
- (r.keywords ?? []).map((word) => word.toLowerCase())
34
+ upstreamRules.flatMap(({ rule }) =>
35
+ (rule.keywords ?? []).map((word) => word.toLowerCase())
19
36
  )
20
37
  );
38
+ const confidence = { high: 0.95, medium: 0.85, low: 0.75 };
39
+ // re2-wasm has a fixed 16 MiB heap shared by every compiled rule and every
40
+ // subject copy, and an allocation failure aborts the module for the rest of
41
+ // the process. All bundled rules compiled take about 7 MiB, and each exec
42
+ // copies its subject, so keep subjects well below the remaining space and
43
+ // scan larger texts with RE2JS (experiments/issue-128/re2-big-input.mjs).
44
+ export const WASM_SUBJECT_LIMIT = 1024 * 1024;
21
45
 
22
46
  export function detectGitleaks(text, emit, trace) {
23
47
  const keywords = keywordIndex(text.toLowerCase());
24
- const large = Buffer.byteLength(text) > 4 * 1024 * 1024;
25
- for (const rule of gitleaksRules) {
48
+ const large = Buffer.byteLength(text) > WASM_SUBJECT_LIMIT;
49
+ for (const { rule, name } of upstreamRules) {
26
50
  if (
27
51
  !rule.regex ||
28
52
  (rule.keywords?.length &&
@@ -32,10 +56,10 @@ export function detectGitleaks(text, emit, trace) {
32
56
  }
33
57
  const started = trace ? performance.now() : 0;
34
58
  for (const view of ruleViews(text, rule)) {
35
- scanRule(view.text, view.start, rule, emit, large);
59
+ scanRule(view.text, view.start, { rule, name }, emit, large);
36
60
  }
37
61
  trace?.({
38
- rule: rule.id,
62
+ rule: name.startsWith('gitleaks/') ? rule.id : name,
39
63
  length: text.length,
40
64
  ms: performance.now() - started,
41
65
  });
@@ -118,8 +142,8 @@ function* boundedViews(text, prefix, id) {
118
142
  }
119
143
  }
120
144
 
121
- function scanRule(text, base, rule, emit, large) {
122
- for (const match of ruleMatches(text, rule, large)) {
145
+ function scanRule(text, base, { rule, name }, emit, large) {
146
+ for (const match of ruleMatches(text, name, rule, large)) {
123
147
  const secret = capturedSecret(rule, match);
124
148
  if (!secret) {
125
149
  continue;
@@ -133,17 +157,25 @@ function scanRule(text, base, rule, emit, large) {
133
157
  end: base + match.index + last + secret.length,
134
158
  type: 'SECRET',
135
159
  category: 'credential',
136
- rule: `gitleaks/${rule.id}`,
137
- confidence: rule.entropy ? 0.85 : 0.95,
160
+ rule: name,
161
+ confidence: ruleConfidence(rule, secret),
138
162
  });
139
163
  }
140
164
  }
141
165
 
142
- function* ruleMatches(text, rule, large) {
166
+ // A token whose embedded CRC32 checksum matches is structurally valid offline.
167
+ function ruleConfidence(rule, secret) {
168
+ if (tokenChecksum(secret)) {
169
+ return 0.99;
170
+ }
171
+ return confidence[rule.confidence] ?? (rule.entropy ? 0.85 : 0.95);
172
+ }
173
+
174
+ function* ruleMatches(text, name, rule, large) {
143
175
  if (large) {
144
- // re2-wasm has a fixed 16 MiB heap. RE2JS implements the same linear-time
145
- // syntax without copying the input into WASM. Its indices are UTF-16.
146
- yield* linearMatches(text, rule);
176
+ // RE2JS implements the same linear-time syntax without copying the input
177
+ // into WASM. Its indices are UTF-16.
178
+ yield* linearMatches(text, name, rule.regex);
147
179
  return;
148
180
  }
149
181
  // re2-wasm 1.0.2 returns codepoint indices despite its RegExp-like API.
@@ -157,11 +189,7 @@ function* ruleMatches(text, rule, large) {
157
189
  offsets.push(offset);
158
190
  }
159
191
  }
160
- let pattern = compiled.get(rule.id);
161
- if (!pattern) {
162
- pattern = new RE2(rule.regex, 'gu');
163
- compiled.set(rule.id, pattern);
164
- }
192
+ const pattern = compiledRule(name, rule.regex);
165
193
  pattern.lastIndex = 0;
166
194
  for (let attempts = 0; attempts < 8; attempts++) {
167
195
  const match = pattern.exec(text);
@@ -177,16 +205,32 @@ function* ruleMatches(text, rule, large) {
177
205
  // Keep the original subject: slicing would change anchors and boundaries.
178
206
  const resume = offsets ? offsets[pattern.lastIndex] : pattern.lastIndex;
179
207
  if (resume !== undefined && resume <= text.length) {
180
- yield* linearMatches(text, rule, resume);
208
+ yield* linearMatches(text, name, rule.regex, resume);
209
+ }
210
+ }
211
+
212
+ // One cached re2-wasm object per rule name. re2-wasm objects are never freed,
213
+ // so every caller, tests included, shares this cache.
214
+ export function compiledRule(name, source) {
215
+ let pattern = compiled.get(name);
216
+ if (!pattern) {
217
+ pattern = new RE2(source, 'gu');
218
+ compiled.set(name, pattern);
181
219
  }
220
+ return pattern;
182
221
  }
183
222
 
184
- function* linearMatches(text, rule, resume = 0) {
185
- let linear = linearPatterns.get(rule.id);
223
+ function linearPattern(key, source) {
224
+ let linear = linearPatterns.get(key);
186
225
  if (!linear) {
187
- linear = RE2JS.compile(rule.regex);
188
- linearPatterns.set(rule.id, linear);
226
+ linear = RE2JS.compile(source);
227
+ linearPatterns.set(key, linear);
189
228
  }
229
+ return linear;
230
+ }
231
+
232
+ function* linearMatches(text, name, source, resume = 0) {
233
+ const linear = linearPattern(name, source);
190
234
  if (linear.test(text)) {
191
235
  for (const match of linear.matchAll(text)) {
192
236
  if (match.index >= resume) {
@@ -200,9 +244,20 @@ function capturedSecret(rule, match) {
200
244
  const secret = rule.secretGroup
201
245
  ? match[rule.secretGroup]
202
246
  : (match.slice(1).find(Boolean) ?? match[0]);
203
- return !secret ||
204
- secret === REDACTED ||
205
- (rule.entropy && entropy(secret) < rule.entropy)
247
+ return !secret || secret === REDACTED || filtered(rule, secret)
206
248
  ? undefined
207
249
  : secret;
208
250
  }
251
+
252
+ // Secret-only Betterleaks filter clauses, translated by the sync script.
253
+ function filtered(rule, secret) {
254
+ const score = rule.entropy ? entropy(secret) : Infinity;
255
+ const test = (source) =>
256
+ linearPattern(`filter:${source}`, source).test(secret);
257
+ return (
258
+ score < rule.entropy ||
259
+ (rule.entropyInclusive && score === rule.entropy) ||
260
+ (rule.reject ?? []).some(test) ||
261
+ !(rule.require ?? []).every(test)
262
+ );
263
+ }
@@ -0,0 +1,36 @@
1
+ import { failure } from './detection.js';
2
+ import { detectNational } from './national.js';
3
+
4
+ const validRule = (rule) =>
5
+ rule &&
6
+ typeof rule.type === 'string' &&
7
+ /^[A-Z][A-Z0-9_]{0,63}$/.test(rule.type) &&
8
+ rule.pattern instanceof RegExp &&
9
+ rule.pattern.global &&
10
+ rule.context instanceof RegExp &&
11
+ !rule.context.global &&
12
+ !rule.context.sticky &&
13
+ typeof rule.checksum === 'function' &&
14
+ ['plausible', 'valid'].every(
15
+ (key) => rule[key] === undefined || typeof rule[key] === 'function'
16
+ );
17
+
18
+ // Adapts identifier rules (the `nationalRules` shape) to the Detector port, so
19
+ // callers can add or replace identifiers without forking the built-in table.
20
+ export function createIdentifierDetector(
21
+ rules,
22
+ { id = 'identifier-rules' } = {}
23
+ ) {
24
+ if (!Array.isArray(rules) || rules.length === 0 || !rules.every(validRule)) {
25
+ throw failure('ERR_CONFIG');
26
+ }
27
+ const frozen = rules.map((rule) => Object.freeze({ ...rule }));
28
+ return {
29
+ id,
30
+ detect(text) {
31
+ const findings = [];
32
+ detectNational(text, (finding) => findings.push(finding), frozen);
33
+ return findings;
34
+ },
35
+ };
36
+ }
package/src/identity.js CHANGED
@@ -38,6 +38,20 @@ export const identityTypes = new Set([
38
38
  'CA_SIN',
39
39
  'RU_SNILS',
40
40
  'RU_INN',
41
+ 'US_ITIN',
42
+ 'US_DRIVER_LICENSE',
43
+ 'UK_DRIVING_LICENCE',
44
+ 'ES_NIE',
45
+ 'IN_AADHAAR',
46
+ 'IN_VOTER',
47
+ 'IN_PASSPORT',
48
+ 'FI_PERSONAL_IDENTITY_CODE',
49
+ 'TH_TNIN',
50
+ 'AU_MEDICARE',
51
+ 'DE_TAX_ID',
52
+ 'AE_EMIRATES_ID',
53
+ 'SA_NATIONAL_ID',
54
+ 'IL_ID_NUMBER',
41
55
  ]);
42
56
  export function isIdentityType(type) {
43
57
  return (