@link-foundation/sensitive-data-sanitizer 0.12.2 → 0.14.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 +12 -0
- package/README.md +44 -0
- package/bin/placeholders.js +115 -0
- package/bin/sensitive-data-sanitizer.js +47 -16
- package/bin/stream.js +2 -1
- package/docs/API.md +130 -21
- package/docs/COVERAGE.md +50 -0
- package/examples/reversible-placeholders.mjs +35 -0
- package/package.json +4 -2
- package/src/context.js +23 -9
- package/src/detection.js +26 -25
- package/src/document-entities.js +110 -0
- package/src/document-vocabulary.js +71 -0
- package/src/engines.js +42 -18
- package/src/entities.js +7 -0
- package/src/fake-names.js +61 -7
- package/src/fake.js +7 -0
- package/src/index.d.ts +55 -2
- package/src/index.js +8 -0
- package/src/json-string-runs.js +18 -0
- package/src/jsonl-stream.js +5 -0
- package/src/keywords.js +17 -0
- package/src/names.js +1 -1
- package/src/personal-variants.js +94 -0
- package/src/personal.js +6 -1
- package/src/placeholder-types.js +33 -0
- package/src/placeholders.js +405 -0
- package/src/projection.js +6 -2
- package/src/propagation.js +13 -1
- package/src/restore-map.js +220 -0
- package/src/sanitizer.js +119 -18
- package/src/stream.js +23 -2
- package/src/structured.js +5 -1
- package/src/transforms.js +12 -1
package/src/context.js
CHANGED
|
@@ -8,6 +8,7 @@ import { plausiblePerson } from './names.js';
|
|
|
8
8
|
// unrelated words such as passport. Verb forms cover the supported locales.
|
|
9
9
|
export const credentialVerbs = [
|
|
10
10
|
'is',
|
|
11
|
+
'are',
|
|
11
12
|
'was',
|
|
12
13
|
'equals',
|
|
13
14
|
'es',
|
|
@@ -45,12 +46,11 @@ export const credentialVerbs = [
|
|
|
45
46
|
'คือ',
|
|
46
47
|
];
|
|
47
48
|
const verbs = credentialVerbs.map(escapePattern).join('|');
|
|
48
|
-
const
|
|
49
|
+
const label = labels.credential
|
|
49
50
|
.filter((word) => !['auth', 'authorization'].includes(word))
|
|
50
51
|
.map(escapePattern)
|
|
51
|
-
.join(
|
|
52
|
-
|
|
53
|
-
)})[\\p{L}\\p{N}_.-]{0,32}|(?:[\\p{L}\\p{N}_.-]{1,32}[_-])?(?:pass|pw)(?:[_-][\\p{L}\\p{N}_.-]{1,32})?`;
|
|
52
|
+
.join('|');
|
|
53
|
+
const key = `(?:[\\p{L}\\p{N}]+[_.-])*(?:auth|authorization|PGPASSWORD|${label}|pass|pw)(?:[_.-][\\p{L}\\p{N}]+)*`;
|
|
54
54
|
// A qualifier such as "for staging" stays on one logical line: it never
|
|
55
55
|
// crosses an escaped line break, an escape sequence or an enclosing quote.
|
|
56
56
|
// Verbs are whole words, so "request" does not end in the verb "est".
|
|
@@ -60,8 +60,6 @@ const credentialPrefix = new RegExp(
|
|
|
60
60
|
);
|
|
61
61
|
// Bare generic labels are common English nouns. After a verb they need a
|
|
62
62
|
// value that looks generated; "the token is valid" is prose, not a secret.
|
|
63
|
-
const genericLabel =
|
|
64
|
-
/^(?:token|sig|sas|secret|auth|authorization|cookie|секрет|токен)$/iu;
|
|
65
63
|
function generatedWord(text, range) {
|
|
66
64
|
const word = /^[^\s]+/u
|
|
67
65
|
.exec(text.slice(range.start, range.end))?.[0]
|
|
@@ -170,7 +168,7 @@ export function authorizationRange(text, start) {
|
|
|
170
168
|
for (; end < text.length; end++) {
|
|
171
169
|
const char = text[end];
|
|
172
170
|
const code = text.charCodeAt(end);
|
|
173
|
-
if (
|
|
171
|
+
if (authorizationDelimiter(char, code)) {
|
|
174
172
|
break;
|
|
175
173
|
}
|
|
176
174
|
if (char === '<' && end !== tokenStart && !brackets.length) {
|
|
@@ -185,7 +183,13 @@ export function authorizationRange(text, start) {
|
|
|
185
183
|
}
|
|
186
184
|
}
|
|
187
185
|
const token = text.slice(tokenStart, end);
|
|
188
|
-
return
|
|
186
|
+
return usableAuthorization(token) ? { start, end } : undefined;
|
|
187
|
+
}
|
|
188
|
+
function usableAuthorization(token) {
|
|
189
|
+
return Boolean(token) && !credentialPlaceholder(token);
|
|
190
|
+
}
|
|
191
|
+
function authorizationDelimiter(char, code) {
|
|
192
|
+
return code < 32 || code === 127 || /[\s"'\\,]/.test(char);
|
|
189
193
|
}
|
|
190
194
|
function quotedRange(text, start, opening, maxLength) {
|
|
191
195
|
const depth = opening[1].length,
|
|
@@ -323,6 +327,13 @@ function backslashes(text, index) {
|
|
|
323
327
|
return count;
|
|
324
328
|
}
|
|
325
329
|
|
|
330
|
+
function sourceReference(label, tail) {
|
|
331
|
+
return (
|
|
332
|
+
/\.(?:[cm]?[jt]sx?|py|go|rs|c|cpp|h|java|cs|rb|sh)$/iu.test(label) &&
|
|
333
|
+
/^\d+(?:[):]|\b)/u.test(tail)
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
326
337
|
function detectCredentials(text, emit) {
|
|
327
338
|
const details = { category: 'credential', type: 'SECRET', rule: 'context' };
|
|
328
339
|
for (const match of text.matchAll(credentialPrefix)) {
|
|
@@ -341,9 +352,12 @@ function detectCredentials(text, emit) {
|
|
|
341
352
|
let range = /^(?:proxy-)?authorization$/i.test(match[1])
|
|
342
353
|
? authorizationRange(text, start)
|
|
343
354
|
: valueRange(text, start, mode);
|
|
344
|
-
if (range && mode === 'prose'
|
|
355
|
+
if (range && mode === 'prose') {
|
|
345
356
|
range = generatedWord(text, range);
|
|
346
357
|
}
|
|
358
|
+
if (sourceReference(match[1], text.slice(start))) {
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
347
361
|
if (range) {
|
|
348
362
|
emitValue(text, range.start, range.end, emit, details, match[1]);
|
|
349
363
|
}
|
package/src/detection.js
CHANGED
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
credentialPlaceholder,
|
|
14
14
|
} from './credentials.js';
|
|
15
15
|
import { localAddress } from './public.js';
|
|
16
|
+
import { detectKnownPersonal } from './personal-variants.js';
|
|
17
|
+
import { detectDocumentEntities } from './document-entities.js';
|
|
16
18
|
|
|
17
19
|
export const REDACTED = '[REDACTED]';
|
|
18
20
|
export const failure = (code = 'ERR_SANITIZATION', location = {}) =>
|
|
@@ -132,6 +134,29 @@ function detectCookies(text, emit) {
|
|
|
132
134
|
|
|
133
135
|
export function nativeDetect(text, options, emit) {
|
|
134
136
|
const credential = { type: 'SECRET', category: 'credential' };
|
|
137
|
+
for (const value of options.knownSecrets ?? []) {
|
|
138
|
+
collectMatches(text, new RegExp(escapePattern(value), 'g'), emit, {
|
|
139
|
+
...credential,
|
|
140
|
+
rule: 'known-secret',
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
detectKnownPersonal(
|
|
144
|
+
text,
|
|
145
|
+
options.knownPersonal,
|
|
146
|
+
'known-personal',
|
|
147
|
+
emit,
|
|
148
|
+
options
|
|
149
|
+
);
|
|
150
|
+
detectKnownPersonal(
|
|
151
|
+
text,
|
|
152
|
+
options.confirmedPersonal,
|
|
153
|
+
'confirmed-personal',
|
|
154
|
+
emit,
|
|
155
|
+
options
|
|
156
|
+
);
|
|
157
|
+
if (options.knownOnly) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
135
160
|
detectGitleaks(
|
|
136
161
|
text,
|
|
137
162
|
emit,
|
|
@@ -142,34 +167,10 @@ export function nativeDetect(text, options, emit) {
|
|
|
142
167
|
for (const [rule, pattern] of serviceRules) {
|
|
143
168
|
collectMatches(text, pattern, emit, { ...credential, rule });
|
|
144
169
|
}
|
|
145
|
-
for (const value of options.knownSecrets ?? []) {
|
|
146
|
-
collectMatches(text, new RegExp(escapePattern(value), 'g'), emit, {
|
|
147
|
-
...credential,
|
|
148
|
-
rule: 'known-secret',
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
for (const entry of options.knownPersonal ?? []) {
|
|
152
|
-
collectMatches(text, new RegExp(escapePattern(entry.value), 'giu'), emit, {
|
|
153
|
-
type: entry.type,
|
|
154
|
-
category: 'personal',
|
|
155
|
-
rule: 'known-personal',
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
for (const entry of options.confirmedPersonal ?? []) {
|
|
159
|
-
const pattern = new RegExp(
|
|
160
|
-
`(?<![\\p{L}\\p{N}])${personalPattern(entry.value)}(?![\\p{L}\\p{N}])`,
|
|
161
|
-
'giu'
|
|
162
|
-
);
|
|
163
|
-
collectMatches(text, pattern, emit, {
|
|
164
|
-
type: entry.type,
|
|
165
|
-
category: 'personal',
|
|
166
|
-
rule: 'confirmed-personal',
|
|
167
|
-
confidence: 0.9,
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
170
|
detectContext(text, emit, options.credentialsOnly);
|
|
171
171
|
if (!options.credentialsOnly) {
|
|
172
172
|
detectPersonal(text, emit);
|
|
173
|
+
detectDocumentEntities(text, emit, options);
|
|
173
174
|
detectCatalog(text, emit);
|
|
174
175
|
detectIdentity(text, emit);
|
|
175
176
|
detectNational(text, emit);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { collectMatches, escapePattern } from './detection.js';
|
|
2
|
+
import { firstNames } from './names.js';
|
|
3
|
+
import { organizationCore, personalRegex } from './personal-variants.js';
|
|
4
|
+
import { documentVocabulary } from './document-vocabulary.js';
|
|
5
|
+
|
|
6
|
+
const words = (column) =>
|
|
7
|
+
[...new Set(documentVocabulary.flatMap((row) => row[column].split('|')))]
|
|
8
|
+
.sort((a, b) => b.length - a.length)
|
|
9
|
+
.map(escapePattern)
|
|
10
|
+
.join('|');
|
|
11
|
+
const legal = words(1),
|
|
12
|
+
school = words(2);
|
|
13
|
+
const proper = "[\\p{Lu}][\\p{L}\\p{M}’'&.-]{1,47}";
|
|
14
|
+
const latinName = `${proper}(?:[ \\t]+${proper}){0,5}`;
|
|
15
|
+
// Scripts without letter case need a bounded script-specific name instead.
|
|
16
|
+
const uncased =
|
|
17
|
+
'[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}\\p{Script=Arabic}\\p{Script=Hebrew}\\p{Script=Devanagari}\\p{Script=Thai}\\p{M}]{2,48}';
|
|
18
|
+
const name = `(?:${latinName}|${uncased}(?:[ \\t]+${uncased}){0,3})`;
|
|
19
|
+
const quoted = `(?:«[^«»\\r\\n]{1,160}»|“[^“”\\r\\n]{1,160}”|"[^"\\r\\n]{1,160}"|「[^「」\\r\\n]{1,160}」|『[^『』\\r\\n]{1,160}』|${name})`;
|
|
20
|
+
const boundary = '[\\p{L}\\p{M}\\p{N}_]';
|
|
21
|
+
const organizations = new RegExp(
|
|
22
|
+
`(?<!${boundary})(?:(?:${legal})[ \\t]*${quoted}|${quoted}[ \\t]*(?:${legal}))(?!${boundary})`,
|
|
23
|
+
'gu'
|
|
24
|
+
);
|
|
25
|
+
const schools = new RegExp(
|
|
26
|
+
`(?<!${boundary})(?:(?:${school})[ \\t]*${quoted}|${quoted}[ \\t]*(?:${school}))(?!${boundary})`,
|
|
27
|
+
'gu'
|
|
28
|
+
);
|
|
29
|
+
const places = new RegExp(
|
|
30
|
+
`(?<!${boundary})(?:${words(3)})(?!${boundary})`,
|
|
31
|
+
'gu'
|
|
32
|
+
);
|
|
33
|
+
const labelledPlace = new RegExp(
|
|
34
|
+
`(?<!${boundary})(?:[Bb]ased in|[Ll]ocated in|[Rr]elocated from|город|г\\.|местоположение|ubicado en|situé à|ansässig in|localizado em|所在地|位于|位於|위치|يقيم في|ממוקם ב|स्थित|merkezi|berlokasi di|đặt tại|ตั้งอยู่ที่)[ \\t]+(${name})(?!${boundary})`,
|
|
35
|
+
'gu'
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const ruWord = '[А-ЯЁа-яё][А-ЯЁа-яё-]{1,47}';
|
|
39
|
+
const ruSchool = new RegExp(
|
|
40
|
+
`(?<!${boundary})[А-ЯЁ][а-яё]{2,30}(?:-[А-ЯЁа-яё][а-яё]{1,30})?(?:[ \t]+[а-яё-]{2,40}){0,4}[ \t]+(?:университет|институт|колледж|школа|академия)(?:[ \t]+имени[ \t]+${ruWord}(?:[ \t]+[А-ЯЁ][а-яё-]{2,40}){0,2})?(?!${boundary})`,
|
|
41
|
+
'gu'
|
|
42
|
+
);
|
|
43
|
+
const ruAcronym =
|
|
44
|
+
/(?<![\p{L}\p{N}_])(?:НИУ[ \t]+[А-ЯЁ]{2,12}|(?:МГУ|СПбГУ|МГТУ|РГГУ|ВШЭ)(?=[, \t]+(?:магистратура|бакалавриат|университет|обучение)))(?![\p{L}\p{N}_])/gu;
|
|
45
|
+
const ruResidence =
|
|
46
|
+
/(?:[Гг]ород|[Пп]роживаю|[Пп]роживание|[Мм]есто жительства)[ \t]*:[ \t]*([А-ЯЁ][а-яё-]{2,47})|([А-ЯЁ][а-яё-]{2,47})(?=,[ \t]*(?:готов[а]?[ \t]+к[ \t]+переезду|Россия|Беларусь|Казахстан|Украина))/gu;
|
|
47
|
+
const ruIntroduction = new RegExp(
|
|
48
|
+
`(?:[Мм]еня зовут|[Сс] уважением)[, \t]+(${firstNames.ru.split(' ').join('|')})(?!${boundary})`,
|
|
49
|
+
'gu'
|
|
50
|
+
);
|
|
51
|
+
const groupOrganization = new RegExp(
|
|
52
|
+
`(?<!${boundary})${latinName}[ \t]+(?:Групп|Group|Холдинг|Holding)(?!${boundary})`,
|
|
53
|
+
'gu'
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
export function detectDocumentEntities(text, emit, options) {
|
|
57
|
+
if (!options.documentEntities) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const found = [];
|
|
61
|
+
const capture = (finding) => {
|
|
62
|
+
found.push(finding);
|
|
63
|
+
emit(finding);
|
|
64
|
+
};
|
|
65
|
+
for (const [type, rule, pattern, group, confidence] of [
|
|
66
|
+
['ORGANIZATION', 'organization-group', groupOrganization, 0, 0.7],
|
|
67
|
+
['EDUCATION', 'education-russian', ruSchool, 0, 0.65],
|
|
68
|
+
['EDUCATION', 'education-acronym', ruAcronym, 0, 0.65],
|
|
69
|
+
['PERSON', 'document-introduction', ruIntroduction, 1, 0.7],
|
|
70
|
+
['ORGANIZATION', 'organization-legal-form', organizations, 0, 0.7],
|
|
71
|
+
['EDUCATION', 'education-type', schools, 0, 0.65],
|
|
72
|
+
['LOCATION', 'location-gazetteer', places, 0, 0.6],
|
|
73
|
+
['LOCATION', 'location-label', labelledPlace, 1, 0.75],
|
|
74
|
+
]) {
|
|
75
|
+
collectMatches(
|
|
76
|
+
text,
|
|
77
|
+
pattern,
|
|
78
|
+
capture,
|
|
79
|
+
{ type, rule, category: 'personal', confidence },
|
|
80
|
+
group
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
for (const match of text.matchAll(ruResidence)) {
|
|
84
|
+
const value = match[1] ?? match[2];
|
|
85
|
+
const start = match.index + match[0].lastIndexOf(value);
|
|
86
|
+
emit({
|
|
87
|
+
start,
|
|
88
|
+
end: start + value.length,
|
|
89
|
+
type: 'LOCATION',
|
|
90
|
+
rule: 'location-residence',
|
|
91
|
+
category: 'personal',
|
|
92
|
+
confidence: 0.75,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
// Organization cores are confirmed only by a bounded legal-form finding.
|
|
96
|
+
for (const finding of found.filter((f) => f.type === 'ORGANIZATION')) {
|
|
97
|
+
const core = organizationCore(text.slice(finding.start, finding.end));
|
|
98
|
+
if (core.length < 3 || core.length > 256) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
for (const match of text.matchAll(personalRegex(core, 'ORGANIZATION'))) {
|
|
102
|
+
emit({
|
|
103
|
+
...finding,
|
|
104
|
+
start: match.index,
|
|
105
|
+
end: match.index + match[0].length,
|
|
106
|
+
rule: 'organization-core',
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Authored, deliberately finite vocabulary; no network or model downloads.
|
|
2
|
+
// Each row follows localeVocabulary: locale, legal forms, school types, places.
|
|
3
|
+
export const documentVocabulary = [
|
|
4
|
+
[
|
|
5
|
+
'en',
|
|
6
|
+
'Inc|LLC|Ltd|PLC|Corporation',
|
|
7
|
+
'University|College|Institute|School',
|
|
8
|
+
'Springfield|Riverton|London|New York|California',
|
|
9
|
+
],
|
|
10
|
+
[
|
|
11
|
+
'es',
|
|
12
|
+
'S.A.|S.L.|S.L.U.',
|
|
13
|
+
'Universidad|Colegio|Instituto|Escuela',
|
|
14
|
+
'Madrid|Barcelona|Andalucía',
|
|
15
|
+
],
|
|
16
|
+
[
|
|
17
|
+
'fr',
|
|
18
|
+
'SARL|SAS|SA|S.A.',
|
|
19
|
+
'Université|Collège|Institut|École',
|
|
20
|
+
'Paris|Lyon|Bretagne',
|
|
21
|
+
],
|
|
22
|
+
[
|
|
23
|
+
'de',
|
|
24
|
+
'GmbH|AG|KG|UG',
|
|
25
|
+
'Universität|Hochschule|Institut|Schule',
|
|
26
|
+
'Berlin|München|Bayern',
|
|
27
|
+
],
|
|
28
|
+
[
|
|
29
|
+
'pt',
|
|
30
|
+
'Ltda|S.A.|Unipessoal',
|
|
31
|
+
'Universidade|Faculdade|Instituto|Escola',
|
|
32
|
+
'Lisboa|São Paulo|Algarve',
|
|
33
|
+
],
|
|
34
|
+
[
|
|
35
|
+
'ru',
|
|
36
|
+
'ООО|АО|ПАО|ИП|ЗАО',
|
|
37
|
+
'Университет|университет|Институт|институт|Школа|школа',
|
|
38
|
+
'Москва|Санкт-Петербург|Казань|Татарстан|Ярославль|Кострома|Новосибирск|Екатеринбург|Нижний Новгород|Самара|Омск|Челябинск|Ростов-на-Дону|Уфа|Красноярск|Пермь|Воронеж|Волгоград|Саратов|Тюмень|Тольятти|Ижевск|Барнаул|Иркутск|Хабаровск|Владивосток|Махачкала|Томск|Оренбург|Кемерово|Рязань|Астрахань|Пенза|Липецк|Киров|Чебоксары|Тула|Калининград|Курск|Улан-Удэ|Ставрополь|Сочи|Тверь|Брянск|Белгород|Владимир|Архангельск|Калуга|Смоленск|Вологда|Саранск|Орёл',
|
|
39
|
+
],
|
|
40
|
+
['ja', '株式会社|有限会社|合同会社', '大学|学院|学校', '東京|大阪|北海道'],
|
|
41
|
+
['zh', '有限公司|股份有限公司', '大学|学院|学校', '北京|上海|广东'],
|
|
42
|
+
['zh-Hant', '有限公司|股份有限公司', '大學|學院|學校', '臺北|台北|高雄|臺灣'],
|
|
43
|
+
['ko', '주식회사|유한회사', '대학교|대학|학교', '서울|부산|경기도'],
|
|
44
|
+
['ar', 'ذ.م.م|شركة', 'جامعة|معهد|مدرسة', 'القاهرة|دبي|الرياض'],
|
|
45
|
+
[
|
|
46
|
+
'he',
|
|
47
|
+
'בע״מ|בע"מ|חברה',
|
|
48
|
+
'אוניברסיטת|אוניברסיטה|מכללת|מכון',
|
|
49
|
+
'ירושלים|תל אביב|חיפה',
|
|
50
|
+
],
|
|
51
|
+
[
|
|
52
|
+
'hi',
|
|
53
|
+
'प्राइवेट लिमिटेड|लिमिटेड',
|
|
54
|
+
'विश्वविद्यालय|महाविद्यालय|संस्थान',
|
|
55
|
+
'दिल्ली|मुंबई|महाराष्ट्र',
|
|
56
|
+
],
|
|
57
|
+
[
|
|
58
|
+
'tr',
|
|
59
|
+
'A.Ş.|Ltd. Şti.',
|
|
60
|
+
'Üniversitesi|Üniversite|Enstitüsü|Okulu',
|
|
61
|
+
'İstanbul|Ankara|İzmir',
|
|
62
|
+
],
|
|
63
|
+
['id', 'PT|CV|Tbk', 'Universitas|Institut|Sekolah', 'Jakarta|Bandung|Bali'],
|
|
64
|
+
['vi', 'TNHH|CTCP', 'Đại học|Trường|Học viện', 'Hà Nội|Hồ Chí Minh|Đà Nẵng'],
|
|
65
|
+
[
|
|
66
|
+
'th',
|
|
67
|
+
'บริษัท|จำกัด',
|
|
68
|
+
'มหาวิทยาลัย|วิทยาลัย|โรงเรียน',
|
|
69
|
+
'กรุงเทพมหานคร|เชียงใหม่|ภูเก็ต',
|
|
70
|
+
],
|
|
71
|
+
];
|
package/src/engines.js
CHANGED
|
@@ -16,6 +16,12 @@ import {
|
|
|
16
16
|
import { failure, internalFailure } from './detection.js';
|
|
17
17
|
import { projectText } from './projection.js';
|
|
18
18
|
import { decodedRuns } from './encoded.js';
|
|
19
|
+
import {
|
|
20
|
+
initializePlaceholders,
|
|
21
|
+
placeholderKnown,
|
|
22
|
+
hasPlaceholders,
|
|
23
|
+
discardPlaceholderState,
|
|
24
|
+
} from './placeholders.js';
|
|
19
25
|
import {
|
|
20
26
|
structuredBatches,
|
|
21
27
|
mapStructuredFindings,
|
|
@@ -149,14 +155,19 @@ async function analyzeStructured(
|
|
|
149
155
|
export function createSanitizer(options = {}) {
|
|
150
156
|
options = applyProfile(options);
|
|
151
157
|
validateOptions(options);
|
|
158
|
+
options = initializePlaceholders(options);
|
|
152
159
|
for (const detector of options.detectors ?? []) {
|
|
153
160
|
if (!detector || typeof detector.detect !== 'function') {
|
|
154
161
|
throw failure('ERR_CONFIG');
|
|
155
162
|
}
|
|
156
163
|
}
|
|
157
164
|
const engines =
|
|
158
|
-
options.secretlint === false
|
|
159
|
-
|
|
165
|
+
options.secretlint === false || options.knownOnly
|
|
166
|
+
? []
|
|
167
|
+
: [{ detect: secretlintDetect }];
|
|
168
|
+
if (!options.knownOnly) {
|
|
169
|
+
engines.push(...(options.detectors ?? []));
|
|
170
|
+
}
|
|
160
171
|
let prepared;
|
|
161
172
|
async function analyze(
|
|
162
173
|
text,
|
|
@@ -165,7 +176,7 @@ export function createSanitizer(options = {}) {
|
|
|
165
176
|
scopedOptions = options,
|
|
166
177
|
nativeFindings
|
|
167
178
|
) {
|
|
168
|
-
const options = scopedOptions;
|
|
179
|
+
const options = placeholderKnown(scopedOptions);
|
|
169
180
|
validateInput(text, options);
|
|
170
181
|
if (options.structured && !plain) {
|
|
171
182
|
return analyzeStructured(text, initial, options, nativeFindings, analyze);
|
|
@@ -203,20 +214,8 @@ export function createSanitizer(options = {}) {
|
|
|
203
214
|
}
|
|
204
215
|
|
|
205
216
|
const sanitizer = {
|
|
206
|
-
inspect:
|
|
207
|
-
|
|
208
|
-
return await analyze(...args);
|
|
209
|
-
} catch (error) {
|
|
210
|
-
throw internalFailure(error);
|
|
211
|
-
}
|
|
212
|
-
},
|
|
213
|
-
sanitize: async (...args) => {
|
|
214
|
-
try {
|
|
215
|
-
return await sanitizeText(...args);
|
|
216
|
-
} catch (error) {
|
|
217
|
-
throw internalFailure(error);
|
|
218
|
-
}
|
|
219
|
-
},
|
|
217
|
+
inspect: guarded(analyze),
|
|
218
|
+
sanitize: guarded(sanitizeText, () => discardPlaceholderState(options)),
|
|
220
219
|
sanitizeJsonl: (text) =>
|
|
221
220
|
createSanitizer({ ...options, structured: 'jsonl' }).sanitize(text),
|
|
222
221
|
};
|
|
@@ -297,15 +296,40 @@ export function createSanitizer(options = {}) {
|
|
|
297
296
|
if (options.structured && !plain) {
|
|
298
297
|
verifyStructured(result.text, options);
|
|
299
298
|
}
|
|
299
|
+
if (await placeholderResidual(result.text, options, plain, analyze)) {
|
|
300
|
+
throw failure('ERR_RESIDUAL');
|
|
301
|
+
}
|
|
300
302
|
return { ...result, findings: auditFaked(findings, options, result) };
|
|
301
303
|
}
|
|
302
304
|
}
|
|
303
305
|
|
|
306
|
+
function guarded(callback, cleanup = () => {}) {
|
|
307
|
+
return async (...args) => {
|
|
308
|
+
try {
|
|
309
|
+
return await callback(...args);
|
|
310
|
+
} catch (error) {
|
|
311
|
+
throw internalFailure(error);
|
|
312
|
+
} finally {
|
|
313
|
+
cleanup();
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
async function placeholderResidual(text, options, plain, analyze) {
|
|
318
|
+
return (
|
|
319
|
+
hasPlaceholders(options) &&
|
|
320
|
+
(await analyze(text, [], plain, options)).some((f) =>
|
|
321
|
+
actionable(f, options)
|
|
322
|
+
)
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
304
326
|
function eligiblePublic(text, finding, findings) {
|
|
305
327
|
return (
|
|
306
328
|
finding.category !== 'credential' &&
|
|
307
329
|
!['known-personal', 'confirmed-personal'].includes(finding.rule) &&
|
|
308
|
-
['PERSON', 'ORGANIZATION', 'EMAIL'].includes(
|
|
330
|
+
['PERSON', 'ORGANIZATION', 'EDUCATION', 'LOCATION', 'EMAIL'].includes(
|
|
331
|
+
finding.type
|
|
332
|
+
) &&
|
|
309
333
|
!findings.some(
|
|
310
334
|
(f) =>
|
|
311
335
|
(f.category === 'credential' ||
|
package/src/entities.js
CHANGED
|
@@ -36,6 +36,13 @@ for (const [vendor, catalog] of Object.entries(entityCatalogs)) {
|
|
|
36
36
|
entries.set(name.toUpperCase(), entry);
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
|
+
export const catalogTypes = () => [
|
|
40
|
+
...new Set(
|
|
41
|
+
[...entries.values()]
|
|
42
|
+
.filter((entry) => entry.category === 'personal')
|
|
43
|
+
.map((entry) => (entry.type === 'DATE_OF_BIRTH' ? 'DATE' : entry.type))
|
|
44
|
+
),
|
|
45
|
+
];
|
|
39
46
|
// All documented names are native labelled recognizers; automatic format
|
|
40
47
|
// recognizers are separate and do not claim the vendors' model behavior.
|
|
41
48
|
const prefix = new RegExp(
|
package/src/fake-names.js
CHANGED
|
@@ -146,6 +146,58 @@ function scriptLocale(value) {
|
|
|
146
146
|
}
|
|
147
147
|
export function fakeName(value, key) {
|
|
148
148
|
indexNames();
|
|
149
|
+
const words = value.match(/[\p{L}\p{M}]+/gu) ?? [];
|
|
150
|
+
const roles = new Map();
|
|
151
|
+
let nameSex;
|
|
152
|
+
if (
|
|
153
|
+
/\p{Script=Cyrillic}/u.test(value) &&
|
|
154
|
+
words.length >= 2 &&
|
|
155
|
+
words.length <= 3
|
|
156
|
+
) {
|
|
157
|
+
const middle = words.findIndex((w) =>
|
|
158
|
+
/(?:ович|евич|ична|овна|евна)$/iu.test(w)
|
|
159
|
+
);
|
|
160
|
+
const surname = words.findIndex(
|
|
161
|
+
(w, i) =>
|
|
162
|
+
i !== middle &&
|
|
163
|
+
/(?:ов|ев|ин|ский|ова|ева|ина|ская)$/iu.test(w) &&
|
|
164
|
+
!given.has(latinName(w))
|
|
165
|
+
);
|
|
166
|
+
const genderWord = words[middle >= 0 ? middle : surname];
|
|
167
|
+
if (genderWord) {
|
|
168
|
+
nameSex = /(?:ова|ева|ина|ская|овна|евна|ична)$/iu.test(genderWord)
|
|
169
|
+
? 'female'
|
|
170
|
+
: 'male';
|
|
171
|
+
}
|
|
172
|
+
if (middle > 0) {
|
|
173
|
+
words.forEach((w, i) =>
|
|
174
|
+
roles.set(
|
|
175
|
+
w,
|
|
176
|
+
i === middle
|
|
177
|
+
? 'middleName'
|
|
178
|
+
: i === middle - 1
|
|
179
|
+
? 'firstName'
|
|
180
|
+
: 'lastName'
|
|
181
|
+
)
|
|
182
|
+
);
|
|
183
|
+
} else if (surname >= 0) {
|
|
184
|
+
words.forEach((w, i) =>
|
|
185
|
+
roles.set(w, i === surname ? 'lastName' : 'firstName')
|
|
186
|
+
);
|
|
187
|
+
} else {
|
|
188
|
+
// Ambiguous spans receive a complete name with the same token count.
|
|
189
|
+
words.forEach((w, i) =>
|
|
190
|
+
roles.set(
|
|
191
|
+
w,
|
|
192
|
+
i === 0
|
|
193
|
+
? 'firstName'
|
|
194
|
+
: i === words.length - 1
|
|
195
|
+
? 'lastName'
|
|
196
|
+
: 'middleName'
|
|
197
|
+
)
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
149
201
|
return value.replace(/[\p{L}\p{M}]+/gu, (word) => {
|
|
150
202
|
for (const locale of ['ja', 'zh', 'ko']) {
|
|
151
203
|
const family = firstNames[locale]
|
|
@@ -158,7 +210,7 @@ export function fakeName(value, key) {
|
|
|
158
210
|
);
|
|
159
211
|
}
|
|
160
212
|
}
|
|
161
|
-
return fakeWord(word, key);
|
|
213
|
+
return fakeWord(word, key, roles.get(word), undefined, nameSex);
|
|
162
214
|
});
|
|
163
215
|
}
|
|
164
216
|
function wordProfile(word, forcedRole, forcedLocale) {
|
|
@@ -188,12 +240,14 @@ function wordProfile(word, forcedRole, forcedLocale) {
|
|
|
188
240
|
: 'lastName');
|
|
189
241
|
return { canonical, locale, sex, role };
|
|
190
242
|
}
|
|
191
|
-
function fakeWord(word, key, forcedRole, forcedLocale) {
|
|
192
|
-
const {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
243
|
+
function fakeWord(word, key, forcedRole, forcedLocale, forcedSex) {
|
|
244
|
+
const {
|
|
245
|
+
canonical,
|
|
246
|
+
locale,
|
|
247
|
+
sex: detectedSex,
|
|
248
|
+
role,
|
|
249
|
+
} = wordProfile(word, forcedRole, forcedLocale);
|
|
250
|
+
const sex = forcedSex ?? detectedSex;
|
|
197
251
|
const faker = new Faker({ locale: [locales[locale] ?? en, en] });
|
|
198
252
|
faker.seed(
|
|
199
253
|
createHmac('sha256', key)
|
package/src/fake.js
CHANGED
|
@@ -501,6 +501,13 @@ export function realisticFake(value, finding, transform) {
|
|
|
501
501
|
.slice(0, 8)}!`;
|
|
502
502
|
} else if (finding.type === 'PERSON') {
|
|
503
503
|
output = fakeName(value, transform.key);
|
|
504
|
+
} else if (['ORGANIZATION', 'EDUCATION', 'LOCATION'].includes(finding.type)) {
|
|
505
|
+
const label = {
|
|
506
|
+
ORGANIZATION: 'Organization',
|
|
507
|
+
EDUCATION: 'School',
|
|
508
|
+
LOCATION: 'Location',
|
|
509
|
+
}[finding.type];
|
|
510
|
+
output = `Example ${label} ${digest(value, transform.key, finding.type).readUInt32BE(0)}`;
|
|
504
511
|
} else if (/DATE|BIRTH/.test(finding.type)) {
|
|
505
512
|
output = fakeDate(value, transform.key);
|
|
506
513
|
} else if (finding.type === 'PASSPORT_MRZ') {
|
package/src/index.d.ts
CHANGED
|
@@ -27,7 +27,7 @@ export interface PersonalValue {
|
|
|
27
27
|
value: string;
|
|
28
28
|
}
|
|
29
29
|
export interface PublicEntity {
|
|
30
|
-
type: 'PERSON' | 'ORGANIZATION' | 'EMAIL';
|
|
30
|
+
type: 'PERSON' | 'ORGANIZATION' | 'EDUCATION' | 'LOCATION' | 'EMAIL';
|
|
31
31
|
value: string;
|
|
32
32
|
/** Organization-controlled HTTPS evidence, reviewed by the policy author. */
|
|
33
33
|
source: string;
|
|
@@ -42,7 +42,11 @@ export interface Detector {
|
|
|
42
42
|
export interface SanitizerOptions {
|
|
43
43
|
/** Preserve all personal/account data; credential findings still redact. */
|
|
44
44
|
credentialsOnly?: boolean;
|
|
45
|
-
|
|
45
|
+
/** Replace only knownPersonal/knownSecrets and reused map values; skip automatic detectors. */
|
|
46
|
+
knownOnly?: boolean;
|
|
47
|
+
profile?: 'publication' | 'documents';
|
|
48
|
+
/** Opt in to bounded employer, school, city/region heuristics. */
|
|
49
|
+
documentEntities?: boolean;
|
|
46
50
|
confirmedPersonal?: Array<{ value: string; type: string }>;
|
|
47
51
|
knownSecrets?: string[];
|
|
48
52
|
knownPersonal?: PersonalValue[];
|
|
@@ -95,6 +99,10 @@ export interface SanitizedText {
|
|
|
95
99
|
findings: Finding[];
|
|
96
100
|
/** Number of disjoint replacements after overlapping detections are merged. */
|
|
97
101
|
redactions: number;
|
|
102
|
+
/** Placeholder-mode replacements absent from the restore map (e.g. credentials). */
|
|
103
|
+
irreversibleRedactions?: number;
|
|
104
|
+
/** Private personal data. Absent for map:false and non-placeholder modes. */
|
|
105
|
+
map?: RestoreMap;
|
|
98
106
|
}
|
|
99
107
|
export interface Sanitizer {
|
|
100
108
|
inspect(text: string): Promise<Finding[]>;
|
|
@@ -220,6 +228,12 @@ export declare function auditGitHistory(
|
|
|
220
228
|
): Promise<HistoryAudit>;
|
|
221
229
|
export type Transformation =
|
|
222
230
|
| { mode: 'redact' | 'hive-mask' }
|
|
231
|
+
| {
|
|
232
|
+
mode: 'placeholder';
|
|
233
|
+
map?: RestoreMap | false;
|
|
234
|
+
format?: string;
|
|
235
|
+
credentials?: boolean;
|
|
236
|
+
}
|
|
223
237
|
| {
|
|
224
238
|
mode: 'mask';
|
|
225
239
|
keepStart?: number;
|
|
@@ -239,6 +253,45 @@ export type Transformation =
|
|
|
239
253
|
}
|
|
240
254
|
| { mode: 'date-shift'; days: number }
|
|
241
255
|
| { mode: 'bucket'; size: number };
|
|
256
|
+
|
|
257
|
+
/** Contains original personal data; never publish this object or its files. */
|
|
258
|
+
export interface RestoreMap {
|
|
259
|
+
kind: 'sensitive-data-restore-map';
|
|
260
|
+
version: 1;
|
|
261
|
+
format: string;
|
|
262
|
+
entries: Array<{
|
|
263
|
+
token: string;
|
|
264
|
+
type: string;
|
|
265
|
+
number: number;
|
|
266
|
+
values: string[];
|
|
267
|
+
aliases: string[];
|
|
268
|
+
}>;
|
|
269
|
+
documents: Array<{
|
|
270
|
+
digest: string;
|
|
271
|
+
occurrences: Array<{
|
|
272
|
+
start: number;
|
|
273
|
+
end: number;
|
|
274
|
+
token: string;
|
|
275
|
+
raw: string;
|
|
276
|
+
}>;
|
|
277
|
+
}>;
|
|
278
|
+
}
|
|
279
|
+
export declare function createRestoreMap(options?: {
|
|
280
|
+
format?: string;
|
|
281
|
+
}): RestoreMap;
|
|
282
|
+
export declare function serializeRestoreMap(
|
|
283
|
+
map: RestoreMap,
|
|
284
|
+
format?: 'json' | 'lino'
|
|
285
|
+
): string;
|
|
286
|
+
export declare function parseRestoreMap(text: string): RestoreMap;
|
|
287
|
+
export declare function readRestoreMap(path: string): Promise<RestoreMap>;
|
|
288
|
+
export declare function writeRestoreMap(
|
|
289
|
+
path: string,
|
|
290
|
+
map: RestoreMap,
|
|
291
|
+
options?: { format?: 'json' | 'lino'; replace?: boolean }
|
|
292
|
+
): Promise<void>;
|
|
293
|
+
/** Exact original-document restoration; derived text uses canonical surfaces. */
|
|
294
|
+
export declare function restore(text: string, map: RestoreMap): string;
|
|
242
295
|
export interface StreamOptions extends SanitizerOptions {
|
|
243
296
|
/** Treat NUL as a log byte; default false. Incompatible with JSON/JSONL. */
|
|
244
297
|
allowBinary?: boolean;
|
package/src/index.js
CHANGED
|
@@ -38,3 +38,11 @@ export { knownSecretsFromGitHubAuth } from './known.js';
|
|
|
38
38
|
export { knownSecretsFromGitHubConfig } from './known.js';
|
|
39
39
|
export { decodeLogBytes, encodeLogText } from './log-bytes.js';
|
|
40
40
|
export { rewriteGitHistory } from './history-rewrite.js';
|
|
41
|
+
export {
|
|
42
|
+
restore,
|
|
43
|
+
createRestoreMap,
|
|
44
|
+
serializeRestoreMap,
|
|
45
|
+
parseRestoreMap,
|
|
46
|
+
readRestoreMap,
|
|
47
|
+
writeRestoreMap,
|
|
48
|
+
} from './restore-map.js';
|