@link-foundation/sensitive-data-sanitizer 0.0.0-stage → 0.12.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 +578 -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
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { failure } from './detection.js';
|
|
2
|
+
|
|
3
|
+
// Session metadata is opt-in at the CLI; library callers choose their own
|
|
4
|
+
// fields. These names do not grant exemptions to provider/known credentials.
|
|
5
|
+
export const sessionStructuralFields = ['id', 'type', 'name', 'description'];
|
|
6
|
+
|
|
7
|
+
// Advance once through each character. A regexp string matcher can repeatedly
|
|
8
|
+
// restart at escaped quotes in a damaged token and become quadratic.
|
|
9
|
+
function* lexemes(text) {
|
|
10
|
+
for (let index = 0; index < text.length; index++) {
|
|
11
|
+
const char = text[index];
|
|
12
|
+
if (char === '"') {
|
|
13
|
+
const start = index;
|
|
14
|
+
index++;
|
|
15
|
+
while (index < text.length && text[index] !== '"') {
|
|
16
|
+
index += text[index] === '\\' ? 2 : 1;
|
|
17
|
+
}
|
|
18
|
+
if (index >= text.length) {
|
|
19
|
+
throw failure('ERR_JSON');
|
|
20
|
+
}
|
|
21
|
+
let next = index + 1;
|
|
22
|
+
while (next < text.length && /\s/.test(text[next])) {
|
|
23
|
+
next++;
|
|
24
|
+
}
|
|
25
|
+
yield {
|
|
26
|
+
raw: text.slice(start, index + 1),
|
|
27
|
+
index: start,
|
|
28
|
+
key: text[next] === ':',
|
|
29
|
+
};
|
|
30
|
+
} else if ('{}[],:'.includes(char)) {
|
|
31
|
+
yield { raw: char, index, key: false };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function tokens(text, offset = 0) {
|
|
37
|
+
const strings = [];
|
|
38
|
+
const stack = [{ field: '' }];
|
|
39
|
+
for (const match of lexemes(text)) {
|
|
40
|
+
const raw = match.raw,
|
|
41
|
+
frame = stack.at(-1);
|
|
42
|
+
if (raw[0] === '"') {
|
|
43
|
+
const key = match.key;
|
|
44
|
+
const value = JSON.parse(raw);
|
|
45
|
+
if (key) {
|
|
46
|
+
frame.field = value;
|
|
47
|
+
}
|
|
48
|
+
strings.push({
|
|
49
|
+
raw,
|
|
50
|
+
start: offset + match.index,
|
|
51
|
+
field: key ? '' : frame.field,
|
|
52
|
+
key,
|
|
53
|
+
});
|
|
54
|
+
} else if (raw === '{' || raw === '[') {
|
|
55
|
+
stack.push({ field: frame.field });
|
|
56
|
+
} else if (raw === '}' || raw === ']') {
|
|
57
|
+
stack.pop();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return strings;
|
|
61
|
+
}
|
|
62
|
+
export function structuredBatches(text, options) {
|
|
63
|
+
const leaves = [];
|
|
64
|
+
try {
|
|
65
|
+
if (options.structured === 'json') {
|
|
66
|
+
JSON.parse(text);
|
|
67
|
+
for (const leaf of tokens(text)) {
|
|
68
|
+
leaves.push(leaf);
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
let offset = 0;
|
|
72
|
+
for (const line of text.split('\n')) {
|
|
73
|
+
if (line.trim()) {
|
|
74
|
+
JSON.parse(line);
|
|
75
|
+
for (const leaf of tokens(line, offset)) {
|
|
76
|
+
leaves.push(leaf);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
offset += line.length + 1;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
throw failure('ERR_JSON');
|
|
84
|
+
}
|
|
85
|
+
const batches = [];
|
|
86
|
+
let parts = [],
|
|
87
|
+
entries = [],
|
|
88
|
+
length = 0;
|
|
89
|
+
const limit = Math.min(
|
|
90
|
+
256 * 1024,
|
|
91
|
+
options.maxInputLength ?? 10 * 1024 * 1024
|
|
92
|
+
);
|
|
93
|
+
for (const leaf of leaves) {
|
|
94
|
+
// Keeping the original escaped token gives exact source offsets without
|
|
95
|
+
// exposing values in finding metadata. Separate keys are scanned too.
|
|
96
|
+
const prefix = leaf.field
|
|
97
|
+
? `${JSON.stringify(leaf.field.slice(0, 128))}: `
|
|
98
|
+
: '';
|
|
99
|
+
const part = `${prefix + leaf.raw}\n`;
|
|
100
|
+
if (length && length + part.length > limit) {
|
|
101
|
+
batches.push({ text: parts.join('').slice(0, -1), entries });
|
|
102
|
+
parts = [];
|
|
103
|
+
entries = [];
|
|
104
|
+
length = 0;
|
|
105
|
+
}
|
|
106
|
+
entries.push({ ...leaf, batchStart: length + prefix.length });
|
|
107
|
+
parts.push(part);
|
|
108
|
+
length += part.length;
|
|
109
|
+
}
|
|
110
|
+
if (parts.length) {
|
|
111
|
+
batches.push({ text: parts.join('').slice(0, -1), entries });
|
|
112
|
+
}
|
|
113
|
+
return batches;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function structuredValueRanges(text, options) {
|
|
117
|
+
return structuredBatches(text, options).flatMap((batch) =>
|
|
118
|
+
batch.entries
|
|
119
|
+
.filter((entry) => !entry.key)
|
|
120
|
+
.map((entry) => ({
|
|
121
|
+
start: entry.start + 1,
|
|
122
|
+
end: entry.start + entry.raw.length - 1,
|
|
123
|
+
}))
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function weakStructuralFinding(finding, entry, batchText) {
|
|
128
|
+
if (
|
|
129
|
+
['known-secret', 'known-personal', 'confirmed-personal'].includes(
|
|
130
|
+
finding.rule
|
|
131
|
+
)
|
|
132
|
+
) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
if (finding.category === 'personal') {
|
|
136
|
+
return ['PERSON', 'ORGANIZATION', 'USERNAME', 'ID'].includes(finding.type);
|
|
137
|
+
}
|
|
138
|
+
if (
|
|
139
|
+
['quoted-entropy', 'entropy', 'gitleaks/generic-api-key'].includes(
|
|
140
|
+
finding.rule
|
|
141
|
+
)
|
|
142
|
+
) {
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
if (finding.rule === 'context') {
|
|
146
|
+
const before = batchText.slice(
|
|
147
|
+
Math.max(entry.batchStart, finding.start - 80),
|
|
148
|
+
finding.start
|
|
149
|
+
);
|
|
150
|
+
// Generic "secret: ..." prose in skill descriptions is metadata; actual
|
|
151
|
+
// password/auth context remains sensitive even in declared fields.
|
|
152
|
+
return /\bsecret\s*[:=]\s*["']?$/i.test(before);
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
function ordinaryPersonalKey(entry, finding) {
|
|
157
|
+
return (
|
|
158
|
+
entry.key &&
|
|
159
|
+
/^[A-Za-z_$][\w$]*$/.test(JSON.parse(entry.raw)) &&
|
|
160
|
+
finding.category === 'personal' &&
|
|
161
|
+
finding.rule !== 'known-personal'
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
// Propagation runs again at source offsets. Apply the same key policy after it
|
|
165
|
+
// so a name confirmed in a value cannot rename an ordinary identifier key.
|
|
166
|
+
export function preserveIdentifierKeys(text, findings, options) {
|
|
167
|
+
if (!options.structured) {
|
|
168
|
+
return findings;
|
|
169
|
+
}
|
|
170
|
+
const keys = structuredBatches(text, options).flatMap((batch) =>
|
|
171
|
+
batch.entries
|
|
172
|
+
.filter((entry) => ordinaryPersonalKey(entry, { category: 'personal' }))
|
|
173
|
+
.map((entry) => ({
|
|
174
|
+
start: entry.start,
|
|
175
|
+
end: entry.start + entry.raw.length,
|
|
176
|
+
}))
|
|
177
|
+
);
|
|
178
|
+
return findings.filter((finding) => {
|
|
179
|
+
if (finding.category !== 'personal' || finding.rule === 'known-personal') {
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
let low = 0,
|
|
183
|
+
high = keys.length;
|
|
184
|
+
while (low < high) {
|
|
185
|
+
const mid = (low + high) >>> 1;
|
|
186
|
+
if (keys[mid].end <= finding.start) {
|
|
187
|
+
low = mid + 1;
|
|
188
|
+
} else {
|
|
189
|
+
high = mid;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return low === keys.length || keys[low].start >= finding.end;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
function preserveStructuredEntry(entry, finding, text, options) {
|
|
196
|
+
return (
|
|
197
|
+
(entry.key &&
|
|
198
|
+
['confirmed-personal', 'document-propagation'].includes(finding.rule)) ||
|
|
199
|
+
ordinaryPersonalKey(entry, finding) ||
|
|
200
|
+
(!entry.key &&
|
|
201
|
+
options.structuralFields?.includes(entry.field) &&
|
|
202
|
+
weakStructuralFinding(finding, entry, text))
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
export function mapStructuredFindings(batch, findings, options) {
|
|
206
|
+
const result = [];
|
|
207
|
+
for (const finding of findings) {
|
|
208
|
+
let low = 0,
|
|
209
|
+
high = batch.entries.length;
|
|
210
|
+
while (low < high) {
|
|
211
|
+
const mid = (low + high) >>> 1,
|
|
212
|
+
entry = batch.entries[mid];
|
|
213
|
+
if (entry.batchStart + entry.raw.length - 1 <= finding.start) {
|
|
214
|
+
low = mid + 1;
|
|
215
|
+
} else {
|
|
216
|
+
high = mid;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (let i = low; i < batch.entries.length; i++) {
|
|
220
|
+
const entry = batch.entries[i];
|
|
221
|
+
const lo = entry.batchStart + 1,
|
|
222
|
+
hi = entry.batchStart + entry.raw.length - 1;
|
|
223
|
+
if (lo >= finding.end) {
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
if (finding.end <= lo || finding.start >= hi) {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (preserveStructuredEntry(entry, finding, batch.text, options)) {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const start = Math.max(lo, finding.start),
|
|
233
|
+
end = Math.min(hi, finding.end);
|
|
234
|
+
if (end <= start) {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
// A detector that crosses a token boundary cannot safely describe a
|
|
238
|
+
// partial string. Redact that string's complete content instead.
|
|
239
|
+
const crosses = finding.start < lo || finding.end > hi;
|
|
240
|
+
result.push({
|
|
241
|
+
...finding,
|
|
242
|
+
start: entry.start + (crosses ? 1 : start - entry.batchStart),
|
|
243
|
+
end:
|
|
244
|
+
entry.start +
|
|
245
|
+
(crosses ? entry.raw.length - 1 : end - entry.batchStart),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return result;
|
|
250
|
+
}
|
|
251
|
+
export function verifyStructured(text, options) {
|
|
252
|
+
structuredBatches(text, options);
|
|
253
|
+
// JSON.parse silently overwrites duplicate keys; prevent loss of meaning
|
|
254
|
+
// when distinct personal keys collapse to the same redaction marker.
|
|
255
|
+
const records =
|
|
256
|
+
options.structured === 'json'
|
|
257
|
+
? [text]
|
|
258
|
+
: text.split('\n').filter((line) => line.trim());
|
|
259
|
+
for (const record of records) {
|
|
260
|
+
const stack = [];
|
|
261
|
+
for (const match of lexemes(record)) {
|
|
262
|
+
if (match.raw === '{') {
|
|
263
|
+
stack.push(new Set());
|
|
264
|
+
} else if (match.raw === '[') {
|
|
265
|
+
stack.push(undefined);
|
|
266
|
+
} else if (match.raw === '}' || match.raw === ']') {
|
|
267
|
+
stack.pop();
|
|
268
|
+
} else if (match.key) {
|
|
269
|
+
const key = JSON.parse(match.raw),
|
|
270
|
+
keys = stack.at(-1);
|
|
271
|
+
if (keys?.has(key)) {
|
|
272
|
+
throw failure('ERR_JSON_COLLISION');
|
|
273
|
+
}
|
|
274
|
+
keys?.add(key);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { realisticFake } from './fake.js';
|
|
2
|
+
import { createHmac } from 'node:crypto';
|
|
3
|
+
import { failure, REDACTED } from './detection.js';
|
|
4
|
+
import { encodedValue, encodeRun } from './encoded.js';
|
|
5
|
+
import { isIdentityType } from './identity.js';
|
|
6
|
+
|
|
7
|
+
const modes = new Set([
|
|
8
|
+
'redact',
|
|
9
|
+
'fake',
|
|
10
|
+
'hive-mask',
|
|
11
|
+
'mask',
|
|
12
|
+
'pseudonym',
|
|
13
|
+
'format-preserving',
|
|
14
|
+
'date-shift',
|
|
15
|
+
'bucket',
|
|
16
|
+
]);
|
|
17
|
+
export function validateTransforms(options) {
|
|
18
|
+
if (
|
|
19
|
+
options.preserveEncoding !== undefined &&
|
|
20
|
+
typeof options.preserveEncoding !== 'boolean'
|
|
21
|
+
) {
|
|
22
|
+
throw failure('ERR_CONFIG');
|
|
23
|
+
}
|
|
24
|
+
if (
|
|
25
|
+
options.transformations !== undefined &&
|
|
26
|
+
(!options.transformations ||
|
|
27
|
+
typeof options.transformations !== 'object' ||
|
|
28
|
+
Array.isArray(options.transformations))
|
|
29
|
+
) {
|
|
30
|
+
throw failure('ERR_CONFIG');
|
|
31
|
+
}
|
|
32
|
+
for (const transform of [
|
|
33
|
+
options.transformation,
|
|
34
|
+
...Object.values(options.transformations ?? {}),
|
|
35
|
+
].filter((t) => t !== undefined)) {
|
|
36
|
+
validateTransform(transform);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function positiveInteger(value) {
|
|
40
|
+
return Number.isSafeInteger(value) && value > 0;
|
|
41
|
+
}
|
|
42
|
+
function validateTransform(transform) {
|
|
43
|
+
if (!transform || !modes.has(transform.mode)) {
|
|
44
|
+
throw failure('ERR_CONFIG');
|
|
45
|
+
}
|
|
46
|
+
const checks = {
|
|
47
|
+
fake: () =>
|
|
48
|
+
typeof transform.key === 'string' &&
|
|
49
|
+
Buffer.byteLength(transform.key) >= 16 &&
|
|
50
|
+
(transform.credentials === undefined ||
|
|
51
|
+
typeof transform.credentials === 'boolean') &&
|
|
52
|
+
(transform.mrzCountry === undefined || transform.mrzCountry === 'UTO'),
|
|
53
|
+
pseudonym: () =>
|
|
54
|
+
typeof transform.key === 'string' &&
|
|
55
|
+
Buffer.byteLength(transform.key) >= 16,
|
|
56
|
+
'format-preserving': () =>
|
|
57
|
+
typeof transform.key === 'string' &&
|
|
58
|
+
Buffer.byteLength(transform.key) >= 16,
|
|
59
|
+
'date-shift': () =>
|
|
60
|
+
Number.isSafeInteger(transform.days) &&
|
|
61
|
+
Math.abs(transform.days) <= 365000,
|
|
62
|
+
bucket: () => positiveInteger(transform.size),
|
|
63
|
+
mask: () =>
|
|
64
|
+
[transform.keepStart ?? 0, transform.keepEnd ?? 0].every(
|
|
65
|
+
(value) => Number.isSafeInteger(value) && value >= 0
|
|
66
|
+
) &&
|
|
67
|
+
(transform.minLength === undefined ||
|
|
68
|
+
positiveInteger(transform.minLength)) &&
|
|
69
|
+
(transform.marker === undefined ||
|
|
70
|
+
(typeof transform.marker === 'string' &&
|
|
71
|
+
transform.marker.length > 0 &&
|
|
72
|
+
transform.marker.length <= 64)),
|
|
73
|
+
};
|
|
74
|
+
if (checks[transform.mode] && !checks[transform.mode]()) {
|
|
75
|
+
throw failure('ERR_CONFIG');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function keyed(value, type, key, suffix = '') {
|
|
80
|
+
return createHmac('sha256', key)
|
|
81
|
+
.update(`${type}\0${value}\0${suffix}`)
|
|
82
|
+
.digest();
|
|
83
|
+
}
|
|
84
|
+
function formatPseudonym(value, type, key) {
|
|
85
|
+
const result = Array.from(value, (char, i) => {
|
|
86
|
+
const alphabet = /\d/.test(char)
|
|
87
|
+
? '0123456789'
|
|
88
|
+
: /[A-Z]/.test(char)
|
|
89
|
+
? 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
|
90
|
+
: /[a-z]/.test(char)
|
|
91
|
+
? 'abcdefghijklmnopqrstuvwxyz'
|
|
92
|
+
: /\p{L}/u.test(char)
|
|
93
|
+
? 'abcdefghijklmnopqrstuvwxyz'
|
|
94
|
+
: undefined;
|
|
95
|
+
return alphabet
|
|
96
|
+
? alphabet[keyed(value, type, key, String(i))[0] % alphabet.length]
|
|
97
|
+
: char;
|
|
98
|
+
}).join('');
|
|
99
|
+
return result === value ? REDACTED : result;
|
|
100
|
+
}
|
|
101
|
+
function shiftedDate(value, days) {
|
|
102
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
103
|
+
if (!match) {
|
|
104
|
+
return REDACTED;
|
|
105
|
+
}
|
|
106
|
+
const date = new Date(`${value}T00:00:00.000Z`);
|
|
107
|
+
if (
|
|
108
|
+
Number.isNaN(date.getTime()) ||
|
|
109
|
+
date.toISOString().slice(0, 10) !== value
|
|
110
|
+
) {
|
|
111
|
+
return REDACTED;
|
|
112
|
+
}
|
|
113
|
+
date.setUTCDate(date.getUTCDate() + days);
|
|
114
|
+
return date.getUTCFullYear() < 0 || date.getUTCFullYear() > 9999
|
|
115
|
+
? REDACTED
|
|
116
|
+
: date.toISOString().slice(0, 10);
|
|
117
|
+
}
|
|
118
|
+
function masked(value, finding, transform) {
|
|
119
|
+
const chars = Array.from(value),
|
|
120
|
+
start = transform.keepStart ?? 0,
|
|
121
|
+
end = transform.keepEnd ?? 0;
|
|
122
|
+
return chars.length <
|
|
123
|
+
(transform.minLength ?? (isIdentityType(finding.type) ? 5 : 0)) ||
|
|
124
|
+
start + end >= chars.length
|
|
125
|
+
? REDACTED
|
|
126
|
+
: `${chars.slice(0, start).join('')}${transform.marker ?? '*'.repeat(chars.length - start - end)}${end ? chars.slice(-end).join('') : ''}`;
|
|
127
|
+
}
|
|
128
|
+
function transformed(value, finding, transform) {
|
|
129
|
+
if (!transform || transform.mode === 'redact') {
|
|
130
|
+
return REDACTED;
|
|
131
|
+
}
|
|
132
|
+
if (transform.mode === 'hive-mask') {
|
|
133
|
+
const chars = Array.from(value);
|
|
134
|
+
return chars.length > 12
|
|
135
|
+
? `${chars.slice(0, 3).join('')}…${chars.slice(-3).join('')}`
|
|
136
|
+
: REDACTED;
|
|
137
|
+
}
|
|
138
|
+
if (transform.mode === 'fake') {
|
|
139
|
+
return realisticFake(value, finding, transform);
|
|
140
|
+
}
|
|
141
|
+
if (finding.category === 'credential') {
|
|
142
|
+
return REDACTED;
|
|
143
|
+
}
|
|
144
|
+
if (transform.mode === 'mask') {
|
|
145
|
+
return masked(value, finding, transform);
|
|
146
|
+
}
|
|
147
|
+
if (transform.mode === 'pseudonym') {
|
|
148
|
+
return `[PSEUDONYM_${keyed(value, finding.type, transform.key).toString('hex').slice(0, 24)}]`;
|
|
149
|
+
}
|
|
150
|
+
if (transform.mode === 'format-preserving') {
|
|
151
|
+
return formatPseudonym(value, finding.type, transform.key);
|
|
152
|
+
}
|
|
153
|
+
if (transform.mode === 'date-shift') {
|
|
154
|
+
return shiftedDate(value, transform.days);
|
|
155
|
+
}
|
|
156
|
+
const number = Number(value);
|
|
157
|
+
if (!Number.isSafeInteger(number)) {
|
|
158
|
+
return REDACTED;
|
|
159
|
+
}
|
|
160
|
+
const lower = Math.floor(number / transform.size) * transform.size;
|
|
161
|
+
return `${lower}–${lower + transform.size - 1}`;
|
|
162
|
+
}
|
|
163
|
+
export function renderReplacement(text, finding, options, encodedSanitize) {
|
|
164
|
+
const transform =
|
|
165
|
+
options.transformations?.[finding.type] ??
|
|
166
|
+
options.transformation ??
|
|
167
|
+
(options.identityMask && isIdentityType(finding.type)
|
|
168
|
+
? { mode: 'mask', keepStart: 2, keepEnd: 2, marker: '***', minLength: 5 }
|
|
169
|
+
: undefined);
|
|
170
|
+
if (finding.type === 'ENCODED_SENSITIVE') {
|
|
171
|
+
const raw = text.slice(finding.start, finding.end);
|
|
172
|
+
const run = encodedValue(raw);
|
|
173
|
+
if (run && (options.preserveEncoding || run.encoding === 'json-content')) {
|
|
174
|
+
return encodeRun(run, encodedSanitize(run.text).text);
|
|
175
|
+
}
|
|
176
|
+
if (options.preserveEncoding) {
|
|
177
|
+
throw failure('ERR_ENCODING');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
let raw = text.slice(finding.start, finding.end);
|
|
181
|
+
const jsonQuoted =
|
|
182
|
+
text[finding.start - 1] === '"' && text[finding.end] === '"';
|
|
183
|
+
if (jsonQuoted) {
|
|
184
|
+
try {
|
|
185
|
+
raw = JSON.parse(`"${raw}"`);
|
|
186
|
+
} catch {
|
|
187
|
+
return REDACTED;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const result = transformed(raw, finding, transform);
|
|
191
|
+
return jsonQuoted ? JSON.stringify(result).slice(1, -1) : result;
|
|
192
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { firstNames } from './names.js';
|
|
2
|
+
// ICAO 9303 spelling and common passport/GOST variants. Transliteration is
|
|
3
|
+
// lossy: aliases are detection candidates, never proof of a person's identity.
|
|
4
|
+
const cyrillic = 'абвгдеёжзийклмнопрстуфхцчшщъыьэюя';
|
|
5
|
+
const tables = [
|
|
6
|
+
'a b v g d e e zh z i i k l m n o p r s t u f kh ts ch sh shch ie y - e iu ia',
|
|
7
|
+
'a b v g d e yo zh z i y k l m n o p r s t u f kh ts ch sh shch - y - e yu ya',
|
|
8
|
+
'a b v g d e jo zh z i j k l m n o p r s t u f x cz ch sh shh - y - eh ju ja',
|
|
9
|
+
'a b v g d e ë ž z i j k l m n o p r s t u f h c č š ŝ ʺ y ʹ è û â',
|
|
10
|
+
].map((t) =>
|
|
11
|
+
Object.fromEntries(
|
|
12
|
+
[...cyrillic].map((c, i) => [c, t.split(' ')[i].replace('-', '')])
|
|
13
|
+
)
|
|
14
|
+
);
|
|
15
|
+
const inverseTables = tables.map((table) =>
|
|
16
|
+
[...Object.entries(table), ['кс', 'x']]
|
|
17
|
+
.filter(([, spelling]) => spelling)
|
|
18
|
+
.sort((a, b) => b[1].length - a[1].length)
|
|
19
|
+
);
|
|
20
|
+
function spellings(lower) {
|
|
21
|
+
const values = new Set([lower]);
|
|
22
|
+
for (const table of tables) {
|
|
23
|
+
const latin = [...lower].map((c) => table[c] ?? c).join('');
|
|
24
|
+
values.add(latin);
|
|
25
|
+
values.add(latin.replaceAll('ks', 'x'));
|
|
26
|
+
}
|
|
27
|
+
return values;
|
|
28
|
+
}
|
|
29
|
+
const equivalents = new Map();
|
|
30
|
+
for (const name of `${firstNames.ru} Олеся`.toLowerCase().split(' ')) {
|
|
31
|
+
const variants = spellings(name);
|
|
32
|
+
for (const variant of variants) {
|
|
33
|
+
equivalents.set(variant, variants);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export const transliteratedGivenNames = [...equivalents.keys()].filter((v) =>
|
|
37
|
+
/^[a-z]+$/.test(v)
|
|
38
|
+
);
|
|
39
|
+
export function nameAliases(value) {
|
|
40
|
+
const lower = value.normalize('NFC').toLowerCase();
|
|
41
|
+
const values = new Set([lower]);
|
|
42
|
+
if (/\p{Script=Cyrillic}/u.test(lower)) {
|
|
43
|
+
for (const spelling of spellings(lower)) {
|
|
44
|
+
values.add(spelling);
|
|
45
|
+
}
|
|
46
|
+
} else if (
|
|
47
|
+
/^[\p{Script=Latin}ʺʹ]+(?:[ _-][\p{Script=Latin}ʺʹ]+)*$/u.test(lower)
|
|
48
|
+
) {
|
|
49
|
+
// Greedy inverse for each passport/GOST table, with bounded ambiguity.
|
|
50
|
+
for (const entries of inverseTables) {
|
|
51
|
+
let inverse = '',
|
|
52
|
+
index = 0;
|
|
53
|
+
while (index < lower.length) {
|
|
54
|
+
const entry = entries.find(
|
|
55
|
+
([c, s]) =>
|
|
56
|
+
(s !== 'y' ||
|
|
57
|
+
c === (/[aeiou]/.test(lower[index - 1] ?? '') ? 'й' : 'ы')) &&
|
|
58
|
+
lower.startsWith(s, index)
|
|
59
|
+
);
|
|
60
|
+
inverse += entry?.[0] ?? lower[index];
|
|
61
|
+
index += entry?.[1].length ?? 1;
|
|
62
|
+
}
|
|
63
|
+
for (const spelling of spellings(inverse)) {
|
|
64
|
+
values.add(spelling);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// The same table-derived equivalences cover the whole Cyrillic gazetteer.
|
|
69
|
+
for (const value of [...values]) {
|
|
70
|
+
for (const variant of equivalents.get(value) ?? []) {
|
|
71
|
+
values.add(variant);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return [...values].filter(Boolean);
|
|
75
|
+
}
|
package/src/utf8.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { failure } from './detection.js';
|
|
2
|
+
|
|
3
|
+
export function sequenceWidth(first) {
|
|
4
|
+
if (first < 0x80) {
|
|
5
|
+
return 1;
|
|
6
|
+
}
|
|
7
|
+
if (first >= 0xc2 && first <= 0xdf) {
|
|
8
|
+
return 2;
|
|
9
|
+
}
|
|
10
|
+
if (first >= 0xe0 && first <= 0xef) {
|
|
11
|
+
return 3;
|
|
12
|
+
}
|
|
13
|
+
if (first >= 0xf0 && first <= 0xf4) {
|
|
14
|
+
return 4;
|
|
15
|
+
}
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
18
|
+
function secondByteAllowed(first, byte) {
|
|
19
|
+
const limits = {
|
|
20
|
+
0xe0: [0xa0, 0xbf],
|
|
21
|
+
0xed: [0x80, 0x9f],
|
|
22
|
+
0xf0: [0x90, 0xbf],
|
|
23
|
+
0xf4: [0x80, 0x8f],
|
|
24
|
+
};
|
|
25
|
+
const [low, high] = limits[first] ?? [0x80, 0xbf];
|
|
26
|
+
return byte >= low && byte <= high;
|
|
27
|
+
}
|
|
28
|
+
export function validSequence(bytes, index, width) {
|
|
29
|
+
if (!width || index + width > bytes.length) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
for (let next = 1; next < width; next++) {
|
|
33
|
+
const byte = bytes[index + next];
|
|
34
|
+
if (
|
|
35
|
+
byte < 0x80 ||
|
|
36
|
+
byte > 0xbf ||
|
|
37
|
+
(next === 1 && !secondByteAllowed(bytes[index], byte))
|
|
38
|
+
) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
// Called only after fatal UTF-8 decoding fails. Diagnostics contain positions,
|
|
45
|
+
// never input bytes or personal values. Offsets identify the sequence start.
|
|
46
|
+
export function encodingFailure(bytes, record, baseOffset) {
|
|
47
|
+
let index = 0;
|
|
48
|
+
while (index < bytes.length) {
|
|
49
|
+
const width = sequenceWidth(bytes[index]);
|
|
50
|
+
if (!validSequence(bytes, index, width)) {
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
index += width;
|
|
54
|
+
}
|
|
55
|
+
return failure('ERR_ENCODING', {
|
|
56
|
+
record,
|
|
57
|
+
offset: baseOffset + index,
|
|
58
|
+
recordOffset: index,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"source": "https://docs.aws.amazon.com/comprehend/latest/dg/how-pii.html",
|
|
3
|
+
"sha256": "0ac728632444f43e09b9079c709558c8237d732e7bcb3c8199c4dd3443f9c6a0",
|
|
4
|
+
"names": [
|
|
5
|
+
"ADDRESS",
|
|
6
|
+
"AGE",
|
|
7
|
+
"AWS_ACCESS_KEY",
|
|
8
|
+
"AWS_SECRET_KEY",
|
|
9
|
+
"BANK_ACCOUNT_NUMBER",
|
|
10
|
+
"BANK_ROUTING",
|
|
11
|
+
"CA_HEALTH_NUMBER",
|
|
12
|
+
"CA_SOCIAL_INSURANCE_NUMBER",
|
|
13
|
+
"CREDIT_DEBIT_CVV",
|
|
14
|
+
"CREDIT_DEBIT_EXPIRY",
|
|
15
|
+
"CREDIT_DEBIT_NUMBER",
|
|
16
|
+
"DATE_TIME",
|
|
17
|
+
"DRIVER_ID",
|
|
18
|
+
"EMAIL",
|
|
19
|
+
"INTERNATIONAL_BANK_ACCOUNT_NUMBER",
|
|
20
|
+
"IN_AADHAAR",
|
|
21
|
+
"IN_NREGA",
|
|
22
|
+
"IN_PERMANENT_ACCOUNT_NUMBER",
|
|
23
|
+
"IN_VOTER_NUMBER",
|
|
24
|
+
"IP_ADDRESS",
|
|
25
|
+
"LICENSE_PLATE",
|
|
26
|
+
"MAC_ADDRESS",
|
|
27
|
+
"NAME",
|
|
28
|
+
"PASSPORT_NUMBER",
|
|
29
|
+
"PASSWORD",
|
|
30
|
+
"PHONE",
|
|
31
|
+
"PIN",
|
|
32
|
+
"SSN",
|
|
33
|
+
"SWIFT_CODE",
|
|
34
|
+
"UK_NATIONAL_HEALTH_SERVICE_NUMBER",
|
|
35
|
+
"UK_NATIONAL_INSURANCE_NUMBER",
|
|
36
|
+
"UK_UNIQUE_TAXPAYER_REFERENCE_NUMBER",
|
|
37
|
+
"URL",
|
|
38
|
+
"USERNAME",
|
|
39
|
+
"US_INDIVIDUAL_TAX_IDENTIFICATION_NUMBER",
|
|
40
|
+
"VEHICLE_IDENTIFICATION_NUMBER"
|
|
41
|
+
]
|
|
42
|
+
}
|