@link-foundation/sensitive-data-sanitizer 0.21.4 → 0.21.6
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/docs/API.md +1 -1
- package/package.json +1 -1
- package/src/context.js +117 -9
- package/src/credential-spans.js +16 -0
- package/src/credentials.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.21.6
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- cbd6b3b: Preserve complete credential placeholders and public configuration values before Markdown backticks, including the exact `HIVE_MIND_CLAUDE_AUTH_AUTHORITY=conventional` opt-out. Keep masking actual HTML/script and keyword-prefixed credentials, with exact known secrets taking priority.
|
|
8
|
+
|
|
9
|
+
## 0.21.5
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- e61a218: Preserve Authorization and Proxy-Authorization scheme words when overlapping credential heuristics match exactly the scheme before a separate payload. Continue masking payloads, bare credentials, and exact known secrets.
|
|
14
|
+
|
|
3
15
|
## 0.21.4
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/docs/API.md
CHANGED
|
@@ -579,7 +579,7 @@ permission values and literal credential references. `publicKnowledge: false`
|
|
|
579
579
|
restores IP detection; explicit `knownSecrets` and `knownPersonal` retain
|
|
580
580
|
priority. No shell command or workflow expression is executed.
|
|
581
581
|
|
|
582
|
-
Authorization headers (including Proxy-Authorization)
|
|
582
|
+
Authorization headers (including Proxy-Authorization) preserve the scheme and redact the credential payload. Overlapping heuristic findings that cover exactly a scheme followed by a separate nonempty payload are ignored, including Gitleaks generic API-key findings on `SharedAccessSignature`. Bare credentials without a scheme, payload findings and exact `knownSecrets` matches remain subject to redaction. Whitespace, C0 controls (including NUL), DEL, quotes, backslashes and commas end the value, preserving subsequent log text in shell and escaped JSON prompts. Matching brackets opened within a token are included in its redaction; unmatched closing brackets remain part of the surrounding log. Already-redacted tokens and known credential references remain exempt after an optional scheme, including `<token>` placeholders. The debug callback receives value-free `gitleaks-rule` events with rule ID, input length and elapsed milliseconds; tracing is off by default.
|
|
583
583
|
|
|
584
584
|
## Caller-declared sensitive fields
|
|
585
585
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@link-foundation/sensitive-data-sanitizer",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.6",
|
|
4
4
|
"description": "Offline sensitive-text sanitization with credential, multilingual personal-data and scanner union detection",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/context.js
CHANGED
|
@@ -143,6 +143,10 @@ export function configurationSetting(keyName, value) {
|
|
|
143
143
|
function credentialMetadata(keyName, value) {
|
|
144
144
|
return (
|
|
145
145
|
configurationSetting(keyName, value) ||
|
|
146
|
+
// This documented opt-out enum is public; other authority values and
|
|
147
|
+
// names still carry credential evidence.
|
|
148
|
+
(keyName === 'HIVE_MIND_CLAUDE_AUTH_AUTHORITY' &&
|
|
149
|
+
value === 'conventional') ||
|
|
146
150
|
(/(?:^|[_.-])auth[_.-](?:mode|method|type)$/i.test(keyName) &&
|
|
147
151
|
/^(?:chatgpt|oauth2?|apikey|api_key|bearer|basic|none)$/i.test(value)) ||
|
|
148
152
|
(keyName === 'id-token' && /^(?:write|read|none)$/.test(value)) ||
|
|
@@ -160,11 +164,13 @@ function credentialMetadata(keyName, value) {
|
|
|
160
164
|
// Only a quote at the same escape depth closes it; embedded quotes belong to
|
|
161
165
|
// the value. Offsets cover escape bytes as well as the decoded characters.
|
|
162
166
|
export function valueRange(text, start, mode = 'token', maxLength = Infinity) {
|
|
163
|
-
const reference = referencePrefix(
|
|
167
|
+
const reference = referencePrefix(
|
|
168
|
+
text.slice(start, markdownEnd(text, start, 512))
|
|
169
|
+
);
|
|
164
170
|
if (reference) {
|
|
165
171
|
return { start, end: start + reference[0].length };
|
|
166
172
|
}
|
|
167
|
-
const opening = text.slice(start).match(/^(\\*)(["'])/);
|
|
173
|
+
const opening = text.slice(start).match(/^(\\*)(["']|`+)/);
|
|
168
174
|
if (opening) {
|
|
169
175
|
const range = quotedRange(text, start, opening, maxLength);
|
|
170
176
|
const outer = outerQuoteEnd(text, range.start, range.end);
|
|
@@ -173,7 +179,8 @@ export function valueRange(text, start, mode = 'token', maxLength = Infinity) {
|
|
|
173
179
|
if (/^[{[]/.test(text.slice(start, start + 1))) {
|
|
174
180
|
return undefined;
|
|
175
181
|
}
|
|
176
|
-
const tail = text.slice(start, start
|
|
182
|
+
const tail = text.slice(start, markdownEnd(text, start, maxLength));
|
|
183
|
+
// Only a matching surrounding Markdown delimiter ends an unquoted value.
|
|
177
184
|
const terminator =
|
|
178
185
|
mode === 'query'
|
|
179
186
|
? /[\s&#"'<>]/
|
|
@@ -198,8 +205,8 @@ export function authorizationRanges(text, start) {
|
|
|
198
205
|
if (reference) {
|
|
199
206
|
return [{ start, end: start + reference[0].length }];
|
|
200
207
|
}
|
|
201
|
-
const scheme =
|
|
202
|
-
const tokenStart =
|
|
208
|
+
const scheme = authorizationScheme(text, start);
|
|
209
|
+
const tokenStart = scheme?.payloadStart ?? start;
|
|
203
210
|
const params = scheme ? authParams(text, tokenStart) : undefined;
|
|
204
211
|
if (params) {
|
|
205
212
|
return params;
|
|
@@ -208,6 +215,25 @@ export function authorizationRanges(text, start) {
|
|
|
208
215
|
const token = text.slice(tokenStart, end);
|
|
209
216
|
return usableAuthorization(token) ? [{ start: tokenStart, end }] : [];
|
|
210
217
|
}
|
|
218
|
+
|
|
219
|
+
// A scheme is structural only when a separate, nonempty payload follows it.
|
|
220
|
+
// Share this boundary with span refinement so overlapping vendor detections
|
|
221
|
+
// cannot mask the scheme that the Authorization parser deliberately preserves.
|
|
222
|
+
export function authorizationScheme(text, start) {
|
|
223
|
+
const opening = /^(\\*)(["'])/.exec(text.slice(start));
|
|
224
|
+
start += opening?.[0].length ?? 0;
|
|
225
|
+
const scheme = /^([a-z][a-z0-9_-]*)[ \t]+/i.exec(text.slice(start));
|
|
226
|
+
if (!scheme) {
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
const payloadStart = start + scheme[0].length;
|
|
230
|
+
const char = text[payloadStart];
|
|
231
|
+
return char &&
|
|
232
|
+
!authorizationDelimiter(char, text.charCodeAt(payloadStart)) &&
|
|
233
|
+
!/[}\]>]/.test(char)
|
|
234
|
+
? { start, end: start + scheme[1].length, payloadStart }
|
|
235
|
+
: undefined;
|
|
236
|
+
}
|
|
211
237
|
function authorizationEnd(text, tokenStart) {
|
|
212
238
|
const brackets = [];
|
|
213
239
|
let end = tokenStart;
|
|
@@ -302,6 +328,9 @@ function quotedRange(text, start, opening, maxLength) {
|
|
|
302
328
|
const depth = opening[1].length,
|
|
303
329
|
quote = opening[2];
|
|
304
330
|
const valueStart = start + opening[0].length;
|
|
331
|
+
if (quote[0] === '`') {
|
|
332
|
+
return markdownRange(text, valueStart, quote.length, maxLength);
|
|
333
|
+
}
|
|
305
334
|
for (
|
|
306
335
|
let i = valueStart;
|
|
307
336
|
i < Math.min(text.length, valueStart + maxLength);
|
|
@@ -334,6 +363,64 @@ function quotedRange(text, start, opening, maxLength) {
|
|
|
334
363
|
};
|
|
335
364
|
}
|
|
336
365
|
|
|
366
|
+
// Markdown closes only at a run of the same length. Scan each run once so
|
|
367
|
+
// long unmatched backtick runs cannot cause repeated prefix comparisons.
|
|
368
|
+
function markdownRange(text, start, length, maxLength) {
|
|
369
|
+
const limit = Math.min(text.length, start + maxLength);
|
|
370
|
+
for (let i = start; i < limit; i++) {
|
|
371
|
+
if (text[i] !== '`') {
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
let end = i + 1;
|
|
375
|
+
while (end < limit && text[end] === '`') {
|
|
376
|
+
end++;
|
|
377
|
+
}
|
|
378
|
+
if (end - i === length && text[end] !== '`') {
|
|
379
|
+
return { start, end: i };
|
|
380
|
+
}
|
|
381
|
+
i = end - 1;
|
|
382
|
+
}
|
|
383
|
+
return { start, end: limit };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Recognize surrounding inline Markdown within a bounded logical line. When
|
|
387
|
+
// its opening or closing delimiter cannot be established, keep scanning the
|
|
388
|
+
// full credential, including any literal material following a placeholder.
|
|
389
|
+
function markdownEnd(text, start, maxLength) {
|
|
390
|
+
const end = Math.min(text.length, start + maxLength);
|
|
391
|
+
const from = Math.max(0, start - 4096);
|
|
392
|
+
const prefix = text.slice(from, start);
|
|
393
|
+
const lineStart =
|
|
394
|
+
Math.max(prefix.lastIndexOf('\n'), prefix.lastIndexOf('\r')) + 1;
|
|
395
|
+
if (from && !lineStart) {
|
|
396
|
+
return end;
|
|
397
|
+
}
|
|
398
|
+
let length = 0;
|
|
399
|
+
for (const run of prefix.slice(lineStart).matchAll(/`+/g)) {
|
|
400
|
+
if (!length) {
|
|
401
|
+
let slashes = 0;
|
|
402
|
+
for (
|
|
403
|
+
let i = lineStart + run.index - 1;
|
|
404
|
+
i >= 0 && prefix[i] === '\\';
|
|
405
|
+
i--
|
|
406
|
+
) {
|
|
407
|
+
slashes++;
|
|
408
|
+
}
|
|
409
|
+
if (slashes % 2 === 0) {
|
|
410
|
+
length = run[0].length;
|
|
411
|
+
}
|
|
412
|
+
} else if (length === run[0].length) {
|
|
413
|
+
length = 0;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (!length) {
|
|
417
|
+
return end;
|
|
418
|
+
}
|
|
419
|
+
const limit = Math.min(end, start + 4096);
|
|
420
|
+
const close = markdownRange(text, start, length, limit - start).end;
|
|
421
|
+
return close < limit ? close : end;
|
|
422
|
+
}
|
|
423
|
+
|
|
337
424
|
function plausibleLabel(value, type) {
|
|
338
425
|
if (operationalValue(value, type)) {
|
|
339
426
|
return false;
|
|
@@ -515,8 +602,11 @@ const angleEnd = {
|
|
|
515
602
|
// detection view instead.
|
|
516
603
|
const formattingTag = new RegExp(`^${htmlTag}`, 'i');
|
|
517
604
|
function angleValue(text, start, mode) {
|
|
518
|
-
|
|
519
|
-
|
|
605
|
+
if (text[start] !== '<' || !angleEnd[mode]) {
|
|
606
|
+
return undefined;
|
|
607
|
+
}
|
|
608
|
+
const tail = text.slice(start, markdownEnd(text, start, 4096));
|
|
609
|
+
if (formattingTag.test(tail)) {
|
|
520
610
|
return undefined;
|
|
521
611
|
}
|
|
522
612
|
const placeholder = /^<[^<>\r\n]{1,64}>/.exec(tail)?.[0];
|
|
@@ -531,6 +621,23 @@ function angleValue(text, start, mode) {
|
|
|
531
621
|
return { start, end: start + (stop < 0 ? tail.length : stop) };
|
|
532
622
|
}
|
|
533
623
|
|
|
624
|
+
// Scheme-prefixed values carry the credential after the keyword, just as
|
|
625
|
+
// Authorization headers do. Never mask only `token` and leave its value.
|
|
626
|
+
function credentialValueRange(text, start, mode) {
|
|
627
|
+
const valueStart = start;
|
|
628
|
+
if (mode === 'token') {
|
|
629
|
+
start +=
|
|
630
|
+
/^(?:token|Bearer|Basic)[ \t]+(?=\S)/i.exec(text.slice(start))?.[0]
|
|
631
|
+
.length ?? 0;
|
|
632
|
+
}
|
|
633
|
+
const range = angleValue(text, start, mode) ?? valueRange(text, start, mode);
|
|
634
|
+
// Keep the prefix in the value used for metadata checks: `token 0` is not
|
|
635
|
+
// the exact public setting `0`, even under a configuration name.
|
|
636
|
+
return range && start !== valueStart
|
|
637
|
+
? { ...range, start: valueStart }
|
|
638
|
+
: range;
|
|
639
|
+
}
|
|
640
|
+
|
|
534
641
|
function credentialRange(text, match, camel) {
|
|
535
642
|
const start = match.index + match[0].length;
|
|
536
643
|
if (
|
|
@@ -543,7 +650,7 @@ function credentialRange(text, match, camel) {
|
|
|
543
650
|
return authorizationRanges(text, start);
|
|
544
651
|
}
|
|
545
652
|
const mode = credentialMode(text, match);
|
|
546
|
-
const range =
|
|
653
|
+
const range = credentialValueRange(text, start, mode);
|
|
547
654
|
const value = range && mode === 'prose' ? generatedWord(text, range) : range;
|
|
548
655
|
if (value && /^(?:set-)?cookie$/i.test(match[1])) {
|
|
549
656
|
return cookieValues(text, value.start, value.end);
|
|
@@ -582,7 +689,8 @@ function detectCredentials(text, emit) {
|
|
|
582
689
|
}
|
|
583
690
|
const flags = new RegExp(`--(${key})(?:[ \\t]+|=)`, 'giu');
|
|
584
691
|
for (const match of text.matchAll(flags)) {
|
|
585
|
-
const
|
|
692
|
+
const start = match.index + match[0].length;
|
|
693
|
+
const range = credentialValueRange(text, start, 'token');
|
|
586
694
|
if (range) {
|
|
587
695
|
emitValue(text, range.start, range.end, emit, details, match[1]);
|
|
588
696
|
}
|
package/src/credential-spans.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { maskedValue } from './credentials.js';
|
|
2
|
+
import { authorizationScheme } from './context.js';
|
|
2
3
|
|
|
3
4
|
// Credential findings cover the secret, not the readable context around it:
|
|
4
5
|
// PEM markers, URL hosts and user names, cookie names and the rest of a
|
|
@@ -159,7 +160,22 @@ function pemMarkers(text, findings) {
|
|
|
159
160
|
|
|
160
161
|
export function refineCredentialSpans(text, findings) {
|
|
161
162
|
const markers = pemMarkers(text, findings);
|
|
163
|
+
const schemes = new Map();
|
|
164
|
+
for (const header of text.matchAll(
|
|
165
|
+
/(?<![\w-])(?:Proxy-)?Authorization(?:\\*["'])?[ \t]*:[ \t]*/gi
|
|
166
|
+
)) {
|
|
167
|
+
const scheme = authorizationScheme(text, header.index + header[0].length);
|
|
168
|
+
if (scheme) {
|
|
169
|
+
schemes.set(scheme.start, scheme.end);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
162
172
|
const narrow = findings
|
|
173
|
+
.filter(
|
|
174
|
+
(finding) =>
|
|
175
|
+
finding.category !== 'credential' ||
|
|
176
|
+
finding.rule === 'known-secret' ||
|
|
177
|
+
schemes.get(finding.start) !== finding.end
|
|
178
|
+
)
|
|
163
179
|
.filter(
|
|
164
180
|
(finding) =>
|
|
165
181
|
finding.category === 'credential' ||
|
package/src/credentials.js
CHANGED
|
@@ -89,7 +89,7 @@ export function templatedCredential(text, finding) {
|
|
|
89
89
|
// Only variable references and the documented gh lookup are exempt. Arbitrary
|
|
90
90
|
// shell commands and Actions expressions can contain literal credentials.
|
|
91
91
|
export function referencePrefix(value) {
|
|
92
|
-
return /^(?:(?:token|Bearer|Basic)\s+)?(?:\$\{\{\s*(?:secrets|env|vars|github|inputs)\.[A-Za-z_][A-Za-z0-9_.]*\s*\}\}|\$\(gh\s+auth\s+token(?:\s+--hostname\s+[a-z0-9.-]+)?\))(?=$|[\s"',;\\])/i.exec(
|
|
92
|
+
return /^(?:(?:token|Bearer|Basic)\s+)?(?:\$\{[A-Za-z_][A-Za-z0-9_]*\}|\$[A-Za-z_][A-Za-z0-9_]*|\$\{\{\s*(?:secrets|env|vars|github|inputs)\.[A-Za-z_][A-Za-z0-9_.]*\s*\}\}|\$\(gh\s+auth\s+token(?:\s+--hostname\s+[a-z0-9.-]+)?\))(?=$|[\s"',;\\])/i.exec(
|
|
93
93
|
value
|
|
94
94
|
);
|
|
95
95
|
}
|