@link-foundation/sensitive-data-sanitizer 0.0.0-stage → 0.11.31
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 +530 -0
- package/LICENSE +24 -0
- package/README.md +184 -2
- package/bin/legacy-example-package-name.js +95 -0
- package/bin/sensitive-data-sanitizer.js +430 -0
- package/bin/stream.js +56 -0
- package/docs/API.md +336 -0
- package/docs/COVERAGE.md +136 -0
- package/docs/HISTORY-REMEDIATION.md +41 -0
- package/examples/combined-scanners.mjs +23 -0
- package/examples/presidio-bridge.py +32 -0
- package/examples/realistic-fakes.mjs +18 -0
- package/examples/sanitize-session.mjs +27 -0
- package/examples/shared-tool-logs.mjs +21 -0
- package/examples/stream-jsonl.mjs +17 -0
- package/package.json +124 -4
- package/src/adapters.js +204 -0
- package/src/arithmetic.js +11 -0
- package/src/confidence.js +65 -0
- package/src/context.js +424 -0
- package/src/credentials.js +54 -0
- package/src/detection.js +293 -0
- package/src/encoded.js +233 -0
- package/src/engines.js +348 -0
- package/src/entities.js +166 -0
- package/src/escape-spans.js +20 -0
- package/src/eslint-plugin.d.ts +10 -0
- package/src/eslint-plugin.js +193 -0
- package/src/external.js +169 -0
- package/src/fake-names.js +222 -0
- package/src/fake.js +524 -0
- package/src/files.js +131 -0
- package/src/gitleaks.js +194 -0
- package/src/history-rewrite.js +339 -0
- package/src/history.js +103 -0
- package/src/identity.js +274 -0
- package/src/index.d.ts +321 -0
- package/src/index.js +40 -0
- package/src/jsonl-stream.js +198 -0
- package/src/keywords.js +45 -0
- package/src/known.js +164 -0
- package/src/legacy.d.ts +3 -0
- package/src/legacy.js +2 -0
- package/src/locales.js +217 -0
- package/src/log-bytes.js +85 -0
- package/src/names.js +65 -0
- package/src/national-checks.js +129 -0
- package/src/national.js +128 -0
- package/src/outbound.js +28 -0
- package/src/personal-context.js +85 -0
- package/src/personal.js +248 -0
- package/src/projection.js +126 -0
- package/src/propagation.js +163 -0
- package/src/public-verifier.js +77 -0
- package/src/public.js +153 -0
- package/src/record-worker.js +20 -0
- package/src/rules.js +210 -0
- package/src/sanitizer.js +794 -0
- package/src/specimens.js +34 -0
- package/src/stream-identity.js +129 -0
- package/src/stream-worker.js +18 -0
- package/src/stream.js +449 -0
- package/src/structured.js +278 -0
- package/src/transforms.js +192 -0
- package/src/transliteration.js +75 -0
- package/src/utf8.js +60 -0
- package/src/vendor/entities/aws.json +42 -0
- package/src/vendor/entities/azure.json +182 -0
- package/src/vendor/entities/google.json +219 -0
- package/src/vendor/gitleaks/LICENSE +21 -0
- package/src/vendor/gitleaks/provenance.json +8 -0
- package/src/vendor/gitleaks/rules-0.json +268 -0
- package/src/vendor/gitleaks/rules-1.json +1794 -0
- package/src/vendor/gitleaks/rules-2.json +292 -0
- package/src/vendor/gitleaks/rules-3.json +263 -0
- package/src/vendor/gitleaks/rules-4.json +277 -0
- package/src/vendor/gitleaks/rules-5.json +154 -0
- package/src/worker-options.js +27 -0
package/src/detection.js
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { cardNetwork, cardEvidence } from './confidence.js';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
3
|
+
import { serviceRules } from './rules.js';
|
|
4
|
+
import { detectContext, valueRange } from './context.js';
|
|
5
|
+
import { detectPersonal } from './personal.js';
|
|
6
|
+
import { detectGitleaks } from './gitleaks.js';
|
|
7
|
+
import { detectCatalog } from './entities.js';
|
|
8
|
+
import { detectIdentity } from './identity.js';
|
|
9
|
+
import { detectNational } from './national.js';
|
|
10
|
+
import { detectPersonalContext } from './personal-context.js';
|
|
11
|
+
import {
|
|
12
|
+
detectCredentialFormats,
|
|
13
|
+
credentialPlaceholder,
|
|
14
|
+
} from './credentials.js';
|
|
15
|
+
import { localAddress } from './public.js';
|
|
16
|
+
|
|
17
|
+
export const REDACTED = '[REDACTED]';
|
|
18
|
+
export const failure = (code = 'ERR_SANITIZATION', location = {}) =>
|
|
19
|
+
Object.assign(new Error('Sanitization failed; output was blocked.'), {
|
|
20
|
+
code,
|
|
21
|
+
...Object.fromEntries(
|
|
22
|
+
['record', 'offset', 'recordOffset']
|
|
23
|
+
.filter(
|
|
24
|
+
(key) => Number.isSafeInteger(location[key]) && location[key] >= 0
|
|
25
|
+
)
|
|
26
|
+
.map((key) => [key, location[key]])
|
|
27
|
+
),
|
|
28
|
+
});
|
|
29
|
+
export function internalFailure(error) {
|
|
30
|
+
return /^ERR_[A-Z_]+$/.test(error?.code) ? error : failure();
|
|
31
|
+
}
|
|
32
|
+
export const maxPropagationLength = 256;
|
|
33
|
+
// Verification redacts values found again after a replacement, up to this many
|
|
34
|
+
// extra rounds, before publication fails closed with ERR_RESIDUAL.
|
|
35
|
+
export const residualRounds = 3;
|
|
36
|
+
export function personalPattern(value) {
|
|
37
|
+
if (value.length > maxPropagationLength) {
|
|
38
|
+
throw failure('ERR_LIMIT');
|
|
39
|
+
}
|
|
40
|
+
// Split before escaping: replacing hyphens in an escaped pattern can turn
|
|
41
|
+
// a preceding literal backslash into regexp syntax.
|
|
42
|
+
return value
|
|
43
|
+
.split(/[ _-]+/)
|
|
44
|
+
.map(escapePattern)
|
|
45
|
+
.join('[ \\t_-]+');
|
|
46
|
+
}
|
|
47
|
+
export function escapePattern(text) {
|
|
48
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function collectMatches(
|
|
52
|
+
text,
|
|
53
|
+
regex,
|
|
54
|
+
emit,
|
|
55
|
+
details,
|
|
56
|
+
group = 0,
|
|
57
|
+
accept = () => true
|
|
58
|
+
) {
|
|
59
|
+
// Clone expressions so concurrent callers never share lastIndex.
|
|
60
|
+
const pattern = new RegExp(
|
|
61
|
+
regex.source,
|
|
62
|
+
regex.flags.includes('g') ? regex.flags : `${regex.flags}g`
|
|
63
|
+
);
|
|
64
|
+
for (const match of text.matchAll(pattern)) {
|
|
65
|
+
const raw = match[group];
|
|
66
|
+
const value = details.category === 'personal' ? raw?.trim() : raw;
|
|
67
|
+
if (!value || value.trim() === REDACTED || !accept(value, match)) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const offset = group ? match[0].lastIndexOf(value) : 0;
|
|
71
|
+
emit({
|
|
72
|
+
start: match.index + offset,
|
|
73
|
+
end: match.index + offset + value.length,
|
|
74
|
+
...details,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function entropy(value) {
|
|
80
|
+
const counts = new Map();
|
|
81
|
+
for (const char of value) {
|
|
82
|
+
counts.set(char, (counts.get(char) ?? 0) + 1);
|
|
83
|
+
}
|
|
84
|
+
let result = 0;
|
|
85
|
+
for (const count of counts.values()) {
|
|
86
|
+
const p = count / value.length;
|
|
87
|
+
result -= p * Math.log2(p);
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function luhn(value) {
|
|
93
|
+
const digits = value.replace(/\D/g, '');
|
|
94
|
+
if (digits.length < 13 || digits.length > 19 || /^(\d)\1+$/.test(digits)) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
let total = 0;
|
|
98
|
+
for (
|
|
99
|
+
let i = digits.length - 1, double = false;
|
|
100
|
+
i >= 0;
|
|
101
|
+
i--, double = !double
|
|
102
|
+
) {
|
|
103
|
+
let n = Number(digits[i]);
|
|
104
|
+
if (double) {
|
|
105
|
+
n *= 2;
|
|
106
|
+
if (n > 9) {
|
|
107
|
+
n -= 9;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
total += n;
|
|
111
|
+
}
|
|
112
|
+
return total % 10 === 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function detectCookies(text, emit) {
|
|
116
|
+
for (const match of text.matchAll(/(?:Set-)?Cookie[ \t]*:[ \t]*/gi)) {
|
|
117
|
+
const range = valueRange(text, match.index + match[0].length, 'line');
|
|
118
|
+
if (
|
|
119
|
+
range &&
|
|
120
|
+
range.end > range.start &&
|
|
121
|
+
text.slice(range.start, range.end) !== REDACTED
|
|
122
|
+
) {
|
|
123
|
+
emit({
|
|
124
|
+
...range,
|
|
125
|
+
type: 'SECRET',
|
|
126
|
+
category: 'credential',
|
|
127
|
+
rule: 'cookie',
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function nativeDetect(text, options, emit) {
|
|
134
|
+
const credential = { type: 'SECRET', category: 'credential' };
|
|
135
|
+
detectGitleaks(text, emit);
|
|
136
|
+
for (const [rule, pattern] of serviceRules) {
|
|
137
|
+
collectMatches(text, pattern, emit, { ...credential, rule });
|
|
138
|
+
}
|
|
139
|
+
for (const value of options.knownSecrets ?? []) {
|
|
140
|
+
collectMatches(text, new RegExp(escapePattern(value), 'g'), emit, {
|
|
141
|
+
...credential,
|
|
142
|
+
rule: 'known-secret',
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
for (const entry of options.knownPersonal ?? []) {
|
|
146
|
+
collectMatches(text, new RegExp(escapePattern(entry.value), 'giu'), emit, {
|
|
147
|
+
type: entry.type,
|
|
148
|
+
category: 'personal',
|
|
149
|
+
rule: 'known-personal',
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
for (const entry of options.confirmedPersonal ?? []) {
|
|
153
|
+
const pattern = new RegExp(
|
|
154
|
+
`(?<![\\p{L}\\p{N}])${personalPattern(entry.value)}(?![\\p{L}\\p{N}])`,
|
|
155
|
+
'giu'
|
|
156
|
+
);
|
|
157
|
+
collectMatches(text, pattern, emit, {
|
|
158
|
+
type: entry.type,
|
|
159
|
+
category: 'personal',
|
|
160
|
+
rule: 'confirmed-personal',
|
|
161
|
+
confidence: 0.9,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
detectContext(text, emit, options.credentialsOnly);
|
|
165
|
+
if (!options.credentialsOnly) {
|
|
166
|
+
detectPersonal(text, emit);
|
|
167
|
+
detectCatalog(text, emit);
|
|
168
|
+
detectIdentity(text, emit);
|
|
169
|
+
detectNational(text, emit);
|
|
170
|
+
detectPersonalContext(text, emit);
|
|
171
|
+
}
|
|
172
|
+
detectCredentialFormats(text, emit);
|
|
173
|
+
// detect-secrets-style quoted entropy detection; bare commit hashes and
|
|
174
|
+
// common tool identifiers remain outside this heuristic's scope.
|
|
175
|
+
collectMatches(
|
|
176
|
+
text,
|
|
177
|
+
/["']([A-Za-z0-9+/=_-]{20,256})["']/g,
|
|
178
|
+
emit,
|
|
179
|
+
{ ...credential, rule: 'quoted-entropy' },
|
|
180
|
+
1,
|
|
181
|
+
(value) => entropy(value) >= (/^[a-f0-9]+$/i.test(value) ? 3 : 4.5)
|
|
182
|
+
);
|
|
183
|
+
collectMatches(
|
|
184
|
+
text,
|
|
185
|
+
/-----BEGIN ([A-Z0-9 ]*PRIVATE KEY)-----[\s\S]*?(?:-----END \1-----|$)/g,
|
|
186
|
+
emit,
|
|
187
|
+
{ ...credential, rule: 'private-key' }
|
|
188
|
+
);
|
|
189
|
+
collectMatches(
|
|
190
|
+
text,
|
|
191
|
+
/(?:Proxy-)?Authorization[ \t]*:[ \t]*(?:Bearer|Basic|Digest)[ \t]+([^\r\n"',]+)/gi,
|
|
192
|
+
emit,
|
|
193
|
+
{ ...credential, rule: 'authorization' },
|
|
194
|
+
1,
|
|
195
|
+
(value) => !credentialPlaceholder(value.trim())
|
|
196
|
+
);
|
|
197
|
+
detectCookies(text, emit);
|
|
198
|
+
collectMatches(
|
|
199
|
+
text,
|
|
200
|
+
/(?<![a-z0-9+.-])[a-z][a-z0-9+.-]*:\/\/([^\s/@]+)@/gi,
|
|
201
|
+
emit,
|
|
202
|
+
{ ...credential, rule: 'url-userinfo' },
|
|
203
|
+
1
|
|
204
|
+
);
|
|
205
|
+
collectMatches(
|
|
206
|
+
text,
|
|
207
|
+
/https:\/\/(?:hooks\.slack\.com\/services|(?:canary\.)?discord(?:app)?\.com\/api\/webhooks)\/([^\s"'<>]+)/gi,
|
|
208
|
+
emit,
|
|
209
|
+
{ ...credential, rule: 'webhook' },
|
|
210
|
+
1
|
|
211
|
+
);
|
|
212
|
+
if (options.credentialsOnly && !options.paranoid) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
detectPersonalFormats(text, options, emit);
|
|
216
|
+
if (options.paranoid) {
|
|
217
|
+
collectMatches(
|
|
218
|
+
text,
|
|
219
|
+
/(?<![A-Za-z0-9_+/-])[A-Za-z0-9_+/-]{20,256}={0,2}(?![A-Za-z0-9_+/-])/g,
|
|
220
|
+
emit,
|
|
221
|
+
{ ...credential, rule: 'entropy' },
|
|
222
|
+
0,
|
|
223
|
+
(v) =>
|
|
224
|
+
!(
|
|
225
|
+
/^[a-f0-9]{7,64}$/i.test(v) ||
|
|
226
|
+
/^mcp__|^browser_|^\[REDACTED\]$/.test(v)
|
|
227
|
+
) && entropy(v) >= 4.2
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function detectPersonalFormats(text, options, emit) {
|
|
233
|
+
collectMatches(
|
|
234
|
+
text,
|
|
235
|
+
/(?<![\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,
|
|
236
|
+
emit,
|
|
237
|
+
{ type: 'EMAIL', category: 'personal', rule: 'email' }
|
|
238
|
+
);
|
|
239
|
+
collectMatches(
|
|
240
|
+
text,
|
|
241
|
+
/(?<!\d)\+\d[\d ()-]{6,24}\d(?!\d)/g,
|
|
242
|
+
emit,
|
|
243
|
+
{ type: 'PHONE', category: 'personal', rule: 'international-phone' },
|
|
244
|
+
0,
|
|
245
|
+
(v) => {
|
|
246
|
+
const n = v.replace(/\D/g, '').length;
|
|
247
|
+
return n >= 8 && n <= 15;
|
|
248
|
+
}
|
|
249
|
+
);
|
|
250
|
+
collectMatches(
|
|
251
|
+
text,
|
|
252
|
+
/(?<!\d)(?:\d[ -]?){12,18}\d(?!\d)/g,
|
|
253
|
+
emit,
|
|
254
|
+
{ type: 'CREDIT_CARD', category: 'personal', rule: 'luhn' },
|
|
255
|
+
0,
|
|
256
|
+
(value, match) =>
|
|
257
|
+
luhn(value) &&
|
|
258
|
+
cardNetwork(value) &&
|
|
259
|
+
cardEvidence(text, match.index, value)
|
|
260
|
+
);
|
|
261
|
+
collectMatches(
|
|
262
|
+
text,
|
|
263
|
+
/\b\d{3}-\d{2}-\d{4}\b/g,
|
|
264
|
+
emit,
|
|
265
|
+
{ type: 'ID', category: 'personal', rule: 'us-ssn' },
|
|
266
|
+
0,
|
|
267
|
+
(v) => !/^(?:000|666|9\d\d)-|^\d{3}-00-|0000$/.test(v)
|
|
268
|
+
);
|
|
269
|
+
collectMatches(
|
|
270
|
+
text,
|
|
271
|
+
/(?<![\w.])(?:\d{1,3}\.){3}\d{1,3}(?![\w.])/g,
|
|
272
|
+
emit,
|
|
273
|
+
{ type: 'IP_ADDRESS', category: 'personal', rule: 'ipv4' },
|
|
274
|
+
0,
|
|
275
|
+
(v) =>
|
|
276
|
+
isIP(v) === 4 && (options.publicKnowledge === false || !localAddress(v))
|
|
277
|
+
);
|
|
278
|
+
collectMatches(
|
|
279
|
+
text,
|
|
280
|
+
/(?<![\w:])(?:[a-fA-F0-9]{0,4}:){2,7}[a-fA-F0-9]{0,4}(?![\w:])/g,
|
|
281
|
+
emit,
|
|
282
|
+
{ type: 'IP_ADDRESS', category: 'personal', rule: 'ipv6' },
|
|
283
|
+
0,
|
|
284
|
+
// Rust paths such as a::b and namespace prefixes are valid IPv6 syntax,
|
|
285
|
+
// but carry no useful address evidence in coding-session text.
|
|
286
|
+
(v) =>
|
|
287
|
+
isIP(v) === 6 &&
|
|
288
|
+
(options.publicKnowledge === false || !localAddress(v)) &&
|
|
289
|
+
v !== '::' &&
|
|
290
|
+
!/^[a-fA-F0-9]{0,4}::$/.test(v) &&
|
|
291
|
+
(v.split(':').filter(Boolean).length >= 3 || /\d/.test(v))
|
|
292
|
+
);
|
|
293
|
+
}
|
package/src/encoded.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { TextDecoder } from 'node:util';
|
|
2
|
+
import { Buffer } from 'node:buffer';
|
|
3
|
+
import { failure } from './detection.js';
|
|
4
|
+
|
|
5
|
+
export function* decodedRuns(text, depth = 0) {
|
|
6
|
+
if (depth >= 2) {
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
const patterns = [
|
|
10
|
+
/(?<![A-Za-z0-9_+/-])[A-Za-z0-9_+/-]{16}[A-Za-z0-9_+/-]*={0,2}(?![A-Za-z0-9_+/-])/g,
|
|
11
|
+
/(?:[^\s"'<>?=&:/]{0,128}%[\da-fA-F]{2})+(?:[^\s"'<>?=&:/]{0,128})/g,
|
|
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
|
+
/(?:&#(?:x[\da-fA-F]{1,6}|\d{1,7});|&(?:amp|lt|gt|quot|apos|nbsp);){2,2048}/g,
|
|
14
|
+
/(?:\\x[\da-fA-F]{2}){2,2048}/g,
|
|
15
|
+
/"(?:[^"\\\r\n]|\\[^\r\n]){1,8192}"/g,
|
|
16
|
+
];
|
|
17
|
+
const seen = new Set();
|
|
18
|
+
let count = 0;
|
|
19
|
+
for (const [index, pattern] of patterns.entries()) {
|
|
20
|
+
const marker = ['', '%', '\n', '&', '\\x', '"'][index];
|
|
21
|
+
if (marker && !text.includes(marker)) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
for (const match of text.matchAll(pattern)) {
|
|
25
|
+
const raw = match[0];
|
|
26
|
+
if (raw.length > 8192) {
|
|
27
|
+
if (isLimitedTextRun(raw)) {
|
|
28
|
+
yield {
|
|
29
|
+
start: match.index,
|
|
30
|
+
end: match.index + raw.length,
|
|
31
|
+
limited: true,
|
|
32
|
+
depth,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const candidates = decodeCandidates(raw);
|
|
38
|
+
for (const candidate of candidates) {
|
|
39
|
+
const { text: decoded, encoding } = candidate;
|
|
40
|
+
const key = `${match.index}:${raw.length}:${decoded}`;
|
|
41
|
+
if (seen.has(key) || !decodedText(raw, decoded)) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
seen.add(key);
|
|
45
|
+
if (++count > 4096) {
|
|
46
|
+
throw failure('ERR_LIMIT');
|
|
47
|
+
}
|
|
48
|
+
const inset = encoding === 'json-content' ? 1 : 0;
|
|
49
|
+
const run = {
|
|
50
|
+
start: match.index + inset,
|
|
51
|
+
end: match.index + raw.length - inset,
|
|
52
|
+
text: decoded,
|
|
53
|
+
encoding,
|
|
54
|
+
depth,
|
|
55
|
+
};
|
|
56
|
+
yield run;
|
|
57
|
+
for (const inner of decodedRuns(decoded, depth + 1)) {
|
|
58
|
+
yield { ...run, text: inner.text, depth: inner.depth };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function decodedText(raw, decoded) {
|
|
66
|
+
return (
|
|
67
|
+
decoded !== raw &&
|
|
68
|
+
Boolean(decoded) &&
|
|
69
|
+
// eslint-disable-next-line no-control-regex -- Binary controls cannot form text detection views.
|
|
70
|
+
!/[\x00-\x08\x0e-\x1f\x7f]/.test(decoded)
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isLimitedTextRun(raw) {
|
|
75
|
+
// Bare opaque identifiers may share the base64 alphabet. Require a
|
|
76
|
+
// textual decode, or an explicit percent/wrapped encoding boundary.
|
|
77
|
+
const probe = decodeCandidates(raw.slice(0, 4096), true);
|
|
78
|
+
return (
|
|
79
|
+
raw.includes('%') ||
|
|
80
|
+
raw.includes('\n') ||
|
|
81
|
+
probe.some(
|
|
82
|
+
// eslint-disable-next-line no-control-regex -- Exclude binary controls from the bounded UTF-8 text probe.
|
|
83
|
+
(c) => c.text && !/[\x00-\x08\x0e-\x1f\x7f]/.test(c.text)
|
|
84
|
+
)
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function decodeCandidates(raw, partial) {
|
|
89
|
+
const candidates = [];
|
|
90
|
+
if (raw.startsWith('"')) {
|
|
91
|
+
return decodeJson(raw);
|
|
92
|
+
}
|
|
93
|
+
if (raw.startsWith('&#') || /^&(?:amp|lt|gt|quot|apos|nbsp);/.test(raw)) {
|
|
94
|
+
candidates.push({
|
|
95
|
+
encoding: raw.startsWith('&#x') ? 'html-hex' : 'html-decimal',
|
|
96
|
+
text: raw.replace(
|
|
97
|
+
/&(?:#(x[\da-fA-F]+|\d+)|(amp|lt|gt|quot|apos|nbsp));/g,
|
|
98
|
+
(whole, code, named) => {
|
|
99
|
+
if (named) {
|
|
100
|
+
return {
|
|
101
|
+
amp: '&',
|
|
102
|
+
lt: '<',
|
|
103
|
+
gt: '>',
|
|
104
|
+
quot: '"',
|
|
105
|
+
apos: "'",
|
|
106
|
+
nbsp: ' ',
|
|
107
|
+
}[named];
|
|
108
|
+
}
|
|
109
|
+
const point =
|
|
110
|
+
code[0] === 'x' ? parseInt(code.slice(1), 16) : Number(code);
|
|
111
|
+
return point <= 0x10ffff && !(point >= 0xd800 && point <= 0xdfff)
|
|
112
|
+
? String.fromCodePoint(point)
|
|
113
|
+
: whole;
|
|
114
|
+
}
|
|
115
|
+
),
|
|
116
|
+
});
|
|
117
|
+
} else if (raw.startsWith('\\x')) {
|
|
118
|
+
try {
|
|
119
|
+
candidates.push({
|
|
120
|
+
encoding: 'byte-escape',
|
|
121
|
+
text: new TextDecoder('utf-8', { fatal: true }).decode(
|
|
122
|
+
Buffer.from(raw.replaceAll('\\x', ''), 'hex')
|
|
123
|
+
),
|
|
124
|
+
});
|
|
125
|
+
} catch {
|
|
126
|
+
/* Non-text bytes. */
|
|
127
|
+
}
|
|
128
|
+
} else if (raw.includes('%')) {
|
|
129
|
+
try {
|
|
130
|
+
candidates.push({ encoding: 'percent', text: decodeURIComponent(raw) });
|
|
131
|
+
} catch {
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
const compact = raw.replace(/\s/g, '');
|
|
136
|
+
// Decode canonical base64 text only.
|
|
137
|
+
const bytes = Buffer.from(compact, 'base64url');
|
|
138
|
+
if (
|
|
139
|
+
bytes.toString('base64url') ===
|
|
140
|
+
compact.replace(/=+$/, '').replaceAll('+', '-').replaceAll('/', '_')
|
|
141
|
+
) {
|
|
142
|
+
try {
|
|
143
|
+
candidates.push({
|
|
144
|
+
encoding:
|
|
145
|
+
compact.includes('-') || compact.includes('_')
|
|
146
|
+
? 'base64url'
|
|
147
|
+
: 'base64',
|
|
148
|
+
text: new TextDecoder('utf-8', { fatal: true }).decode(bytes, {
|
|
149
|
+
stream: partial,
|
|
150
|
+
}),
|
|
151
|
+
wrapped: raw.includes('\n'),
|
|
152
|
+
padded: raw.endsWith('='),
|
|
153
|
+
});
|
|
154
|
+
} catch {
|
|
155
|
+
/* Binary content. */
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (/^(?:[a-fA-F0-9]{2}){16,}$/.test(compact)) {
|
|
159
|
+
try {
|
|
160
|
+
candidates.push({
|
|
161
|
+
encoding: 'hex',
|
|
162
|
+
text: new TextDecoder('utf-8', { fatal: true }).decode(
|
|
163
|
+
Buffer.from(compact, 'hex')
|
|
164
|
+
),
|
|
165
|
+
});
|
|
166
|
+
} catch {
|
|
167
|
+
/* Binary content. */
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return candidates;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function encodeRun(run, text) {
|
|
175
|
+
const bytes = Buffer.from(text, 'utf8');
|
|
176
|
+
let encoded;
|
|
177
|
+
if (run.encoding === 'json-content') {
|
|
178
|
+
encoded = JSON.stringify(text).slice(1, -1);
|
|
179
|
+
} else if (run.encoding === 'percent') {
|
|
180
|
+
encoded = Array.from(
|
|
181
|
+
bytes,
|
|
182
|
+
(b) => `%${b.toString(16).padStart(2, '0')}`
|
|
183
|
+
).join('');
|
|
184
|
+
} else if (run.encoding === 'byte-escape') {
|
|
185
|
+
encoded = Array.from(
|
|
186
|
+
bytes,
|
|
187
|
+
(b) => `\\x${b.toString(16).padStart(2, '0')}`
|
|
188
|
+
).join('');
|
|
189
|
+
} else if (run.encoding.startsWith('html-')) {
|
|
190
|
+
encoded = Array.from(text, (c) =>
|
|
191
|
+
run.encoding === 'html-hex'
|
|
192
|
+
? `&#x${c.codePointAt(0).toString(16)};`
|
|
193
|
+
: `&#${c.codePointAt(0)};`
|
|
194
|
+
).join('');
|
|
195
|
+
} else {
|
|
196
|
+
encoded = bytes.toString(run.encoding);
|
|
197
|
+
}
|
|
198
|
+
const decoded =
|
|
199
|
+
run.encoding === 'json-content'
|
|
200
|
+
? { text: JSON.parse(`"${encoded}"`) }
|
|
201
|
+
: decodeCandidates(encoded).find((c) => c.encoding === run.encoding);
|
|
202
|
+
if (!decoded || decoded.text !== text) {
|
|
203
|
+
throw failure('ERR_ENCODING');
|
|
204
|
+
}
|
|
205
|
+
return encoded;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function encodedValue(raw) {
|
|
209
|
+
for (const run of decodedRuns(raw)) {
|
|
210
|
+
if (run.start === 0 && run.end === raw.length && run.depth === 0) {
|
|
211
|
+
return run;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (raw.includes('\\')) {
|
|
215
|
+
try {
|
|
216
|
+
return { encoding: 'json-content', text: JSON.parse(`"${raw}"`) };
|
|
217
|
+
} catch {
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function decodeJson(raw) {
|
|
225
|
+
if (!raw.includes('\\')) {
|
|
226
|
+
return [];
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
return [{ encoding: 'json-content', text: JSON.parse(raw) }];
|
|
230
|
+
} catch {
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
}
|