@link-foundation/sensitive-data-sanitizer 0.19.2 → 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.
- package/CHANGELOG.md +12 -0
- package/README.md +8 -1
- package/bin/sensitive-data-sanitizer.js +49 -28
- package/docs/API.md +193 -2
- package/docs/COVERAGE.md +22 -4
- package/docs/RULES.md +217 -0
- package/package.json +1 -1
- package/src/allow-list.js +93 -0
- package/src/confidence.js +24 -9
- package/src/context.js +121 -25
- package/src/credentials.js +44 -0
- package/src/crypto-addresses.js +74 -0
- package/src/detection.js +21 -6
- package/src/document-person-cues.js +1 -1
- package/src/encrypt.js +95 -0
- package/src/entities.js +4 -3
- package/src/fake.js +29 -1
- package/src/files.js +57 -12
- package/src/gitleaks.js +82 -27
- package/src/identifier-detector.js +36 -0
- package/src/identity.js +14 -0
- package/src/index.d.ts +117 -1
- package/src/index.js +7 -0
- package/src/national-checks.js +209 -0
- package/src/national.js +310 -92
- package/src/pattern-detector.js +219 -0
- package/src/personal-context.js +3 -2
- package/src/personal-evidence.js +233 -0
- package/src/personal.js +42 -10
- package/src/projection.js +85 -26
- package/src/rule-catalog.js +424 -0
- package/src/sanitizer.js +48 -3
- package/src/sarif.js +127 -0
- package/src/strict-attributes.js +71 -0
- package/src/strict.js +2 -12
- package/src/token-checksum.js +49 -0
- package/src/transforms.js +108 -25
- package/src/vendor/betterleaks/LICENSE +21 -0
- package/src/vendor/betterleaks/provenance.json +11 -0
- package/src/vendor/betterleaks/rules-0.json +463 -0
- package/src/vendor/betterleaks/rules-1.json +454 -0
- package/src/vendor/betterleaks/rules-2.json +461 -0
- package/src/vendor/betterleaks/rules-3.json +455 -0
- package/src/vendor/betterleaks/rules-4.json +483 -0
- package/src/vendor/betterleaks/rules-5.json +35 -0
- package/src/vendor/entities/aws.json +1 -1
- package/src/vendor/entities/azure.json +6 -2
- 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 {
|
|
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,
|
|
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
|
-
|
|
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)
|
|
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}(
|
|
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]*)
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
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
|
-
|
|
18
|
-
(
|
|
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) >
|
|
25
|
-
for (const rule of
|
|
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:
|
|
137
|
-
confidence: rule
|
|
160
|
+
rule: name,
|
|
161
|
+
confidence: ruleConfidence(rule, secret),
|
|
138
162
|
});
|
|
139
163
|
}
|
|
140
164
|
}
|
|
141
165
|
|
|
142
|
-
|
|
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
|
-
//
|
|
145
|
-
//
|
|
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
|
-
|
|
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
|
|
185
|
-
let linear = linearPatterns.get(
|
|
223
|
+
function linearPattern(key, source) {
|
|
224
|
+
let linear = linearPatterns.get(key);
|
|
186
225
|
if (!linear) {
|
|
187
|
-
linear = RE2JS.compile(
|
|
188
|
-
linearPatterns.set(
|
|
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 (
|