@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.
Files changed (78) hide show
  1. package/CHANGELOG.md +530 -0
  2. package/LICENSE +24 -0
  3. package/README.md +184 -2
  4. package/bin/legacy-example-package-name.js +95 -0
  5. package/bin/sensitive-data-sanitizer.js +430 -0
  6. package/bin/stream.js +56 -0
  7. package/docs/API.md +336 -0
  8. package/docs/COVERAGE.md +136 -0
  9. package/docs/HISTORY-REMEDIATION.md +41 -0
  10. package/examples/combined-scanners.mjs +23 -0
  11. package/examples/presidio-bridge.py +32 -0
  12. package/examples/realistic-fakes.mjs +18 -0
  13. package/examples/sanitize-session.mjs +27 -0
  14. package/examples/shared-tool-logs.mjs +21 -0
  15. package/examples/stream-jsonl.mjs +17 -0
  16. package/package.json +124 -4
  17. package/src/adapters.js +204 -0
  18. package/src/arithmetic.js +11 -0
  19. package/src/confidence.js +65 -0
  20. package/src/context.js +424 -0
  21. package/src/credentials.js +54 -0
  22. package/src/detection.js +293 -0
  23. package/src/encoded.js +233 -0
  24. package/src/engines.js +348 -0
  25. package/src/entities.js +166 -0
  26. package/src/escape-spans.js +20 -0
  27. package/src/eslint-plugin.d.ts +10 -0
  28. package/src/eslint-plugin.js +193 -0
  29. package/src/external.js +169 -0
  30. package/src/fake-names.js +222 -0
  31. package/src/fake.js +524 -0
  32. package/src/files.js +131 -0
  33. package/src/gitleaks.js +194 -0
  34. package/src/history-rewrite.js +339 -0
  35. package/src/history.js +103 -0
  36. package/src/identity.js +274 -0
  37. package/src/index.d.ts +321 -0
  38. package/src/index.js +40 -0
  39. package/src/jsonl-stream.js +198 -0
  40. package/src/keywords.js +45 -0
  41. package/src/known.js +164 -0
  42. package/src/legacy.d.ts +3 -0
  43. package/src/legacy.js +2 -0
  44. package/src/locales.js +217 -0
  45. package/src/log-bytes.js +85 -0
  46. package/src/names.js +65 -0
  47. package/src/national-checks.js +129 -0
  48. package/src/national.js +128 -0
  49. package/src/outbound.js +28 -0
  50. package/src/personal-context.js +85 -0
  51. package/src/personal.js +248 -0
  52. package/src/projection.js +126 -0
  53. package/src/propagation.js +163 -0
  54. package/src/public-verifier.js +77 -0
  55. package/src/public.js +153 -0
  56. package/src/record-worker.js +20 -0
  57. package/src/rules.js +210 -0
  58. package/src/sanitizer.js +794 -0
  59. package/src/specimens.js +34 -0
  60. package/src/stream-identity.js +129 -0
  61. package/src/stream-worker.js +18 -0
  62. package/src/stream.js +449 -0
  63. package/src/structured.js +278 -0
  64. package/src/transforms.js +192 -0
  65. package/src/transliteration.js +75 -0
  66. package/src/utf8.js +60 -0
  67. package/src/vendor/entities/aws.json +42 -0
  68. package/src/vendor/entities/azure.json +182 -0
  69. package/src/vendor/entities/google.json +219 -0
  70. package/src/vendor/gitleaks/LICENSE +21 -0
  71. package/src/vendor/gitleaks/provenance.json +8 -0
  72. package/src/vendor/gitleaks/rules-0.json +268 -0
  73. package/src/vendor/gitleaks/rules-1.json +1794 -0
  74. package/src/vendor/gitleaks/rules-2.json +292 -0
  75. package/src/vendor/gitleaks/rules-3.json +263 -0
  76. package/src/vendor/gitleaks/rules-4.json +277 -0
  77. package/src/vendor/gitleaks/rules-5.json +154 -0
  78. 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
+ }