@link-foundation/sensitive-data-sanitizer 0.19.3 → 0.21.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 +22 -2
- package/bin/sensitive-data-sanitizer.js +50 -28
- package/docs/API.md +355 -16
- package/docs/COVERAGE.md +19 -9
- package/docs/RULES.md +218 -0
- package/examples/second-pass-placeholders.mjs +28 -0
- package/package.json +12 -3
- package/src/allow-list.js +93 -0
- package/src/confidence.js +24 -9
- package/src/context.js +287 -40
- package/src/credential-spans.js +180 -0
- package/src/credentials-entry.d.ts +23 -0
- package/src/credentials-entry.js +62 -0
- package/src/credentials.js +51 -0
- package/src/crypto-addresses.js +74 -0
- package/src/detection.js +87 -41
- package/src/document-person-cues.js +1 -1
- package/src/encoded.js +137 -51
- package/src/encrypt.js +95 -0
- package/src/engines.js +33 -10
- package/src/entities.js +4 -3
- package/src/exempt.js +72 -0
- package/src/fake-memory.js +32 -0
- package/src/fake.js +33 -29
- package/src/features.js +29 -0
- package/src/files.js +57 -12
- package/src/gitleaks.js +82 -27
- package/src/html-tags.js +8 -0
- package/src/identifier-detector.js +36 -0
- package/src/identity-types.js +63 -0
- package/src/identity.js +3 -47
- package/src/index.d.ts +157 -3
- package/src/index.js +13 -0
- package/src/known.js +33 -15
- 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-features.js +39 -0
- package/src/personal.js +66 -10
- package/src/placeholder-types.js +154 -4
- package/src/placeholders.js +127 -45
- package/src/projection.js +78 -26
- package/src/proper-names.js +48 -5
- package/src/prose-words.js +120 -0
- package/src/record-worker.js +1 -0
- package/src/rule-catalog.js +426 -0
- package/src/rules.js +8 -0
- package/src/sanitizer.js +133 -23
- package/src/sarif.js +127 -0
- package/src/stream-worker.js +1 -0
- package/src/strict-attributes.js +48 -4
- package/src/strict.js +1 -1
- package/src/structured.js +2 -0
- package/src/token-checksum.js +49 -0
- package/src/transforms.js +146 -33
- 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
- package/src/vocabulary.js +27 -1
- package/src/worker-options.js +1 -0
package/src/context.js
CHANGED
|
@@ -2,7 +2,9 @@ import { labels } from './rules.js';
|
|
|
2
2
|
import { escapePattern, REDACTED } from './detection.js';
|
|
3
3
|
import { credentialPlaceholder, referencePrefix } from './credentials.js';
|
|
4
4
|
import { servicePath } from './public.js';
|
|
5
|
+
import { cookieValues } from './credential-spans.js';
|
|
5
6
|
import { plausiblePerson } from './names.js';
|
|
7
|
+
import { htmlTag } from './html-tags.js';
|
|
6
8
|
|
|
7
9
|
// Bounded vocabularies are explicit so the short PASS/PW forms do not match
|
|
8
10
|
// unrelated words such as passport. Verb forms cover the supported locales.
|
|
@@ -50,14 +52,34 @@ const label = labels.credential
|
|
|
50
52
|
.filter((word) => !['auth', 'authorization'].includes(word))
|
|
51
53
|
.map(escapePattern)
|
|
52
54
|
.join('|');
|
|
53
|
-
|
|
55
|
+
// Glued service prefixes (`dbpass`, `rootpassword`, `bindpw`, `tlspassword`)
|
|
56
|
+
// are fixed so that ordinary words ending in "pass" stay prose.
|
|
57
|
+
const glued =
|
|
58
|
+
'(?:db|user|root|admin|smtp|mail|ftp|sftp|ssh|redis|ldap|bind|proxy|tls|ssl|keystore|truststore|store|master|sys|app|login|account|acct|wifi|wpa)(?:password|passwd|pass|pwd|pw|secret|token)';
|
|
59
|
+
// Plural credential names (`--access-tokens`, `API_KEYS`) hold credentials;
|
|
60
|
+
// other plurals (`--max-tokens`, `tokens: 1234`) are accounting.
|
|
61
|
+
const plural =
|
|
62
|
+
'(?:access|refresh|auth|session|api|bearer|client)[_.-]?(?:tokens|keys|secrets)';
|
|
63
|
+
const key = `(?:[\\p{L}\\p{N}]+[_.-])*(?:auth|authorization|PGPASSWORD|${glued}|${plural}|${label}|pass|pw)(?:[_.-][\\p{L}\\p{N}]+)*`;
|
|
64
|
+
// Ruby/Perl/PHP hash rockets (`'password' => 'x'`, `:token => "x"`) take a
|
|
65
|
+
// quoted literal; an arrow function (`token => token.trim()`) does not.
|
|
66
|
+
const rocket = `=>(?=[ \\t]*\\\\*["'])`;
|
|
54
67
|
// A qualifier such as "for staging" stays on one logical line: it never
|
|
55
68
|
// crosses an escaped line break, an escape sequence or an enclosing quote.
|
|
56
69
|
// Verbs are whole words, so "request" does not end in the verb "est".
|
|
57
70
|
const credentialPrefix = new RegExp(
|
|
58
|
-
`(?<![\\p{L}\\p{N}_])(?:\\\\*["'])?(${key})(?![\\p{L}\\p{N}_])(?:\\\\*["'])?(?:[ \\t]+(?:от|для|к|for|de|du|del)[ \\t]+[^:=\\r\\n"\\\\]{1,64})?[ \\t]*([:=:]|(?<![\\p{L}\\p{N}_])(?:${verbs})(?![\\p{L}\\p{N}_]))[ \\t]*`,
|
|
71
|
+
`(?<![\\p{L}\\p{N}_])(?:\\\\*["'])?(${key})(?![\\p{L}\\p{N}_])(?:\\\\*["'])?(?:[ \\t]+(?:от|для|к|for|de|du|del)[ \\t]+[^:=\\r\\n"\\\\]{1,64})?[ \\t]*(${rocket}|[:=:]|(?<![\\p{L}\\p{N}_])(?:${verbs})(?![\\p{L}\\p{N}_]))[ \\t]*`,
|
|
59
72
|
'giu'
|
|
60
73
|
);
|
|
74
|
+
// camelCase and PascalCase keys (`clientSecret`, `secretKey`, `dbPassword`,
|
|
75
|
+
// `ApiSecret`) are case-sensitive: the credential word starts a new word, so
|
|
76
|
+
// `tokenType`, `secretName` and `maxTokens` stay configuration metadata.
|
|
77
|
+
const camelKey =
|
|
78
|
+
'[\\p{L}][\\p{Ll}\\p{N}]*(?:\\p{Lu}[\\p{Ll}\\p{N}]*)*?(?:Secret|Password|Passwd|Passphrase|Pass|Pwd|Token|Credentials?|(?:Api|Private|Access|Secret|Signing|Encryption|Master|Client|App|Subscription|Account|Storage)Key)(?:Key|Value)?|(?:[Ss]ecret|[Pp]assword|[Pp]asswd|[Tt]oken|[Pp]rivate|[Aa]ccess|[Ss]igning|[Ee]ncryption)(?:Key|Value|Secret|Token)';
|
|
79
|
+
const camelPrefix = new RegExp(
|
|
80
|
+
`(?<![\\p{L}\\p{N}_$])(?:\\\\*["'])?(${camelKey})(?![\\p{L}\\p{N}_])(?:\\\\*["'])?[ \\t]*(${rocket}|[:=])[ \\t]*`,
|
|
81
|
+
'gu'
|
|
82
|
+
);
|
|
61
83
|
// Bare generic labels are common English nouns. After a verb they need a
|
|
62
84
|
// value that looks generated; "the token is valid" is prose, not a secret.
|
|
63
85
|
function generatedWord(text, range) {
|
|
@@ -107,8 +129,20 @@ function emitValue(text, start, end, emit, details, keyName = '') {
|
|
|
107
129
|
emit({ start, end, ...details });
|
|
108
130
|
}
|
|
109
131
|
|
|
132
|
+
// `HIVE_MIND_ROUTER_AUTH_GUARD=0`, `GIT_AUTH_TRANSPORT=1`, `AUTH_MODE=2`:
|
|
133
|
+
// a number or a boolean under a configuration name is a setting. Credential
|
|
134
|
+
// names (`AUTH_TOKEN=123456`, `AUTH_PASSWORD=1`) do not end in one of these.
|
|
135
|
+
const configurationSuffix =
|
|
136
|
+
/[_.-](?:guard|transport|mode|method|type|kind|flow|strategy|scheme|protocol|port|level|state|status|enabled?|disabled?|required|strict|debug|verbose|timeout|ttl|interval|retries|retry|attempts|count|limit|max|min|size|length|version|flag)$/i;
|
|
137
|
+
const configurationValue =
|
|
138
|
+
/^(?:[+-]?\d{1,9}(?:\.\d+)?|true|false|yes|no|on|off|enabled|disabled|none)$/i;
|
|
139
|
+
export function configurationSetting(keyName, value) {
|
|
140
|
+
return configurationSuffix.test(keyName) && configurationValue.test(value);
|
|
141
|
+
}
|
|
142
|
+
|
|
110
143
|
function credentialMetadata(keyName, value) {
|
|
111
144
|
return (
|
|
145
|
+
configurationSetting(keyName, value) ||
|
|
112
146
|
(/(?:^|[_.-])auth[_.-](?:mode|method|type)$/i.test(keyName) &&
|
|
113
147
|
/^(?:chatgpt|oauth2?|apikey|api_key|bearer|basic|none)$/i.test(value)) ||
|
|
114
148
|
(keyName === 'id-token' && /^(?:write|read|none)$/.test(value)) ||
|
|
@@ -154,15 +188,27 @@ export function valueRange(text, start, mode = 'token', maxLength = Infinity) {
|
|
|
154
188
|
}
|
|
155
189
|
// Header credentials occupy a scheme and one token even inside shell/JSON
|
|
156
190
|
// strings. A closing quote or escape belongs to the surrounding log record.
|
|
157
|
-
|
|
191
|
+
// The scheme (`token`, `Bearer`, `Digest`) stays readable; only the
|
|
192
|
+
// credential is returned. Auth-param lists (`Digest username="a", nonce=…`,
|
|
193
|
+
// AWS SigV4) return each secret parameter value.
|
|
194
|
+
export function authorizationRanges(text, start) {
|
|
158
195
|
const opening = /^(\\*)(["'])/.exec(text.slice(start));
|
|
159
196
|
start += opening?.[0].length ?? 0;
|
|
160
197
|
const reference = referencePrefix(text.slice(start, start + 512));
|
|
161
198
|
if (reference) {
|
|
162
|
-
return { start, end: start + reference[0].length };
|
|
199
|
+
return [{ start, end: start + reference[0].length }];
|
|
163
200
|
}
|
|
164
201
|
const scheme = /^[a-z][a-z0-9_-]*[ \t]+/i.exec(text.slice(start));
|
|
165
202
|
const tokenStart = start + (scheme?.[0].length ?? 0);
|
|
203
|
+
const params = scheme ? authParams(text, tokenStart) : undefined;
|
|
204
|
+
if (params) {
|
|
205
|
+
return params;
|
|
206
|
+
}
|
|
207
|
+
const end = authorizationEnd(text, tokenStart);
|
|
208
|
+
const token = text.slice(tokenStart, end);
|
|
209
|
+
return usableAuthorization(token) ? [{ start: tokenStart, end }] : [];
|
|
210
|
+
}
|
|
211
|
+
function authorizationEnd(text, tokenStart) {
|
|
166
212
|
const brackets = [];
|
|
167
213
|
let end = tokenStart;
|
|
168
214
|
for (; end < text.length; end++) {
|
|
@@ -182,8 +228,69 @@ export function authorizationRange(text, start) {
|
|
|
182
228
|
break;
|
|
183
229
|
}
|
|
184
230
|
}
|
|
185
|
-
|
|
186
|
-
|
|
231
|
+
return end;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// RFC 7616/7235 parameters that name the request, not a secret.
|
|
235
|
+
const publicAuthParam =
|
|
236
|
+
/^(?:username|realm|uri|qop|algorithm|nc|charset|userhash|domain|stale|SignedHeaders|oauth_signature_method|oauth_version|oauth_timestamp|oauth_callback|keyId|headers|created|expires)$/i;
|
|
237
|
+
const authParam = /^([A-Za-z][\w-]*)[ \t]*=[ \t]*/;
|
|
238
|
+
function authParams(text, start) {
|
|
239
|
+
const ranges = [];
|
|
240
|
+
let cursor = start,
|
|
241
|
+
count = 0,
|
|
242
|
+
quoted = false;
|
|
243
|
+
for (;;) {
|
|
244
|
+
const name = authParam.exec(text.slice(cursor, cursor + 128));
|
|
245
|
+
if (!name) {
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
const valueStart = cursor + name[0].length;
|
|
249
|
+
const value = authParamValue(text, valueStart);
|
|
250
|
+
if (!value) {
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
count++;
|
|
254
|
+
quoted ||= value.quoted;
|
|
255
|
+
if (!publicAuthParam.test(name[1]) && value.end > value.start) {
|
|
256
|
+
ranges.push({ start: value.start, end: value.end });
|
|
257
|
+
}
|
|
258
|
+
const separator = /^[ \t]*,[ \t]*/.exec(
|
|
259
|
+
text.slice(value.after, value.after + 64)
|
|
260
|
+
);
|
|
261
|
+
if (!separator) {
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
cursor = value.after + separator[0].length;
|
|
265
|
+
}
|
|
266
|
+
// `Basic dXNlcg==` is one token68 credential, not a parameter list.
|
|
267
|
+
return count > 1 || (count === 1 && quoted) ? ranges : undefined;
|
|
268
|
+
}
|
|
269
|
+
function authParamValue(text, start) {
|
|
270
|
+
const quote = /^(\\*)"/.exec(text.slice(start, start + 64));
|
|
271
|
+
if (quote) {
|
|
272
|
+
// Parameter values are short; a quote further away is not their end.
|
|
273
|
+
const window = text.slice(start, start + 4096);
|
|
274
|
+
const offset = window.indexOf(quote[0], quote[0].length);
|
|
275
|
+
const close = start + offset;
|
|
276
|
+
if (offset < 0 || /[\r\n]/.test(window.slice(0, offset))) {
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
const after = close + quote[0].length;
|
|
280
|
+
// A quoted value ends the header or continues the list.
|
|
281
|
+
if (
|
|
282
|
+
!/^(?:[ \t]*(?:,|$|[\r\n])|\\*["'\r\n])/.test(
|
|
283
|
+
text.slice(after, after + 64)
|
|
284
|
+
)
|
|
285
|
+
) {
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
return { start: start + quote[0].length, end: close, after, quoted: true };
|
|
289
|
+
}
|
|
290
|
+
const token = /^[^\s,"'\\]{1,4096}/.exec(text.slice(start, start + 4096));
|
|
291
|
+
return token
|
|
292
|
+
? { start, end: start + token[0].length, after: start + token[0].length }
|
|
293
|
+
: undefined;
|
|
187
294
|
}
|
|
188
295
|
function usableAuthorization(token) {
|
|
189
296
|
return Boolean(token) && !credentialPlaceholder(token);
|
|
@@ -262,6 +369,25 @@ function operationalLocationPath(value) {
|
|
|
262
369
|
servicePath(value) || /^(?:\.?\.?\/)?[\w.-]+(?:\/[\w.-]+)+\/?$/.test(value)
|
|
263
370
|
);
|
|
264
371
|
}
|
|
372
|
+
// Types with a fixed value shape decide on the value alone; `undefined` leaves
|
|
373
|
+
// the decision to the general checks.
|
|
374
|
+
function shapedValueAllowed(value, type) {
|
|
375
|
+
if (type === 'PHONE') {
|
|
376
|
+
return (
|
|
377
|
+
/^\+?[\d () .-]{7,40}$/.test(value) &&
|
|
378
|
+
/\d{3}/.test(value.replace(/\D/g, ''))
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
if (type === 'EMAIL') {
|
|
382
|
+
return /^[^\s@]+@[^\s@]+\.[\p{L}]{2,63}$/u.test(value);
|
|
383
|
+
}
|
|
384
|
+
// A URL holds no whitespace, so prose under a `url` key is left to the
|
|
385
|
+
// detectors of what the prose contains.
|
|
386
|
+
if (type === 'URL' && /\s/u.test(value)) {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
return undefined;
|
|
390
|
+
}
|
|
265
391
|
// Shared by native labels and the vendor catalogs. Code expressions and
|
|
266
392
|
// line escapes are not personal field values, even when a key is familiar.
|
|
267
393
|
export function personalValueAllowed(text, range, type, labelStart) {
|
|
@@ -275,14 +401,9 @@ export function personalValueAllowed(text, range, type, labelStart) {
|
|
|
275
401
|
) {
|
|
276
402
|
return false;
|
|
277
403
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
/\d{3}/.test(value.replace(/\D/g, ''))
|
|
282
|
-
);
|
|
283
|
-
}
|
|
284
|
-
if (type === 'EMAIL') {
|
|
285
|
-
return /^[^\s@]+@[^\s@]+\.[\p{L}]{2,63}$/u.test(value);
|
|
404
|
+
const shaped = shapedValueAllowed(value, type);
|
|
405
|
+
if (shaped !== undefined) {
|
|
406
|
+
return shaped;
|
|
286
407
|
}
|
|
287
408
|
if (/^(?:\\[nrt]|[\r\n])/.test(value)) {
|
|
288
409
|
return false;
|
|
@@ -359,36 +480,103 @@ function sourceReference(label, tail) {
|
|
|
359
480
|
);
|
|
360
481
|
}
|
|
361
482
|
|
|
483
|
+
// A camelCase key is usually source code; an unquoted member chain, call or
|
|
484
|
+
// type annotation after it (`config.clientSecret`, `apiToken: string;`) is
|
|
485
|
+
// a reference, not the secret itself.
|
|
486
|
+
function codeValue(text, start) {
|
|
487
|
+
return /^(?:[A-Za-z_$][\w$]*(?:\??\.[A-Za-z_$][\w$]*)+(?:\([^()\r\n]*\))?|[A-Za-z_$][\w$]*\([^()\r\n]*\)|(?:string|number|boolean|any|unknown|str|bytes|String|SecretStr|Optional\[str\])(?![\w$]))[ \t]*(?:[,;)}\]]|\r?\n|$)/u.test(
|
|
488
|
+
text.slice(start, start + 256)
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Picks how far a credential value runs: to the end of a query parameter, a
|
|
493
|
+
// token, a line or a prose word.
|
|
494
|
+
function credentialMode(text, match) {
|
|
495
|
+
const prefix = text.slice(Math.max(0, match.index - 512), match.index);
|
|
496
|
+
if (
|
|
497
|
+
/(?:https?:\/\/[^\s]*[?&]|^[?&])[^\s]*$/i.test(prefix) ||
|
|
498
|
+
/[?&]$/.test(prefix)
|
|
499
|
+
) {
|
|
500
|
+
return 'query';
|
|
501
|
+
}
|
|
502
|
+
if (/^=>?$/.test(match[2]) || /["']\s*[::]/.test(match[0])) {
|
|
503
|
+
return 'token';
|
|
504
|
+
}
|
|
505
|
+
return /[::]/.test(match[2]) ? 'line' : 'prose';
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// `token: <script>alert(1)</script>`: markup is the value. A bare
|
|
509
|
+
// `<placeholder>` that ends the value stays a placeholder.
|
|
510
|
+
const angleEnd = {
|
|
511
|
+
line: /[\r\n"]|\\+[nrt"']/,
|
|
512
|
+
token: /[\s,;}\]"']|\\+[nrt"']/,
|
|
513
|
+
};
|
|
514
|
+
// Formatting tags around a value (`token=<em>…</em>`) are dropped from the
|
|
515
|
+
// detection view instead.
|
|
516
|
+
const formattingTag = new RegExp(`^${htmlTag}`, 'i');
|
|
517
|
+
function angleValue(text, start, mode) {
|
|
518
|
+
const tail = text.slice(start, start + 4096);
|
|
519
|
+
if (text[start] !== '<' || !angleEnd[mode] || formattingTag.test(tail)) {
|
|
520
|
+
return undefined;
|
|
521
|
+
}
|
|
522
|
+
const placeholder = /^<[^<>\r\n]{1,64}>/.exec(tail)?.[0];
|
|
523
|
+
if (
|
|
524
|
+
placeholder &&
|
|
525
|
+
credentialPlaceholder(placeholder) &&
|
|
526
|
+
angleEnd[mode].test(tail[placeholder.length] ?? '\n')
|
|
527
|
+
) {
|
|
528
|
+
return { start, end: start + placeholder.length };
|
|
529
|
+
}
|
|
530
|
+
const stop = tail.search(angleEnd[mode]);
|
|
531
|
+
return { start, end: start + (stop < 0 ? tail.length : stop) };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function credentialRange(text, match, camel) {
|
|
535
|
+
const start = match.index + match[0].length;
|
|
536
|
+
if (
|
|
537
|
+
sourceReference(match[1], text.slice(start)) ||
|
|
538
|
+
(camel && codeValue(text, start))
|
|
539
|
+
) {
|
|
540
|
+
return undefined;
|
|
541
|
+
}
|
|
542
|
+
if (/^(?:proxy-)?authorization$/i.test(match[1])) {
|
|
543
|
+
return authorizationRanges(text, start);
|
|
544
|
+
}
|
|
545
|
+
const mode = credentialMode(text, match);
|
|
546
|
+
const range = angleValue(text, start, mode) ?? valueRange(text, start, mode);
|
|
547
|
+
const value = range && mode === 'prose' ? generatedWord(text, range) : range;
|
|
548
|
+
if (value && /^(?:set-)?cookie$/i.test(match[1])) {
|
|
549
|
+
return cookieValues(text, value.start, value.end);
|
|
550
|
+
}
|
|
551
|
+
return [value].filter(Boolean);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// `<password>…</password>`, `<clientSecret attr="1">…</clientSecret>`:
|
|
555
|
+
// attributes in the opening tag stay readable.
|
|
556
|
+
const element = (name) =>
|
|
557
|
+
`<(${name})(?:[ \\t\\r\\n]+[^<>]{0,512})?>([^<]+)</\\1[ \\t]*>`;
|
|
558
|
+
const xmlElement = new RegExp(element(key), 'giu');
|
|
559
|
+
const camelElement = new RegExp(element(camelKey), 'gu');
|
|
560
|
+
|
|
362
561
|
function detectCredentials(text, emit) {
|
|
363
562
|
const details = { category: 'credential', type: 'SECRET', rule: 'context' };
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
? 'query'
|
|
372
|
-
: match[2] === '=' || /["']\s*[::]/.test(match[0])
|
|
373
|
-
? 'token'
|
|
374
|
-
: /[::]/.test(match[2])
|
|
375
|
-
? 'line'
|
|
376
|
-
: 'prose';
|
|
377
|
-
let range = /^(?:proxy-)?authorization$/i.test(match[1])
|
|
378
|
-
? authorizationRange(text, start)
|
|
379
|
-
: valueRange(text, start, mode);
|
|
380
|
-
if (range && mode === 'prose') {
|
|
381
|
-
range = generatedWord(text, range);
|
|
382
|
-
}
|
|
383
|
-
if (sourceReference(match[1], text.slice(start))) {
|
|
563
|
+
const matches = [
|
|
564
|
+
...[...text.matchAll(credentialPrefix)].map((match) => [match, false]),
|
|
565
|
+
...[...text.matchAll(camelPrefix)].map((match) => [match, true]),
|
|
566
|
+
];
|
|
567
|
+
for (const [match, camel] of matches) {
|
|
568
|
+
// `https://x-access-token:…@host` is URL user info (`url-userinfo`).
|
|
569
|
+
if (/:\/\/$/.test(text.slice(Math.max(0, match.index - 3), match.index))) {
|
|
384
570
|
continue;
|
|
385
571
|
}
|
|
386
|
-
|
|
387
|
-
emitValue(text,
|
|
572
|
+
for (const value of credentialRange(text, match, camel) ?? []) {
|
|
573
|
+
emitValue(text, value.start, value.end, emit, details, match[1]);
|
|
388
574
|
}
|
|
389
575
|
}
|
|
390
|
-
const
|
|
391
|
-
|
|
576
|
+
for (const match of [
|
|
577
|
+
...text.matchAll(xmlElement),
|
|
578
|
+
...text.matchAll(camelElement),
|
|
579
|
+
]) {
|
|
392
580
|
const start = match.index + match[0].indexOf('>') + 1;
|
|
393
581
|
emitValue(text, start, start + match[2].length, emit, details, match[1]);
|
|
394
582
|
}
|
|
@@ -417,12 +605,19 @@ const personalPrefixes = Object.entries(labels)
|
|
|
417
605
|
];
|
|
418
606
|
});
|
|
419
607
|
|
|
608
|
+
// "passport number is 912803456": the number word and a following linking
|
|
609
|
+
// verb belong to the label, never to the identity value.
|
|
610
|
+
const identitySuffix = new RegExp(
|
|
611
|
+
`^(?:(?:number|no\\.?|номер)[ \\t]*(?:[:=:]|(?:${verbs})(?![\\p{L}\\p{N}_]))?[ \\t]*|[№#][ \\t]*)`,
|
|
612
|
+
'iu'
|
|
613
|
+
);
|
|
614
|
+
|
|
420
615
|
function detectPersonalLabels(text, emit) {
|
|
421
616
|
for (const [type, prefix] of personalPrefixes) {
|
|
422
617
|
for (const match of text.matchAll(prefix)) {
|
|
423
618
|
const identity = type === 'ID';
|
|
424
619
|
const suffix = identity
|
|
425
|
-
? (
|
|
620
|
+
? (identitySuffix.exec(
|
|
426
621
|
text.slice(match.index + match[0].length)
|
|
427
622
|
)?.[0] ?? '')
|
|
428
623
|
: '';
|
|
@@ -488,7 +683,7 @@ function detectCommandPasswords(text, emit) {
|
|
|
488
683
|
}
|
|
489
684
|
if (/(?:^|[\s/])curl(?:\s|$)/.test(command)) {
|
|
490
685
|
for (const match of command.matchAll(
|
|
491
|
-
/(?:^|\s)(?:-u|--user)(?:[ \t]+|=)/g
|
|
686
|
+
/(?:^|\s)(?:-u|--user|-U|--proxy-user)(?:[ \t]+|=)/g
|
|
492
687
|
)) {
|
|
493
688
|
const range = valueRange(
|
|
494
689
|
text,
|
|
@@ -502,10 +697,62 @@ function detectCommandPasswords(text, emit) {
|
|
|
502
697
|
}
|
|
503
698
|
}
|
|
504
699
|
|
|
700
|
+
// Kubernetes/compose environment entries name the secret in one field and
|
|
701
|
+
// carry it in the next: `{"name":"DB_PASSWORD","value":"hunter2"}` or
|
|
702
|
+
// `- name: DB_PASSWORD` followed by `value: hunter2`.
|
|
703
|
+
const namedValue = new RegExp(
|
|
704
|
+
`(["']?)name\\1[ \\t]*:[ \\t]*(["']?)(${key})\\2[ \\t]*,?[ \\t]*(?:\\r?\\n[ \\t]*)?(["']?)value\\4[ \\t]*:[ \\t]*`,
|
|
705
|
+
'giu'
|
|
706
|
+
);
|
|
707
|
+
// `smtp.login(user, "hunter2")`, `ftp.login('anna', 'pw')`: a literal second
|
|
708
|
+
// argument of a login call is the password.
|
|
709
|
+
const loginCall =
|
|
710
|
+
/(?<![\p{L}\p{N}_])(?:login|log_in|logIn|signin|sign_in|signIn|authenticate|auth_login|bind_s|simple_bind_s|connect_with_password)[ \t]*\([ \t]*[^,()\r\n]{1,64},[ \t]*(["'])([^"'\\\r\n]{4,128})\1[ \t]*[,)]/gu;
|
|
711
|
+
// Modular crypt and Argon2 password hashes (`$2y$10$…`, `$apr1$…`, `$6$…`)
|
|
712
|
+
// from htpasswd, shadow files and user tables crack offline.
|
|
713
|
+
const passwordHash =
|
|
714
|
+
/(?<![\p{L}\p{N}_$])\$(?:2[abxy]?\$\d{2}\$[./A-Za-z0-9]{53}|apr1\$[./A-Za-z0-9]{1,8}\$[./A-Za-z0-9]{22}|1\$[./A-Za-z0-9]{1,8}\$[./A-Za-z0-9]{22}|[56]\$(?:rounds=\d{1,9}\$)?[./A-Za-z0-9]{1,16}\$[./A-Za-z0-9]{43,86}|y\$[./A-Za-z0-9]+\$[./A-Za-z0-9]{1,64}\$[./A-Za-z0-9]{43}|argon2(?:id|i|d)\$v=\d+\$m=\d+,t=\d+,p=\d+\$[A-Za-z0-9+/]{11,}\$[A-Za-z0-9+/]{16,})(?![./A-Za-z0-9$])/gu;
|
|
715
|
+
function detectCredentialForms(text, emit) {
|
|
716
|
+
const details = { category: 'credential', type: 'SECRET' };
|
|
717
|
+
if (/name/i.test(text)) {
|
|
718
|
+
for (const match of text.matchAll(namedValue)) {
|
|
719
|
+
const range = valueRange(text, match.index + match[0].length);
|
|
720
|
+
if (range) {
|
|
721
|
+
emitValue(
|
|
722
|
+
text,
|
|
723
|
+
range.start,
|
|
724
|
+
range.end,
|
|
725
|
+
emit,
|
|
726
|
+
{ ...details, rule: 'named-value' },
|
|
727
|
+
match[3]
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
for (const match of text.matchAll(loginCall)) {
|
|
733
|
+
const start = match.index + match[0].lastIndexOf(match[2]);
|
|
734
|
+
emitValue(text, start, start + match[2].length, emit, {
|
|
735
|
+
...details,
|
|
736
|
+
rule: 'login-call',
|
|
737
|
+
confidence: 0.85,
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
if (text.includes('$')) {
|
|
741
|
+
for (const match of text.matchAll(passwordHash)) {
|
|
742
|
+
emitValue(text, match.index, match.index + match[0].length, emit, {
|
|
743
|
+
...details,
|
|
744
|
+
rule: 'password-hash',
|
|
745
|
+
confidence: 0.95,
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
505
751
|
export function detectContext(text, emit, credentialsOnly = false) {
|
|
506
752
|
if (credentialHint.test(text)) {
|
|
507
753
|
detectCredentials(text, emit);
|
|
508
754
|
}
|
|
755
|
+
detectCredentialForms(text, emit);
|
|
509
756
|
if (!credentialsOnly) {
|
|
510
757
|
detectPersonalLabels(text, emit);
|
|
511
758
|
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { maskedValue } from './credentials.js';
|
|
2
|
+
|
|
3
|
+
// Credential findings cover the secret, not the readable context around it:
|
|
4
|
+
// PEM markers, URL hosts and user names, cookie names and the rest of a
|
|
5
|
+
// sentence stay visible, so a masked log still shows what was masked.
|
|
6
|
+
|
|
7
|
+
// Context rules that read "to the end of the value" without knowing the
|
|
8
|
+
// value's format. A structured vendor token that starts the same value
|
|
9
|
+
// bounds them.
|
|
10
|
+
const openEnded = new Set([
|
|
11
|
+
'context',
|
|
12
|
+
'cookie',
|
|
13
|
+
'named-value',
|
|
14
|
+
'cli-password',
|
|
15
|
+
'quoted-entropy',
|
|
16
|
+
'entropy',
|
|
17
|
+
'gitleaks/generic-api-key',
|
|
18
|
+
'betterleaks/generic-api-key',
|
|
19
|
+
]);
|
|
20
|
+
const notBounding = new Set([...openEnded, 'known-secret', 'authorization']);
|
|
21
|
+
const lineBreak = '(?:\\r?\\n|\\\\+[nr])';
|
|
22
|
+
const pemMarker = new RegExp(
|
|
23
|
+
`-----(BEGIN|END) [A-Z0-9 _-]{0,100}-----(?:[ \\t]*${lineBreak})?`,
|
|
24
|
+
'g'
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
// Body spans of every BEGIN/END block inside [start, end). Text between two
|
|
28
|
+
// blocks never belongs to a key; an unterminated block runs to `end`.
|
|
29
|
+
export function pemBodies(text, start, end) {
|
|
30
|
+
const segment = text.slice(start, end);
|
|
31
|
+
if (!/^(?:\\*["'])?-----BEGIN /.test(segment)) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
const bodies = [];
|
|
35
|
+
let open;
|
|
36
|
+
for (const marker of segment.matchAll(pemMarker)) {
|
|
37
|
+
if (marker[1] === 'BEGIN') {
|
|
38
|
+
open = marker.index + marker[0].length;
|
|
39
|
+
} else if (open !== undefined) {
|
|
40
|
+
bodies.push(trimmed(text, start + open, start + marker.index));
|
|
41
|
+
open = undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (open !== undefined) {
|
|
45
|
+
bodies.push(trimmed(text, start + open, end));
|
|
46
|
+
}
|
|
47
|
+
return bodies.filter((body) => body.end > body.start);
|
|
48
|
+
}
|
|
49
|
+
function trimmed(text, start, end) {
|
|
50
|
+
const body = text.slice(start, end);
|
|
51
|
+
const lead = /^(?:\s|\\+[nrt])+/.exec(body)?.[0].length ?? 0;
|
|
52
|
+
const tail = /(?:\s|\\+[nrt])+$/.exec(body.slice(lead))?.[0].length ?? 0;
|
|
53
|
+
return { start: start + lead, end: end - tail };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// `scheme://user:password@host`: only the password is the credential.
|
|
57
|
+
const userinfo = /^[a-z][a-z0-9+.-]*:\/\/([^\s/@:]+):([^\s/@]+)@/i;
|
|
58
|
+
// A URL secret keeps its scheme, host and fixed path words
|
|
59
|
+
// (`https://hooks.slack.com/services/`), so a reader still sees which
|
|
60
|
+
// endpoint it was.
|
|
61
|
+
const serviceUser =
|
|
62
|
+
/^(?:x-access-token|x-oauth-basic|x-token-auth|oauth2?|git|gitlab-ci-token|token|pat|apikey|api)$/i;
|
|
63
|
+
const endpoint = /^(?:https?:\/\/)?[a-z0-9-]+(?:\.[a-z0-9-]+)+\//i;
|
|
64
|
+
const pathWord = /^(?:[a-z]{2,20}(?:\d{1,2})?\/)+/i;
|
|
65
|
+
|
|
66
|
+
function urlSecret(text, finding) {
|
|
67
|
+
const value = text.slice(finding.start, finding.end);
|
|
68
|
+
// `url-userinfo` already covers only `user:password`.
|
|
69
|
+
const own = finding.rule === 'url-userinfo';
|
|
70
|
+
const credentials = own
|
|
71
|
+
? /^([^:]+):(.+)$/s.exec(value)
|
|
72
|
+
: userinfo.exec(value);
|
|
73
|
+
if (credentials) {
|
|
74
|
+
const end = finding.start + credentials[0].length - (own ? 0 : 1);
|
|
75
|
+
const start = end - credentials[2].length;
|
|
76
|
+
const user = start - 1 - credentials[1].length;
|
|
77
|
+
return [
|
|
78
|
+
{ start, end },
|
|
79
|
+
// Outside credentialsOnly the user name is still personal data.
|
|
80
|
+
...(serviceUser.test(credentials[1]) || maskedValue(credentials[1])
|
|
81
|
+
? []
|
|
82
|
+
: [
|
|
83
|
+
{
|
|
84
|
+
start: user,
|
|
85
|
+
end: user + credentials[1].length,
|
|
86
|
+
type: 'USERNAME',
|
|
87
|
+
category: 'personal',
|
|
88
|
+
},
|
|
89
|
+
]),
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
const host = endpoint.exec(value);
|
|
93
|
+
if (!host || finding.rule === 'url-userinfo') {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
const words = pathWord.exec(value.slice(host[0].length))?.[0] ?? '';
|
|
97
|
+
const start = finding.start + host[0].length + words.length;
|
|
98
|
+
return finding.end - start >= 8 ? [{ start, end: finding.end }] : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function narrowed(text, finding) {
|
|
102
|
+
if (finding.category !== 'credential') {
|
|
103
|
+
return [finding];
|
|
104
|
+
}
|
|
105
|
+
const parts =
|
|
106
|
+
pemBodies(text, finding.start, finding.end) ?? urlSecret(text, finding);
|
|
107
|
+
return parts ? parts.map((part) => ({ ...finding, ...part })) : [finding];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// `token: ghp_… . Next sentence` — a context value that a vendor rule ends
|
|
111
|
+
// earlier stops at that end when only punctuation or a space follows.
|
|
112
|
+
function bounded(text, finding, findings) {
|
|
113
|
+
if (!openEnded.has(finding.rule) || finding.category !== 'credential') {
|
|
114
|
+
return finding;
|
|
115
|
+
}
|
|
116
|
+
let end = finding.end;
|
|
117
|
+
for (const other of findings) {
|
|
118
|
+
if (
|
|
119
|
+
other.category === 'credential' &&
|
|
120
|
+
!notBounding.has(other.rule) &&
|
|
121
|
+
other.start === finding.start &&
|
|
122
|
+
other.end < end &&
|
|
123
|
+
other.end > other.start &&
|
|
124
|
+
/^[.,;:!?]*(?:\s|$)/.test(text.slice(other.end, finding.end))
|
|
125
|
+
) {
|
|
126
|
+
end = other.end;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return end === finding.end ? finding : { ...finding, end };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// PEM armor (`-----BEGIN RSA PRIVATE KEY-----`) stays readable, and its
|
|
133
|
+
// upper-case words are not a person or an organization.
|
|
134
|
+
function pemMarkers(text, findings) {
|
|
135
|
+
const markers = [];
|
|
136
|
+
for (const finding of findings) {
|
|
137
|
+
const segment = text.slice(finding.start, finding.end);
|
|
138
|
+
if (finding.category === 'credential' && /-----BEGIN /.test(segment)) {
|
|
139
|
+
for (const marker of segment.matchAll(pemMarker)) {
|
|
140
|
+
const start = finding.start + marker.index;
|
|
141
|
+
markers.push({ start, end: start + marker[0].length });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return markers;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function refineCredentialSpans(text, findings) {
|
|
149
|
+
const markers = pemMarkers(text, findings);
|
|
150
|
+
const narrow = findings
|
|
151
|
+
.filter(
|
|
152
|
+
(finding) =>
|
|
153
|
+
finding.category === 'credential' ||
|
|
154
|
+
!markers.some(
|
|
155
|
+
(marker) => finding.start < marker.end && marker.start < finding.end
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
.flatMap((finding) => narrowed(text, finding));
|
|
159
|
+
return narrow.map((finding) => bounded(text, finding, narrow));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// `Cookie: a=1; b=2` and `Set-Cookie: id=…; Path=/; HttpOnly`: each cookie
|
|
163
|
+
// value is a credential; names, separators and attributes stay readable.
|
|
164
|
+
const cookieAttribute =
|
|
165
|
+
/^(?:path|domain|expires|max-age|samesite|secure|httponly|priority|partitioned|version|comment)$/i;
|
|
166
|
+
export function cookieValues(text, start, end) {
|
|
167
|
+
const values = [];
|
|
168
|
+
for (const pair of text.slice(start, end).matchAll(/[^;]+/g)) {
|
|
169
|
+
const part = /^[ \t]*(?:([^=\s]+)[ \t]*=[ \t]*)?(.*?)[ \t]*$/s.exec(
|
|
170
|
+
pair[0]
|
|
171
|
+
);
|
|
172
|
+
const [, name, value] = part;
|
|
173
|
+
if (!value || cookieAttribute.test(name ?? value)) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const valueEnd = start + pair.index + pair[0].replace(/[ \t]+$/, '').length;
|
|
177
|
+
values.push({ start: valueEnd - value.length, end: valueEnd });
|
|
178
|
+
}
|
|
179
|
+
return values;
|
|
180
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credentials-only entry point. It has the same API as the main entry for the
|
|
3
|
+
* functions it exports, always runs with `credentialsOnly: true`, and rejects
|
|
4
|
+
* `credentialsOnly: false` and `mode: 'fake'` with `ERR_CONFIG`.
|
|
5
|
+
*/
|
|
6
|
+
export type * from './index.js';
|
|
7
|
+
export {
|
|
8
|
+
inspect,
|
|
9
|
+
sanitize,
|
|
10
|
+
redact,
|
|
11
|
+
createSanitizer,
|
|
12
|
+
createSyncSanitizer,
|
|
13
|
+
REDACTED,
|
|
14
|
+
entropy,
|
|
15
|
+
builtInRules,
|
|
16
|
+
tokenChecksum,
|
|
17
|
+
tokenWithChecksum,
|
|
18
|
+
knownSecretsFromEnv,
|
|
19
|
+
knownSecretsFromGitHubAuth,
|
|
20
|
+
knownSecretsFromGitHubConfig,
|
|
21
|
+
reservedPlaceholderTypes,
|
|
22
|
+
isPlaceholderToken,
|
|
23
|
+
} from './index.js';
|