@link-foundation/sensitive-data-sanitizer 0.20.0 → 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 +6 -0
- package/README.md +14 -1
- package/bin/sensitive-data-sanitizer.js +1 -0
- package/docs/API.md +163 -15
- package/docs/COVERAGE.md +16 -6
- package/docs/RULES.md +1 -0
- package/examples/second-pass-placeholders.mjs +28 -0
- package/package.json +12 -3
- package/src/context.js +176 -25
- 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 +8 -1
- package/src/detection.js +68 -37
- package/src/encoded.js +137 -51
- package/src/engines.js +33 -10
- package/src/exempt.js +72 -0
- package/src/fake-memory.js +32 -0
- package/src/fake.js +4 -28
- package/src/features.js +29 -0
- package/src/html-tags.js +8 -0
- package/src/identity-types.js +63 -0
- package/src/identity.js +3 -61
- package/src/index.d.ts +41 -2
- package/src/index.js +6 -0
- package/src/known.js +33 -15
- package/src/personal-features.js +39 -0
- package/src/personal.js +29 -5
- package/src/placeholder-types.js +154 -4
- package/src/placeholders.js +127 -45
- package/src/projection.js +1 -8
- 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 +2 -0
- package/src/rules.js +8 -0
- package/src/sanitizer.js +85 -20
- 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/transforms.js +38 -8
- package/src/vocabulary.js +27 -1
- package/src/worker-options.js +1 -0
package/src/detection.js
CHANGED
|
@@ -5,26 +5,21 @@ import {
|
|
|
5
5
|
uatpCard,
|
|
6
6
|
} from './confidence.js';
|
|
7
7
|
import { isIP } from 'node:net';
|
|
8
|
+
import { Buffer } from 'node:buffer';
|
|
8
9
|
import { serviceRules } from './rules.js';
|
|
9
|
-
import { detectContext, valueRange,
|
|
10
|
-
import { detectPersonal } from './personal.js';
|
|
10
|
+
import { detectContext, valueRange, authorizationRanges } from './context.js';
|
|
11
11
|
import { detectGitleaks } from './gitleaks.js';
|
|
12
|
-
import { detectCatalog } from './entities.js';
|
|
13
|
-
import { detectIdentity } from './identity.js';
|
|
14
|
-
import { detectNational } from './national.js';
|
|
15
|
-
import { detectPersonalContext } from './personal-context.js';
|
|
16
|
-
import { detectPersonalEvidence } from './personal-evidence.js';
|
|
17
12
|
import {
|
|
18
13
|
detectCredentialFormats,
|
|
19
14
|
credentialPlaceholder,
|
|
15
|
+
maskedValue,
|
|
20
16
|
} from './credentials.js';
|
|
21
17
|
import { localAddress } from './public.js';
|
|
22
18
|
import { detectKnownPersonal } from './personal-variants.js';
|
|
23
|
-
import { detectDocumentEntities } from './document-entities.js';
|
|
24
|
-
import { detectProseAndDocuments } from './proper-names.js';
|
|
25
19
|
import { builtInEnabled } from './policy.js';
|
|
26
|
-
import {
|
|
20
|
+
import { feature } from './features.js';
|
|
27
21
|
import { tokenChecksum } from './token-checksum.js';
|
|
22
|
+
import { refineCredentialSpans, cookieValues } from './credential-spans.js';
|
|
28
23
|
|
|
29
24
|
export const REDACTED = '[REDACTED]';
|
|
30
25
|
export const failure = (code = 'ERR_SANITIZATION', location = {}) =>
|
|
@@ -56,6 +51,30 @@ export function personalPattern(value) {
|
|
|
56
51
|
.map(escapePattern)
|
|
57
52
|
.join('[ \\t_-]+');
|
|
58
53
|
}
|
|
54
|
+
// Hex and base64 spellings of a known secret, at any length. The generic
|
|
55
|
+
// decoders need 16 or more encoded characters, so they cannot see short ones.
|
|
56
|
+
const encodedForms = new Map();
|
|
57
|
+
function knownEncodedForms(value) {
|
|
58
|
+
let pattern = encodedForms.get(value);
|
|
59
|
+
if (!pattern) {
|
|
60
|
+
const bytes = Buffer.from(value, 'utf8');
|
|
61
|
+
const hex = bytes.toString('hex');
|
|
62
|
+
const base64 = bytes.toString('base64').replace(/=+$/, '');
|
|
63
|
+
const url = bytes.toString('base64url');
|
|
64
|
+
const alternatives = [...new Set([hex, hex.toUpperCase()])].join('|');
|
|
65
|
+
const base64Forms = [...new Set([base64, url])].map(escapePattern);
|
|
66
|
+
pattern = new RegExp(
|
|
67
|
+
`(?<![\\da-fA-F])(?:${alternatives})(?![\\da-fA-F])|(?<![\\w+/-])(?:${base64Forms.join('|')})(?:={0,2}(?![\\w+/=-]))`,
|
|
68
|
+
'g'
|
|
69
|
+
);
|
|
70
|
+
if (encodedForms.size > 1024) {
|
|
71
|
+
encodedForms.clear();
|
|
72
|
+
}
|
|
73
|
+
encodedForms.set(value, pattern);
|
|
74
|
+
}
|
|
75
|
+
return pattern;
|
|
76
|
+
}
|
|
77
|
+
|
|
59
78
|
export function escapePattern(text) {
|
|
60
79
|
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
61
80
|
}
|
|
@@ -130,34 +149,42 @@ export function luhn(value) {
|
|
|
130
149
|
function detectCookies(text, emit) {
|
|
131
150
|
for (const match of text.matchAll(/(?:Set-)?Cookie[ \t]*:[ \t]*/gi)) {
|
|
132
151
|
const range = valueRange(text, match.index + match[0].length, 'line');
|
|
133
|
-
|
|
134
|
-
range
|
|
135
|
-
|
|
136
|
-
text.slice(
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
152
|
+
for (const value of range
|
|
153
|
+
? cookieValues(text, range.start, range.end)
|
|
154
|
+
: []) {
|
|
155
|
+
if (!maskedValue(text.slice(value.start, value.end))) {
|
|
156
|
+
emit({
|
|
157
|
+
...value,
|
|
158
|
+
type: 'SECRET',
|
|
159
|
+
category: 'credential',
|
|
160
|
+
rule: 'cookie',
|
|
161
|
+
});
|
|
162
|
+
}
|
|
144
163
|
}
|
|
145
164
|
}
|
|
146
165
|
}
|
|
147
166
|
|
|
148
167
|
export function nativeDetect(text, options, emit) {
|
|
149
|
-
const
|
|
150
|
-
|
|
168
|
+
const found = [];
|
|
169
|
+
detectNative(text, options, (finding) => found.push(finding));
|
|
170
|
+
for (const finding of refineCredentialSpans(text, found)) {
|
|
151
171
|
if (builtInEnabled(finding, options)) {
|
|
152
|
-
|
|
172
|
+
emit(finding);
|
|
153
173
|
}
|
|
154
|
-
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function detectNative(text, options, emit) {
|
|
155
178
|
const credential = { type: 'SECRET', category: 'credential' };
|
|
156
179
|
for (const value of options.knownSecrets ?? []) {
|
|
157
180
|
collectMatches(text, new RegExp(escapePattern(value), 'g'), emit, {
|
|
158
181
|
...credential,
|
|
159
182
|
rule: 'known-secret',
|
|
160
183
|
});
|
|
184
|
+
collectMatches(text, knownEncodedForms(value), emit, {
|
|
185
|
+
...credential,
|
|
186
|
+
rule: 'known-secret',
|
|
187
|
+
});
|
|
161
188
|
}
|
|
162
189
|
detectKnownPersonal(
|
|
163
190
|
text,
|
|
@@ -194,14 +221,7 @@ export function nativeDetect(text, options, emit) {
|
|
|
194
221
|
}
|
|
195
222
|
detectContext(text, emit, options.credentialsOnly);
|
|
196
223
|
if (!options.credentialsOnly) {
|
|
197
|
-
|
|
198
|
-
detectProseAndDocuments(text, emit, options, detectDocumentEntities);
|
|
199
|
-
detectCatalog(text, emit);
|
|
200
|
-
detectIdentity(text, emit);
|
|
201
|
-
detectNational(text, emit);
|
|
202
|
-
detectPersonalContext(text, emit);
|
|
203
|
-
detectPersonalEvidence(text, emit);
|
|
204
|
-
detectStrict(text, emit, options);
|
|
224
|
+
feature('detectPersonalData')(text, emit, options);
|
|
205
225
|
}
|
|
206
226
|
detectCredentialFormats(text, emit);
|
|
207
227
|
// detect-secrets-style quoted entropy detection; bare commit hashes and
|
|
@@ -262,9 +282,11 @@ function detectAuthorization(text, emit, credential) {
|
|
|
262
282
|
for (const match of text.matchAll(
|
|
263
283
|
/(?:Proxy-)?Authorization[ \t]*:[ \t]*/gi
|
|
264
284
|
)) {
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
285
|
+
const start = match.index + match[0].length;
|
|
286
|
+
for (const range of authorizationRanges(text, start)) {
|
|
287
|
+
if (!credentialPlaceholder(text.slice(range.start, range.end))) {
|
|
288
|
+
emit({ ...range, ...credential, rule: 'authorization' });
|
|
289
|
+
}
|
|
268
290
|
}
|
|
269
291
|
}
|
|
270
292
|
}
|
|
@@ -291,7 +313,16 @@ function detectPersonalFormats(text, options, emit) {
|
|
|
291
313
|
collectMatches(
|
|
292
314
|
text,
|
|
293
315
|
/(?<![\p{L}\p{N}_.!#$%&'*+/?^`{|}~-])[\p{L}\p{N}_.!#$%&'*+/?^`{|}~-]+@[\p{L}\p{N}](?:[\p{L}\p{N}.-]{0,252})\.[\p{L}]{2,63}(?![\p{L}\p{N}_-])/gu,
|
|
294
|
-
(finding) =>
|
|
316
|
+
(finding) => {
|
|
317
|
+
// `https://user:password@host` is URL user info, not an address.
|
|
318
|
+
if (
|
|
319
|
+
!/[a-z][a-z0-9+.-]*:\/\/[^\s/@]*:$/i.test(
|
|
320
|
+
text.slice(Math.max(0, finding.start - 256), finding.start)
|
|
321
|
+
)
|
|
322
|
+
) {
|
|
323
|
+
emit(emailAfterMention(text, finding));
|
|
324
|
+
}
|
|
325
|
+
},
|
|
295
326
|
{ type: 'EMAIL', category: 'personal', rule: 'email' }
|
|
296
327
|
);
|
|
297
328
|
collectMatches(
|
package/src/encoded.js
CHANGED
|
@@ -1,68 +1,101 @@
|
|
|
1
1
|
import { TextDecoder } from 'node:util';
|
|
2
2
|
import { Buffer } from 'node:buffer';
|
|
3
|
-
import { failure } from './detection.js';
|
|
4
3
|
import { jsonStringRuns } from './json-string-runs.js';
|
|
5
4
|
|
|
5
|
+
// Decoded candidates are re-scanned by every detector, so their number is
|
|
6
|
+
// bounded per window of the input; a denser window falls back to masking the
|
|
7
|
+
// remaining candidates as `encoded-limit` runs, without blocking the text.
|
|
8
|
+
const WINDOW = 65536;
|
|
9
|
+
const WINDOW_CANDIDATES = 4096;
|
|
10
|
+
|
|
11
|
+
const WRAPPED =
|
|
12
|
+
/(?<![A-Za-z0-9_+/-])[A-Za-z0-9_+/-]{16,128}(?:\r?\n[A-Za-z0-9_+/-]{1,128})+={0,2}(?![A-Za-z0-9_+/-])/g;
|
|
13
|
+
|
|
6
14
|
export function* decodedRuns(text, depth = 0) {
|
|
7
15
|
if (depth >= 2) {
|
|
8
16
|
return;
|
|
9
17
|
}
|
|
18
|
+
// Wrapped runs come first: a line inside a decoded wrapped run is covered
|
|
19
|
+
// by the run's own finding and is not decoded again.
|
|
10
20
|
const patterns = [
|
|
21
|
+
WRAPPED,
|
|
11
22
|
/(?<![A-Za-z0-9_+/-])[A-Za-z0-9_+/-]{16}[A-Za-z0-9_+/-]*={0,2}(?![A-Za-z0-9_+/-])/g,
|
|
12
23
|
/(?:[^\s"'<>?=&:/]{0,128}%[\da-fA-F]{2})+(?:[^\s"'<>?=&:/]{0,128})/g,
|
|
13
|
-
/(?<![A-Za-z0-9_+/-])[A-Za-z0-9_+/-]{16,128}(?:\r?\n[A-Za-z0-9_+/-]{1,128})+={0,2}(?![A-Za-z0-9_+/-])/g,
|
|
14
24
|
/(?:&#(?:x[\da-fA-F]{1,6}|\d{1,7});|&(?:amp|lt|gt|quot|apos|nbsp);){2,2048}/g,
|
|
15
25
|
/(?:\\x[\da-fA-F]{2}){2,2048}/g,
|
|
16
26
|
undefined, // JSON candidates use a linear scanner below.
|
|
17
27
|
];
|
|
18
|
-
const
|
|
19
|
-
let count = 0;
|
|
28
|
+
const scan = { depth, seen: new Set(), counts: new Map(), covered: [] };
|
|
20
29
|
for (const [index, pattern] of patterns.entries()) {
|
|
21
|
-
const marker = ['', '
|
|
30
|
+
const marker = ['\n', '', '%', '&', '\\x', '"'][index];
|
|
22
31
|
if (marker && !text.includes(marker)) {
|
|
23
32
|
continue;
|
|
24
33
|
}
|
|
34
|
+
const inWrappedRun = coverage(scan.covered);
|
|
25
35
|
for (const match of candidateMatches(text, pattern)) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (isLimitedTextRun(raw)) {
|
|
29
|
-
yield {
|
|
30
|
-
start: match.index,
|
|
31
|
-
end: match.index + raw.length,
|
|
32
|
-
limited: true,
|
|
33
|
-
depth,
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
continue;
|
|
37
|
-
}
|
|
38
|
-
const candidates = decodeCandidates(raw);
|
|
39
|
-
for (const candidate of candidates) {
|
|
40
|
-
const { text: decoded, encoding } = candidate;
|
|
41
|
-
const key = `${match.index}:${raw.length}:${decoded}`;
|
|
42
|
-
if (seen.has(key) || !decodedText(raw, decoded)) {
|
|
43
|
-
continue;
|
|
44
|
-
}
|
|
45
|
-
seen.add(key);
|
|
46
|
-
if (++count > 4096) {
|
|
47
|
-
throw failure('ERR_LIMIT');
|
|
48
|
-
}
|
|
49
|
-
const inset = encoding === 'json-content' ? 1 : 0;
|
|
50
|
-
const run = {
|
|
51
|
-
start: match.index + inset,
|
|
52
|
-
end: match.index + raw.length - inset,
|
|
53
|
-
text: decoded,
|
|
54
|
-
encoding,
|
|
55
|
-
depth,
|
|
56
|
-
};
|
|
57
|
-
yield run;
|
|
58
|
-
for (const inner of decodedRuns(decoded, depth + 1)) {
|
|
59
|
-
yield { ...run, text: inner.text, depth: inner.depth };
|
|
60
|
-
}
|
|
36
|
+
if (index !== 1 || !inWrappedRun(match.index)) {
|
|
37
|
+
yield* matchRuns(match, index === 0, scan);
|
|
61
38
|
}
|
|
62
39
|
}
|
|
63
40
|
}
|
|
64
41
|
}
|
|
65
42
|
|
|
43
|
+
// Tests ascending offsets against the sorted, disjoint wrapped runs.
|
|
44
|
+
function coverage(covered) {
|
|
45
|
+
let cursor = 0;
|
|
46
|
+
return (start) => {
|
|
47
|
+
while (cursor < covered.length && covered[cursor].end <= start) {
|
|
48
|
+
cursor++;
|
|
49
|
+
}
|
|
50
|
+
return cursor < covered.length && covered[cursor].start <= start;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function* matchRuns(match, wrapped, scan) {
|
|
55
|
+
const raw = match[0];
|
|
56
|
+
const start = match.index;
|
|
57
|
+
const end = start + raw.length;
|
|
58
|
+
const limited = { start, end, limited: true, depth: scan.depth };
|
|
59
|
+
if (raw.length > 8192) {
|
|
60
|
+
if (isLimitedTextRun(raw, wrapped)) {
|
|
61
|
+
scan.covered.push(limited);
|
|
62
|
+
yield limited;
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const window = Math.floor(start / WINDOW);
|
|
67
|
+
for (const candidate of decodeCandidates(raw)) {
|
|
68
|
+
const { text: decoded, encoding } = candidate;
|
|
69
|
+
const key = `${start}:${raw.length}:${decoded}`;
|
|
70
|
+
if (scan.seen.has(key) || !decodedText(raw, decoded)) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
scan.seen.add(key);
|
|
74
|
+
const count = (scan.counts.get(window) ?? 0) + 1;
|
|
75
|
+
scan.counts.set(window, count);
|
|
76
|
+
if (count > WINDOW_CANDIDATES) {
|
|
77
|
+
yield limited;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (wrapped && scan.covered.at(-1)?.start !== start) {
|
|
81
|
+
scan.covered.push({ start, end });
|
|
82
|
+
}
|
|
83
|
+
const inset = encoding === 'json-content' ? 1 : 0;
|
|
84
|
+
const run = {
|
|
85
|
+
start: start + inset,
|
|
86
|
+
end: end - inset,
|
|
87
|
+
text: decoded,
|
|
88
|
+
encoding,
|
|
89
|
+
depth: scan.depth,
|
|
90
|
+
...(candidate.layout && { layout: candidate.layout }),
|
|
91
|
+
};
|
|
92
|
+
yield run;
|
|
93
|
+
for (const inner of decodedRuns(decoded, scan.depth + 1)) {
|
|
94
|
+
yield { ...run, text: inner.text, depth: inner.depth };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
66
99
|
function candidateMatches(text, pattern) {
|
|
67
100
|
return pattern ? text.matchAll(pattern) : jsonStringRuns(text);
|
|
68
101
|
}
|
|
@@ -76,20 +109,59 @@ function decodedText(raw, decoded) {
|
|
|
76
109
|
);
|
|
77
110
|
}
|
|
78
111
|
|
|
79
|
-
function isLimitedTextRun(raw) {
|
|
112
|
+
function isLimitedTextRun(raw, wrapped) {
|
|
80
113
|
// Bare opaque identifiers may share the base64 alphabet. Require a
|
|
81
|
-
// textual decode,
|
|
82
|
-
|
|
114
|
+
// textual decode, an explicit percent encoding, or the uniform layout of
|
|
115
|
+
// wrapped base64 (`base64 -w`, PEM bodies). Lists of identifiers or commit
|
|
116
|
+
// hashes are ordinary lines and are scanned one by one.
|
|
117
|
+
if (raw.includes('%')) {
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
const head = wrapped
|
|
121
|
+
? raw.slice(0, 8192).replace(/\s/g, '').slice(0, 4096)
|
|
122
|
+
: raw.slice(0, 4096);
|
|
123
|
+
const probe = decodeCandidates(head, true);
|
|
83
124
|
return (
|
|
84
|
-
raw.includes('%') ||
|
|
85
|
-
raw.includes('\n') ||
|
|
86
125
|
probe.some(
|
|
87
126
|
// eslint-disable-next-line no-control-regex -- Exclude binary controls from the bounded UTF-8 text probe.
|
|
88
127
|
(c) => c.text && !/[\x00-\x08\x0e-\x1f\x7f]/.test(c.text)
|
|
128
|
+
) ||
|
|
129
|
+
(wrapped && isWrappedBinary(raw))
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Wrapped binary base64 uses the standard alphabet, lines of one width that
|
|
134
|
+
// is a multiple of four and is not plain hexadecimal.
|
|
135
|
+
function isWrappedBinary(raw) {
|
|
136
|
+
// Trim the padding without a regular expression: `/=+$/` backtracks
|
|
137
|
+
// quadratically over long `=` runs that are not at the end.
|
|
138
|
+
let end = raw.length;
|
|
139
|
+
while (end > 0 && raw.charCodeAt(end - 1) === 61) {
|
|
140
|
+
end -= 1;
|
|
141
|
+
}
|
|
142
|
+
const lines = raw.slice(0, end).split(/\r?\n/);
|
|
143
|
+
const width = lines[0].length;
|
|
144
|
+
return (
|
|
145
|
+
width % 4 === 0 &&
|
|
146
|
+
!/[-_]/.test(raw) &&
|
|
147
|
+
!/^[\da-fA-F\s]+$/.test(raw) &&
|
|
148
|
+
lines.every((line, i) =>
|
|
149
|
+
i === lines.length - 1 ? line.length <= width : line.length === width
|
|
89
150
|
)
|
|
90
151
|
);
|
|
91
152
|
}
|
|
92
153
|
|
|
154
|
+
// Line width and separator of a wrapped run, re-applied when re-encoding.
|
|
155
|
+
function wrapLayout(raw) {
|
|
156
|
+
if (!raw.includes('\n')) {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
width: raw.split(/\r?\n/, 1)[0].length,
|
|
161
|
+
newline: raw.includes('\r\n') ? '\r\n' : '\n',
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
93
165
|
function decodeCandidates(raw, partial) {
|
|
94
166
|
const candidates = [];
|
|
95
167
|
if (raw.startsWith('"')) {
|
|
@@ -153,8 +225,7 @@ function decodeCandidates(raw, partial) {
|
|
|
153
225
|
text: new TextDecoder('utf-8', { fatal: true }).decode(bytes, {
|
|
154
226
|
stream: partial,
|
|
155
227
|
}),
|
|
156
|
-
|
|
157
|
-
padded: raw.endsWith('='),
|
|
228
|
+
layout: wrapLayout(raw),
|
|
158
229
|
});
|
|
159
230
|
} catch {
|
|
160
231
|
/* Binary content. */
|
|
@@ -167,6 +238,7 @@ function decodeCandidates(raw, partial) {
|
|
|
167
238
|
text: new TextDecoder('utf-8', { fatal: true }).decode(
|
|
168
239
|
Buffer.from(compact, 'hex')
|
|
169
240
|
),
|
|
241
|
+
layout: wrapLayout(raw),
|
|
170
242
|
});
|
|
171
243
|
} catch {
|
|
172
244
|
/* Binary content. */
|
|
@@ -198,16 +270,30 @@ export function encodeRun(run, text) {
|
|
|
198
270
|
: `&#${c.codePointAt(0)};`
|
|
199
271
|
).join('');
|
|
200
272
|
} else {
|
|
201
|
-
encoded = bytes.toString(run.encoding);
|
|
273
|
+
encoded = wrap(bytes.toString(run.encoding), run.layout);
|
|
202
274
|
}
|
|
203
275
|
const decoded =
|
|
204
276
|
run.encoding === 'json-content'
|
|
205
277
|
? { text: JSON.parse(`"${encoded}"`) }
|
|
206
278
|
: decodeCandidates(encoded).find((c) => c.encoding === run.encoding);
|
|
207
|
-
|
|
208
|
-
|
|
279
|
+
// A short payload may re-encode below the length a decoder recognizes
|
|
280
|
+
// (hex needs 32 digits); callers then mask the encoded text as it stands.
|
|
281
|
+
return decoded?.text === text ? encoded : undefined;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function wrap(encoded, layout) {
|
|
285
|
+
if (!layout?.width || encoded.length <= layout.width) {
|
|
286
|
+
return encoded;
|
|
287
|
+
}
|
|
288
|
+
const lines = [];
|
|
289
|
+
for (let i = 0; i < encoded.length; i += layout.width) {
|
|
290
|
+
lines.push(encoded.slice(i, i + layout.width));
|
|
291
|
+
}
|
|
292
|
+
// Padding never starts a line of its own.
|
|
293
|
+
if (/^=+$/.test(lines.at(-1)) && lines.length > 1) {
|
|
294
|
+
lines[lines.length - 2] += lines.pop();
|
|
209
295
|
}
|
|
210
|
-
return
|
|
296
|
+
return lines.join(layout.newline);
|
|
211
297
|
}
|
|
212
298
|
|
|
213
299
|
export function encodedValue(raw) {
|
package/src/engines.js
CHANGED
|
@@ -18,6 +18,7 @@ import { projectText } from './projection.js';
|
|
|
18
18
|
import { decodedRuns } from './encoded.js';
|
|
19
19
|
import { hasCustomTransforms } from './transforms.js';
|
|
20
20
|
import { builtInEnabled } from './policy.js';
|
|
21
|
+
import { exemptFindingsAsync } from './exempt.js';
|
|
21
22
|
import {
|
|
22
23
|
initializePlaceholders,
|
|
23
24
|
placeholderKnown,
|
|
@@ -220,10 +221,7 @@ async function analyzeStructured(
|
|
|
220
221
|
);
|
|
221
222
|
}
|
|
222
223
|
|
|
223
|
-
|
|
224
|
-
options = applyProfile(options);
|
|
225
|
-
validateOptions(options);
|
|
226
|
-
options = initializePlaceholders(options);
|
|
224
|
+
function configuredEngines(options) {
|
|
227
225
|
for (const detector of options.detectors ?? []) {
|
|
228
226
|
if (!detector || typeof detector.detect !== 'function') {
|
|
229
227
|
throw failure('ERR_CONFIG');
|
|
@@ -234,6 +232,14 @@ export function createSanitizer(options = {}) {
|
|
|
234
232
|
if (!options.knownOnly) {
|
|
235
233
|
engines.push(...(options.detectors ?? []));
|
|
236
234
|
}
|
|
235
|
+
return engines;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function createSanitizer(options = {}) {
|
|
239
|
+
options = applyProfile(options);
|
|
240
|
+
validateOptions(options);
|
|
241
|
+
options = initializePlaceholders(options);
|
|
242
|
+
const engines = configuredEngines(options);
|
|
237
243
|
let prepared;
|
|
238
244
|
async function analyze(
|
|
239
245
|
text,
|
|
@@ -266,6 +272,7 @@ export function createSanitizer(options = {}) {
|
|
|
266
272
|
...options,
|
|
267
273
|
structured: undefined,
|
|
268
274
|
detectors: [],
|
|
275
|
+
exempt: undefined,
|
|
269
276
|
findings,
|
|
270
277
|
})
|
|
271
278
|
: reviewFindings(text, [...nativeFindings, ...findings], options);
|
|
@@ -274,15 +281,31 @@ export function createSanitizer(options = {}) {
|
|
|
274
281
|
: detected;
|
|
275
282
|
}
|
|
276
283
|
|
|
284
|
+
// The caller's `exempt` hook screens the findings of the text it sees,
|
|
285
|
+
// including the verification passes over the published output.
|
|
286
|
+
async function screened(
|
|
287
|
+
text,
|
|
288
|
+
initial,
|
|
289
|
+
plain,
|
|
290
|
+
scopedOptions = options,
|
|
291
|
+
nativeFindings
|
|
292
|
+
) {
|
|
293
|
+
return exemptFindingsAsync(
|
|
294
|
+
text,
|
|
295
|
+
await analyze(text, initial, plain, scopedOptions, nativeFindings),
|
|
296
|
+
scopedOptions.exempt
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
277
300
|
const sanitizer = {
|
|
278
|
-
inspect: guarded(
|
|
301
|
+
inspect: guarded(screened),
|
|
279
302
|
sanitize: guarded(sanitizeText, () => discardPlaceholderState(options)),
|
|
280
303
|
sanitizeJsonl: (text) =>
|
|
281
304
|
createSanitizer({ ...options, structured: 'jsonl' }).sanitize(text),
|
|
282
305
|
};
|
|
283
306
|
configurations.set(sanitizer, options);
|
|
284
307
|
streamPreparers.set(sanitizer, async (text, nativeFindings) => {
|
|
285
|
-
const findings = await
|
|
308
|
+
const findings = await screened(
|
|
286
309
|
text,
|
|
287
310
|
nativeFindings === undefined ? (options.findings ?? []) : [],
|
|
288
311
|
false,
|
|
@@ -305,7 +328,7 @@ export function createSanitizer(options = {}) {
|
|
|
305
328
|
const findings =
|
|
306
329
|
depth === 0 && prepared?.text === text
|
|
307
330
|
? prepared.findings
|
|
308
|
-
: await
|
|
331
|
+
: await screened(text, options.findings ?? [], plain, options);
|
|
309
332
|
if (depth === 0) {
|
|
310
333
|
prepared = undefined;
|
|
311
334
|
}
|
|
@@ -329,7 +352,7 @@ export function createSanitizer(options = {}) {
|
|
|
329
352
|
recursiveOptions
|
|
330
353
|
)
|
|
331
354
|
);
|
|
332
|
-
if (await customResidual(result.text, options, plain,
|
|
355
|
+
if (await customResidual(result.text, options, plain, screened)) {
|
|
333
356
|
throw failure('ERR_RESIDUAL');
|
|
334
357
|
}
|
|
335
358
|
const sanitized = fullRedaction
|
|
@@ -351,7 +374,7 @@ export function createSanitizer(options = {}) {
|
|
|
351
374
|
).text;
|
|
352
375
|
// Verification at the publication boundary runs every enabled engine.
|
|
353
376
|
if (
|
|
354
|
-
(await
|
|
377
|
+
(await screened(sanitized, [], plain, options)).some((f) =>
|
|
355
378
|
actionable(f, options)
|
|
356
379
|
)
|
|
357
380
|
) {
|
|
@@ -360,7 +383,7 @@ export function createSanitizer(options = {}) {
|
|
|
360
383
|
);
|
|
361
384
|
}
|
|
362
385
|
verifyStructuredResult(result.text, options, plain);
|
|
363
|
-
if (await placeholderResidual(result.text, options, plain,
|
|
386
|
+
if (await placeholderResidual(result.text, options, plain, screened)) {
|
|
364
387
|
throw failure('ERR_RESIDUAL');
|
|
365
388
|
}
|
|
366
389
|
return { ...result, findings: auditFaked(findings, options, result) };
|
package/src/exempt.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { failure } from './detection.js';
|
|
2
|
+
|
|
3
|
+
// `exempt(finding, text) => boolean` keeps findings the caller's policy
|
|
4
|
+
// knows are not sensitive (a configuration flag, a counter, a value another
|
|
5
|
+
// tool already masked). It runs after detection and before transformation.
|
|
6
|
+
// The hook decides per span: verification of the published text consults it
|
|
7
|
+
// again for each finding in the output, so the same value in another place
|
|
8
|
+
// is still caught unless the hook keeps that span too.
|
|
9
|
+
// Values the caller declared (`knownSecrets`, `knownPersonal`,
|
|
10
|
+
// `confirmedPersonal`) and structured `JSON_VALUE` denials are never offered.
|
|
11
|
+
const declared = new Set([
|
|
12
|
+
'known-secret',
|
|
13
|
+
'known-personal',
|
|
14
|
+
'confirmed-personal',
|
|
15
|
+
]);
|
|
16
|
+
const offered = (finding) =>
|
|
17
|
+
!declared.has(finding.rule) && finding.type !== 'JSON_VALUE';
|
|
18
|
+
|
|
19
|
+
function view(text, finding) {
|
|
20
|
+
return Object.freeze({
|
|
21
|
+
...finding,
|
|
22
|
+
value: text.slice(finding.start, finding.end),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function validateExempt(exempt) {
|
|
27
|
+
if (exempt !== undefined && typeof exempt !== 'function') {
|
|
28
|
+
throw failure('ERR_CONFIG');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function decision(kept) {
|
|
33
|
+
if (typeof kept !== 'boolean') {
|
|
34
|
+
// `undefined` from a forgotten return must not silently keep a secret.
|
|
35
|
+
throw failure('ERR_CONFIG');
|
|
36
|
+
}
|
|
37
|
+
return kept;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function exemptFindings(text, findings, exempt) {
|
|
41
|
+
if (!exempt) {
|
|
42
|
+
return findings;
|
|
43
|
+
}
|
|
44
|
+
return findings.filter((finding) => {
|
|
45
|
+
if (!offered(finding)) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
const kept = exempt(view(text, finding), text);
|
|
49
|
+
if (typeof kept?.then === 'function') {
|
|
50
|
+
// An async hook needs the async engine (`createSanitizer`).
|
|
51
|
+
kept.then(undefined, () => {});
|
|
52
|
+
throw failure('ERR_CONFIG');
|
|
53
|
+
}
|
|
54
|
+
return !decision(kept);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function exemptFindingsAsync(text, findings, exempt) {
|
|
59
|
+
if (!exempt) {
|
|
60
|
+
return findings;
|
|
61
|
+
}
|
|
62
|
+
const retained = [];
|
|
63
|
+
for (const finding of findings) {
|
|
64
|
+
if (
|
|
65
|
+
!offered(finding) ||
|
|
66
|
+
!decision(await exempt(view(text, finding), text))
|
|
67
|
+
) {
|
|
68
|
+
retained.push(finding);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return retained;
|
|
72
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { failure } from './detection.js';
|
|
2
|
+
|
|
3
|
+
// Values that `mode: 'fake'` generated, per transformation object, so that a
|
|
4
|
+
// later pass recognizes them as fictional. Kept apart from fake.js so that the
|
|
5
|
+
// credentials entry can read the (empty) memory without loading faker.
|
|
6
|
+
const generated = new WeakMap();
|
|
7
|
+
export function generatedValues(options) {
|
|
8
|
+
const transforms = [
|
|
9
|
+
options.transformation,
|
|
10
|
+
...Object.values(options.transformations ?? {}),
|
|
11
|
+
];
|
|
12
|
+
return transforms.flatMap((t) =>
|
|
13
|
+
t?.mode === 'fake'
|
|
14
|
+
? [...(generated.get(t) ?? [])].map(([value, credential]) => ({
|
|
15
|
+
value,
|
|
16
|
+
credential,
|
|
17
|
+
}))
|
|
18
|
+
: []
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
export function remember(value, transform, credential) {
|
|
22
|
+
let values = generated.get(transform);
|
|
23
|
+
if (!values) {
|
|
24
|
+
values = new Map();
|
|
25
|
+
generated.set(transform, values);
|
|
26
|
+
}
|
|
27
|
+
if (values.size >= 100000 && !values.has(value)) {
|
|
28
|
+
throw failure('ERR_LIMIT');
|
|
29
|
+
}
|
|
30
|
+
values.set(value, credential);
|
|
31
|
+
return value;
|
|
32
|
+
}
|
package/src/fake.js
CHANGED
|
@@ -4,35 +4,11 @@ import { parsePhoneNumberFromString } from 'libphonenumber-js/max';
|
|
|
4
4
|
import { mrzCheck } from './identity.js';
|
|
5
5
|
import { fakeName } from './fake-names.js';
|
|
6
6
|
import * as checks from './national-checks.js';
|
|
7
|
-
import { REDACTED
|
|
7
|
+
import { REDACTED } from './detection.js';
|
|
8
|
+
import { remember } from './fake-memory.js';
|
|
9
|
+
|
|
10
|
+
export { generatedValues } from './fake-memory.js';
|
|
8
11
|
|
|
9
|
-
const generated = new WeakMap();
|
|
10
|
-
export function generatedValues(options) {
|
|
11
|
-
const transforms = [
|
|
12
|
-
options.transformation,
|
|
13
|
-
...Object.values(options.transformations ?? {}),
|
|
14
|
-
];
|
|
15
|
-
return transforms.flatMap((t) =>
|
|
16
|
-
t?.mode === 'fake'
|
|
17
|
-
? [...(generated.get(t) ?? [])].map(([value, credential]) => ({
|
|
18
|
-
value,
|
|
19
|
-
credential,
|
|
20
|
-
}))
|
|
21
|
-
: []
|
|
22
|
-
);
|
|
23
|
-
}
|
|
24
|
-
function remember(value, transform, credential) {
|
|
25
|
-
let values = generated.get(transform);
|
|
26
|
-
if (!values) {
|
|
27
|
-
values = new Map();
|
|
28
|
-
generated.set(transform, values);
|
|
29
|
-
}
|
|
30
|
-
if (values.size >= 100000 && !values.has(value)) {
|
|
31
|
-
throw failure('ERR_LIMIT');
|
|
32
|
-
}
|
|
33
|
-
values.set(value, credential);
|
|
34
|
-
return value;
|
|
35
|
-
}
|
|
36
12
|
function digest(value, key, purpose = 'value') {
|
|
37
13
|
return createHmac('sha256', key).update(`${purpose}\0${value}`).digest();
|
|
38
14
|
}
|