@link-foundation/sensitive-data-sanitizer 0.17.1 → 0.19.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 +15 -0
- package/README.md +25 -0
- package/bin/sensitive-data-sanitizer.js +8 -0
- package/docs/API.md +266 -34
- package/docs/COVERAGE.md +135 -13
- package/docs/RULES.md +403 -0
- package/examples/custom-log-policy.mjs +23 -0
- package/examples/prose-logs.mjs +16 -0
- package/package.json +18 -2
- package/src/compiled-known.js +55 -0
- package/src/context.js +9 -1
- package/src/detection.js +11 -1
- package/src/document-catalog.js +246 -0
- package/src/document-dates.js +15 -0
- package/src/document-entities.js +27 -14
- package/src/document-experience.js +115 -0
- package/src/document-extras.js +96 -64
- package/src/document-fields.js +3 -1
- package/src/document-vocabulary.js +80 -68
- package/src/engines.js +93 -36
- package/src/index.d.ts +57 -4
- package/src/index.js +5 -0
- package/src/jsonl-stream.js +2 -5
- package/src/ner.d.ts +5 -0
- package/src/ner.js +96 -0
- package/src/personal-variants.js +29 -8
- package/src/placeholder-types.js +1 -0
- package/src/policy.js +107 -0
- package/src/propagation.js +5 -1
- package/src/proper-names.js +222 -0
- package/src/record-worker.js +6 -0
- package/src/rule-catalog.js +669 -0
- package/src/sanitizer.js +114 -21
- package/src/stream-worker.js +5 -0
- package/src/stream.js +11 -6
- package/src/strict.js +66 -0
- package/src/structured.js +131 -59
- package/src/sync-detectors.js +38 -0
- package/src/sync-sanitizer.js +16 -0
- package/src/transforms.js +85 -7
- package/src/vocabulary.js +217 -0
- package/src/worker-options.js +12 -0
package/src/index.d.ts
CHANGED
|
@@ -21,7 +21,20 @@ export interface Finding {
|
|
|
21
21
|
export interface PersonalValue {
|
|
22
22
|
type: string;
|
|
23
23
|
value: string;
|
|
24
|
+
properCase?: boolean;
|
|
24
25
|
}
|
|
26
|
+
export type VocabularyList =
|
|
27
|
+
readonly string[] | { values: readonly string[]; replace: true };
|
|
28
|
+
export type Vocabulary = Partial<
|
|
29
|
+
Record<
|
|
30
|
+
| 'roles'
|
|
31
|
+
| 'organizationCues'
|
|
32
|
+
| 'educationCues'
|
|
33
|
+
| 'personalLabels'
|
|
34
|
+
| 'personalFields',
|
|
35
|
+
VocabularyList
|
|
36
|
+
>
|
|
37
|
+
>;
|
|
25
38
|
export interface PublicEntity {
|
|
26
39
|
type: 'PERSON' | 'ORGANIZATION' | 'EDUCATION' | 'LOCATION' | 'EMAIL';
|
|
27
40
|
value: string;
|
|
@@ -38,14 +51,28 @@ export interface Detector {
|
|
|
38
51
|
export interface SanitizerOptions {
|
|
39
52
|
/** Preserve all personal/account data; credential findings still redact. */
|
|
40
53
|
credentialsOnly?: boolean;
|
|
41
|
-
/**
|
|
54
|
+
/** Use explicit findings, knownPersonal/knownSecrets and reused map values; skip automatic detectors. */
|
|
42
55
|
knownOnly?: boolean;
|
|
43
56
|
profile?: 'publication' | 'documents';
|
|
44
57
|
/** Opt in to bounded employer, school, city/region heuristics. */
|
|
45
58
|
documentEntities?: boolean;
|
|
59
|
+
/** Opt-in aggressive stored-log attributes, ages, dates and numeric shapes. */
|
|
60
|
+
strict?: boolean;
|
|
61
|
+
vocabulary?: Vocabulary;
|
|
62
|
+
/** Stable ids from builtInRules. Unknown ids fail with ERR_CONFIG. */
|
|
63
|
+
rules?: Record<string, boolean>;
|
|
64
|
+
types?: Record<string, boolean>;
|
|
65
|
+
/** Required to disable any built-in credential rule or type. */
|
|
66
|
+
allowCredentialDisable?: boolean;
|
|
67
|
+
/** Treat a built-in property as ordinary scanned text. */
|
|
68
|
+
fields?: Record<string, 'scan'>;
|
|
69
|
+
/** Aggressive structural proper names in prose values; opt-in, off by default. */
|
|
70
|
+
properNames?: 'capitalized';
|
|
71
|
+
/** Unicode Script aliases that activate prose detection; default ['Cyrillic']. */
|
|
72
|
+
scripts?: string[];
|
|
46
73
|
confirmedPersonal?: Array<{ value: string; type: string }>;
|
|
47
74
|
knownSecrets?: string[];
|
|
48
|
-
knownPersonal?: PersonalValue[];
|
|
75
|
+
knownPersonal?: readonly PersonalValue[];
|
|
49
76
|
publicEntities?: PublicEntity[];
|
|
50
77
|
/** Built-in exact public entities/resolvers and role mail on known domains. */
|
|
51
78
|
publicKnowledge?: boolean;
|
|
@@ -63,8 +90,12 @@ export interface SanitizerOptions {
|
|
|
63
90
|
structured?: 'json' | 'jsonl';
|
|
64
91
|
/** Property names whose metadata heuristics may be suppressed. */
|
|
65
92
|
structuralFields?: string[];
|
|
66
|
-
/**
|
|
93
|
+
/** Normalized property names or dotted paths with *; entire JSON/JSONL values redact. */
|
|
67
94
|
sensitiveFields?: string[];
|
|
95
|
+
/** Match sensitiveFields/allowedFields with exact case and punctuation. */
|
|
96
|
+
exactFields?: boolean;
|
|
97
|
+
/** Only these string leaves remain eligible to keep; listed strings are still scanned. */
|
|
98
|
+
allowedFields?: string[];
|
|
68
99
|
/** Personal replacement threshold, default 0.5. Inspect retains lower scores. */
|
|
69
100
|
minConfidence?: number;
|
|
70
101
|
/** Alias for minConfidence; specifying both requires equal values. */
|
|
@@ -80,7 +111,7 @@ export interface SanitizerOptions {
|
|
|
80
111
|
maxInputLength?: number;
|
|
81
112
|
maxFindings?: number;
|
|
82
113
|
debug?: (event: DebugEvent) => void;
|
|
83
|
-
/**
|
|
114
|
+
/** Required callbacks; synchronous functions reject Promise-returning detectors. */
|
|
84
115
|
detectors?: Detector[];
|
|
85
116
|
/** Required by default in createSanitizer. */
|
|
86
117
|
secretlint?: boolean;
|
|
@@ -88,6 +119,7 @@ export interface SanitizerOptions {
|
|
|
88
119
|
/** Value-free diagnostics; enabled only when a debug callback is supplied. */
|
|
89
120
|
export type DebugEvent =
|
|
90
121
|
| { event: 'detected'; count: number }
|
|
122
|
+
| { event: 'document-experience' | 'proper-name-prose'; count: number }
|
|
91
123
|
| { event: 'residual-redaction'; round: number }
|
|
92
124
|
| { event: 'two-pass-confirmation'; type: string }
|
|
93
125
|
| { event: 'propagation-skipped'; reason: 'length'; type: string }
|
|
@@ -106,6 +138,22 @@ export interface Sanitizer {
|
|
|
106
138
|
inspect(text: string): Promise<Finding[]>;
|
|
107
139
|
sanitize(text: string): Promise<SanitizedText>;
|
|
108
140
|
}
|
|
141
|
+
export interface SyncSanitizer {
|
|
142
|
+
inspect(text: string): Finding[];
|
|
143
|
+
sanitize(text: string): SanitizedText;
|
|
144
|
+
}
|
|
145
|
+
export declare function createSyncSanitizer(
|
|
146
|
+
options?: SanitizerOptions
|
|
147
|
+
): SyncSanitizer;
|
|
148
|
+
/** Copies and freezes caller values, merging lists and caching matching/projection. */
|
|
149
|
+
export declare function compileKnown(
|
|
150
|
+
...lists: Array<readonly PersonalValue[]>
|
|
151
|
+
): readonly Readonly<PersonalValue>[];
|
|
152
|
+
export declare const builtInRules: readonly string[];
|
|
153
|
+
export declare const defaultVocabulary: Readonly<
|
|
154
|
+
Record<keyof Vocabulary, readonly string[]>
|
|
155
|
+
>;
|
|
156
|
+
export declare const documentVocabulary: readonly (readonly string[])[];
|
|
109
157
|
export interface JsonlSanitizer extends Sanitizer {
|
|
110
158
|
sanitizeJsonl(text: string): Promise<SanitizedText>;
|
|
111
159
|
}
|
|
@@ -224,7 +272,12 @@ export declare function auditGitHistory(
|
|
|
224
272
|
maxTotalBytes?: number;
|
|
225
273
|
}
|
|
226
274
|
): Promise<HistoryAudit>;
|
|
275
|
+
/** Callback receives private matched data. Sync APIs require an immediate string. */
|
|
276
|
+
export type TransformationCallback = (
|
|
277
|
+
finding: Readonly<Finding & { value: string }>
|
|
278
|
+
) => string | Promise<string>;
|
|
227
279
|
export type Transformation =
|
|
280
|
+
| TransformationCallback
|
|
228
281
|
| { mode: 'redact' | 'hive-mask' }
|
|
229
282
|
| {
|
|
230
283
|
mode: 'placeholder';
|
package/src/index.js
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
export { inspect, sanitize, redact } from './sanitizer.js';
|
|
4
4
|
export { createSanitizer } from './engines.js';
|
|
5
|
+
export { compileKnown } from './compiled-known.js';
|
|
6
|
+
export { builtInRules } from './rule-catalog.js';
|
|
7
|
+
export { defaultVocabulary } from './vocabulary.js';
|
|
8
|
+
export { documentVocabulary } from './document-vocabulary.js';
|
|
9
|
+
export { createSyncSanitizer } from './sync-sanitizer.js';
|
|
5
10
|
export { REDACTED, entropy, luhn } from './detection.js';
|
|
6
11
|
export { codePointRange } from './projection.js';
|
|
7
12
|
export {
|
package/src/jsonl-stream.js
CHANGED
|
@@ -3,7 +3,7 @@ import { Worker } from 'node:worker_threads';
|
|
|
3
3
|
import { URL } from 'node:url';
|
|
4
4
|
import { failure } from './detection.js';
|
|
5
5
|
import { encodingFailure } from './utf8.js';
|
|
6
|
-
import { workerExecArgv } from './worker-options.js';
|
|
6
|
+
import { workerExecArgv, hasWorkerCallbacks } from './worker-options.js';
|
|
7
7
|
import { prepareStreamEngine } from './engines.js';
|
|
8
8
|
import { inspect } from './sanitizer.js';
|
|
9
9
|
import { hasPlaceholders } from './placeholders.js';
|
|
@@ -129,10 +129,7 @@ function workerCount(options, config) {
|
|
|
129
129
|
return 1;
|
|
130
130
|
}
|
|
131
131
|
const callbacks =
|
|
132
|
-
options.sanitizer ||
|
|
133
|
-
config.sanitizerOptions.debug ||
|
|
134
|
-
config.sanitizerOptions.verifyPublic ||
|
|
135
|
-
config.sanitizerOptions.detectors?.length;
|
|
132
|
+
options.sanitizer || hasWorkerCallbacks(config.sanitizerOptions);
|
|
136
133
|
const count =
|
|
137
134
|
options.workers ?? (callbacks || typeof Deno !== 'undefined' ? 1 : 2);
|
|
138
135
|
if (
|
package/src/ner.d.ts
ADDED
package/src/ner.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Optional entry: no core module imports NLP.js or this recognizer.
|
|
2
|
+
import { firstNames } from './names.js';
|
|
3
|
+
import { documentVocabulary } from './document-vocabulary.js';
|
|
4
|
+
|
|
5
|
+
const boundary = '[\\p{L}\\p{M}\\p{N}_]';
|
|
6
|
+
const proper = "\\p{Lu}[\\p{L}\\p{M}’'-]{1,47}";
|
|
7
|
+
const name = `${proper}(?:[ \\t]+${proper}){0,3}`;
|
|
8
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
9
|
+
const blocked =
|
|
10
|
+
/^(?:Kubernetes|PostgreSQL|Python|JavaScript|TypeScript|Node|Docker|Redis|Linux|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday|REDACTED)$/iu;
|
|
11
|
+
const configError = () =>
|
|
12
|
+
Object.assign(new Error('Invalid offline recognizer configuration.'), {
|
|
13
|
+
code: 'ERR_CONFIG',
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function rulesFor(language) {
|
|
17
|
+
const names = firstNames[language]
|
|
18
|
+
.split(' ')
|
|
19
|
+
.filter((s) => !/^(?:May|Will|Mark|Rose)$/u.test(s));
|
|
20
|
+
const places = documentVocabulary
|
|
21
|
+
.filter((row) => row[0] === language)
|
|
22
|
+
.flatMap((row) => row[3].split('|'));
|
|
23
|
+
const rules = [
|
|
24
|
+
['PERSON', `(?:${names.map(escape).join('|')})(?:[ \\t]+${proper})?`],
|
|
25
|
+
['LOCATION', `(?:${places.map(escape).join('|') || '(?!)'})`],
|
|
26
|
+
];
|
|
27
|
+
if (language === 'ru') {
|
|
28
|
+
rules.push(
|
|
29
|
+
['ORGANIZATION', `(?<=(?:[Рр]аботал[аи]?|[Сс]лужил[аи]?) в )${name}`],
|
|
30
|
+
[
|
|
31
|
+
'ORGANIZATION',
|
|
32
|
+
`${name}(?=:[ \\t]*(?:менеджер|директор|разработчик|инженер|аналитик|маркетолог)(?!\\p{L}))`,
|
|
33
|
+
],
|
|
34
|
+
['EDUCATION', '(?:МГУ|СПбГУ|МГТУ|РГГУ|ВШЭ|МФТИ|ИТМО)'],
|
|
35
|
+
['EDUCATION', `${name}[ \\t]+(?:университет|институт|академия)`],
|
|
36
|
+
['LOCATION', `(?<=(?:[Жж]иву|[Пп]роживаю) в )${name}`]
|
|
37
|
+
);
|
|
38
|
+
} else {
|
|
39
|
+
rules.push(
|
|
40
|
+
['ORGANIZATION', `(?<=(?:[Aa]t|[Jj]oined|[Ee]mployer:) )${name}`],
|
|
41
|
+
['EDUCATION', `(?<=(?:[Gg]raduated from|[Ss]tudied at) )${name}`],
|
|
42
|
+
[
|
|
43
|
+
'EDUCATION',
|
|
44
|
+
'(?:MIT|MSU|UCLA|UCL|NYU|Harvard|Stanford|Oxford|Cambridge)',
|
|
45
|
+
],
|
|
46
|
+
['EDUCATION', `${name}[ \\t]+(?:University|College|Institute)`],
|
|
47
|
+
['LOCATION', `(?<=(?:[Ll]ive in|[Ll]ocated in|[Bb]ased in) )${name}`]
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return rules.map(([type, source]) => ({
|
|
51
|
+
name: type,
|
|
52
|
+
type: 'regex',
|
|
53
|
+
rules: [new RegExp(`(?<!${boundary})(?:${source})(?!${boundary})`, 'gu')],
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Prepare an offline recognizer with synchronous detection. */
|
|
58
|
+
export async function createNerDetector(options = {}) {
|
|
59
|
+
const languages = options.languages ?? ['ru', 'en'];
|
|
60
|
+
if (
|
|
61
|
+
!Array.isArray(languages) ||
|
|
62
|
+
!languages.length ||
|
|
63
|
+
languages.length > 2 ||
|
|
64
|
+
languages.some((s) => !['ru', 'en'].includes(s)) ||
|
|
65
|
+
Object.keys(options).some((key) => !['languages', 'debug'].includes(key)) ||
|
|
66
|
+
(options.debug !== undefined && typeof options.debug !== 'function')
|
|
67
|
+
) {
|
|
68
|
+
throw configError();
|
|
69
|
+
}
|
|
70
|
+
const { ExtractorRegex } = await import('@nlpjs/ner');
|
|
71
|
+
const extractor = new ExtractorRegex();
|
|
72
|
+
const nerRules = [...new Set(languages)].flatMap(rulesFor);
|
|
73
|
+
return Object.freeze({
|
|
74
|
+
id: 'offline-ner',
|
|
75
|
+
detect(text) {
|
|
76
|
+
if (typeof text !== 'string' || text.length > 1024 * 1024) {
|
|
77
|
+
throw Object.assign(new Error('Offline recognizer input limit.'), {
|
|
78
|
+
code: 'ERR_LIMIT',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
const findings = extractor
|
|
82
|
+
.extract({ text, nerRules })
|
|
83
|
+
.edges.filter((edge) => !blocked.test(edge.sourceText))
|
|
84
|
+
.map((edge) => ({
|
|
85
|
+
start: edge.start,
|
|
86
|
+
end: edge.end + 1,
|
|
87
|
+
type: edge.entity,
|
|
88
|
+
category: 'personal',
|
|
89
|
+
rule: 'ner/offline',
|
|
90
|
+
confidence: 0.8,
|
|
91
|
+
}));
|
|
92
|
+
options.debug?.({ event: 'offline-ner', count: findings.length });
|
|
93
|
+
return findings;
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
package/src/personal-variants.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { escapePattern, failure } from './detection.js';
|
|
2
2
|
import { russianNameRoles } from './russian-names.js';
|
|
3
|
+
import { compiledPatterns } from './compiled-known.js';
|
|
3
4
|
|
|
4
5
|
const boundary = '[\\p{L}\\p{M}\\p{N}_]';
|
|
5
6
|
export function organizationCore(value) {
|
|
@@ -143,6 +144,22 @@ export function personalPatterns(value, type) {
|
|
|
143
144
|
return [...patterns];
|
|
144
145
|
}
|
|
145
146
|
export function personalRegex(value, type) {
|
|
147
|
+
if (type === 'URL') {
|
|
148
|
+
let core = value.replace(/^https?:\/\//iu, '');
|
|
149
|
+
const suffix = core.search(/[?#]/u);
|
|
150
|
+
if (suffix >= 0) {
|
|
151
|
+
core = core.slice(0, suffix);
|
|
152
|
+
}
|
|
153
|
+
let end = core.length;
|
|
154
|
+
while (end > 0 && core[end - 1] === '/') {
|
|
155
|
+
end--;
|
|
156
|
+
}
|
|
157
|
+
core = core.slice(0, end);
|
|
158
|
+
return new RegExp(
|
|
159
|
+
`(?<![\\p{L}\\p{N}_./-])(?:https?:\\/\\/)?${escapePattern(core)}(?:\\/)?(?:[?#][^\\s"<>\\\\]*)?(?![\\p{L}\\p{N}_-])`,
|
|
160
|
+
'giu'
|
|
161
|
+
);
|
|
162
|
+
}
|
|
146
163
|
return new RegExp(
|
|
147
164
|
`(?<!${boundary})(?:${personalPatterns(value, type).join('|')})(?!${boundary})`,
|
|
148
165
|
'giu'
|
|
@@ -153,17 +170,21 @@ export function samePersonal(value, original, type) {
|
|
|
153
170
|
new RegExp(`^(?:${pattern})$`, 'iu').test(value)
|
|
154
171
|
);
|
|
155
172
|
}
|
|
173
|
+
function confirmedFamily(entry, rule, options) {
|
|
174
|
+
return rule === 'confirmed-personal' &&
|
|
175
|
+
options.documentEntities &&
|
|
176
|
+
!options.knownOnly &&
|
|
177
|
+
entry.type === 'ORGANIZATION'
|
|
178
|
+
? organizationFamily(organizationCore(entry.value))
|
|
179
|
+
: undefined;
|
|
180
|
+
}
|
|
156
181
|
export function detectKnownPersonal(text, entries, rule, emit, options) {
|
|
157
182
|
let count = 0;
|
|
158
|
-
for (const entry of entries ?? []) {
|
|
159
|
-
const family =
|
|
160
|
-
rule === 'confirmed-personal' &&
|
|
161
|
-
options.documentEntities &&
|
|
162
|
-
!options.knownOnly &&
|
|
163
|
-
entry.type === 'ORGANIZATION' &&
|
|
164
|
-
organizationFamily(organizationCore(entry.value));
|
|
183
|
+
for (const [index, entry] of (entries ?? []).entries()) {
|
|
184
|
+
const family = confirmedFamily(entry, rule, options);
|
|
165
185
|
for (const pattern of [
|
|
166
|
-
|
|
186
|
+
compiledPatterns(entries)?.[index] ??
|
|
187
|
+
personalRegex(entry.value, entry.type),
|
|
167
188
|
...(family ? [family.pattern] : []),
|
|
168
189
|
]) {
|
|
169
190
|
for (const match of text.matchAll(pattern)) {
|
package/src/placeholder-types.js
CHANGED
package/src/policy.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { failure } from './detection.js';
|
|
2
|
+
import { builtInRules, credentialRules } from './rule-catalog.js';
|
|
3
|
+
import { normalizeFieldName } from './document-fields.js';
|
|
4
|
+
|
|
5
|
+
const ruleIds = new Set(builtInRules);
|
|
6
|
+
const credentialType = (type) =>
|
|
7
|
+
/SECRET|TOKEN|PASSWORD|AUTH|CREDENTIAL|PRIVATE_KEY|ACCESS_KEY|^PIN$|CVV|CVC|CONNECTION.*STRING/.test(
|
|
8
|
+
type
|
|
9
|
+
);
|
|
10
|
+
const record = (value) =>
|
|
11
|
+
value && typeof value === 'object' && !Array.isArray(value);
|
|
12
|
+
// These recognizers consume the replaceable cue lists. Independent evidence
|
|
13
|
+
// (legal forms, dates, full names, identifiers) remains available.
|
|
14
|
+
const vocabularyRules = {
|
|
15
|
+
roles: ['document-reversed-role', 'document-role-employer'],
|
|
16
|
+
organizationCues: [
|
|
17
|
+
'organization-context',
|
|
18
|
+
'employer-label',
|
|
19
|
+
'employer-prose',
|
|
20
|
+
'organization-cue',
|
|
21
|
+
'organization-group-cue',
|
|
22
|
+
'document-employer-prefix',
|
|
23
|
+
'document-employer-dash',
|
|
24
|
+
],
|
|
25
|
+
educationCues: [
|
|
26
|
+
'school-acronym-context',
|
|
27
|
+
'education-label',
|
|
28
|
+
'education-training',
|
|
29
|
+
'document-school-context',
|
|
30
|
+
'numbered-school',
|
|
31
|
+
'education-type',
|
|
32
|
+
'education-russian',
|
|
33
|
+
],
|
|
34
|
+
personalLabels: ['document-role'],
|
|
35
|
+
};
|
|
36
|
+
export function validatePolicyControls(options) {
|
|
37
|
+
for (const key of ['allowCredentialDisable', 'strict', 'exactFields']) {
|
|
38
|
+
if (options[key] !== undefined && typeof options[key] !== 'boolean') {
|
|
39
|
+
throw failure('ERR_CONFIG');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
for (const key of ['rules', 'types', 'fields']) {
|
|
43
|
+
if (options[key] === undefined) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (!record(options[key])) {
|
|
47
|
+
throw failure('ERR_CONFIG');
|
|
48
|
+
}
|
|
49
|
+
for (const [id, value] of Object.entries(options[key])) {
|
|
50
|
+
validateControl(key, id, value, options);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function validateControl(key, id, value, options) {
|
|
55
|
+
const validId =
|
|
56
|
+
key === 'rules'
|
|
57
|
+
? ruleIds.has(id)
|
|
58
|
+
: key === 'types'
|
|
59
|
+
? /^[A-Z][A-Z0-9_]{0,63}$/.test(id)
|
|
60
|
+
: id.length > 0;
|
|
61
|
+
if (
|
|
62
|
+
!validId ||
|
|
63
|
+
(key === 'fields' ? value !== 'scan' : typeof value !== 'boolean')
|
|
64
|
+
) {
|
|
65
|
+
throw failure('ERR_CONFIG');
|
|
66
|
+
}
|
|
67
|
+
const credential =
|
|
68
|
+
key === 'rules' ? credentialRules.has(id) : credentialType(id);
|
|
69
|
+
if (value === false && !options.allowCredentialDisable && credential) {
|
|
70
|
+
throw failure('ERR_CONFIG');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function builtInEnabled(finding, options) {
|
|
74
|
+
if (finding.category === 'credential' && !options.allowCredentialDisable) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
for (const [list, rules] of Object.entries(vocabularyRules)) {
|
|
78
|
+
if (
|
|
79
|
+
options.vocabulary?.[list]?.replace === true &&
|
|
80
|
+
rules.includes(finding.rule)
|
|
81
|
+
) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return (
|
|
86
|
+
options.rules?.[finding.rule] !== false &&
|
|
87
|
+
options.types?.[finding.type] !== false
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
export function scanField(field, options) {
|
|
91
|
+
return Object.keys(options.fields ?? {}).some(
|
|
92
|
+
(name) => normalizeFieldName(name) === normalizeFieldName(field)
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
export function fieldMatches(patterns, field, path, exact = false) {
|
|
96
|
+
const normalize = exact ? (s) => s : normalizeFieldName;
|
|
97
|
+
return (patterns ?? []).some((pattern) => {
|
|
98
|
+
const parts = pattern.split('.');
|
|
99
|
+
const values = parts.length === 1 ? [field] : path;
|
|
100
|
+
return (
|
|
101
|
+
parts.length === values.length &&
|
|
102
|
+
parts.every(
|
|
103
|
+
(part, i) => part === '*' || normalize(part) === normalize(values[i])
|
|
104
|
+
)
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
}
|
package/src/propagation.js
CHANGED
|
@@ -36,7 +36,11 @@ function eligibleSeed(finding, options) {
|
|
|
36
36
|
!options.knownOnly &&
|
|
37
37
|
finding.category === 'personal' &&
|
|
38
38
|
finding.type !== 'ENCODED_SENSITIVE' &&
|
|
39
|
-
![
|
|
39
|
+
![
|
|
40
|
+
'confirmed-personal',
|
|
41
|
+
'document-propagation',
|
|
42
|
+
'proper-name-prose',
|
|
43
|
+
].includes(finding.rule) &&
|
|
40
44
|
actionable(finding, options) &&
|
|
41
45
|
((options.documentEntities && finding.type === 'ORGANIZATION') ||
|
|
42
46
|
finding.confidence >= 0.85 ||
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { failure } from './detection.js';
|
|
2
|
+
import { documentDateRange } from './document-dates.js';
|
|
3
|
+
|
|
4
|
+
const scriptPatterns = new Map();
|
|
5
|
+
const dateRange = new RegExp(`\\(${documentDateRange}\\)`, 'u');
|
|
6
|
+
const commaMobile = /,[ \t]*[78]9\d{9}(?![\p{L}\p{N}_])/u;
|
|
7
|
+
const mobile = /^[78]9\d{9}$/u;
|
|
8
|
+
const tokenPattern = /[\p{L}\p{M}\p{N}_./:\\?&#%=@+'’~+-]+/gu;
|
|
9
|
+
const upper = /[\p{Lu}\p{Lt}]/u;
|
|
10
|
+
const quotePairs = new Map([
|
|
11
|
+
['«', '»'],
|
|
12
|
+
['“', '”'],
|
|
13
|
+
['„', '“'],
|
|
14
|
+
['‘', '’'],
|
|
15
|
+
['「', '」'],
|
|
16
|
+
['『', '』'],
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
function scriptPattern(scripts = ['Cyrillic']) {
|
|
20
|
+
if (
|
|
21
|
+
scripts.some(
|
|
22
|
+
(script) => typeof script !== 'string' || !/^[A-Za-z_]+$/.test(script)
|
|
23
|
+
)
|
|
24
|
+
) {
|
|
25
|
+
throw failure('ERR_CONFIG');
|
|
26
|
+
}
|
|
27
|
+
const key = scripts.join('|');
|
|
28
|
+
let pattern = scriptPatterns.get(key);
|
|
29
|
+
if (!pattern) {
|
|
30
|
+
// The alias grammar prevents regexp injection; constructing the Unicode
|
|
31
|
+
// property also validates aliases supported by this runtime.
|
|
32
|
+
try {
|
|
33
|
+
pattern = new RegExp(
|
|
34
|
+
scripts.length
|
|
35
|
+
? `(?=\\p{L})(?:${scripts.map((s) => `\\p{Script=${s}}`).join('|')})`
|
|
36
|
+
: '(?!)',
|
|
37
|
+
'u'
|
|
38
|
+
);
|
|
39
|
+
} catch {
|
|
40
|
+
throw failure('ERR_CONFIG');
|
|
41
|
+
}
|
|
42
|
+
// Bound cache growth when callers submit many different configurations.
|
|
43
|
+
if (scriptPatterns.size >= 64) {
|
|
44
|
+
scriptPatterns.clear();
|
|
45
|
+
}
|
|
46
|
+
scriptPatterns.set(key, pattern);
|
|
47
|
+
}
|
|
48
|
+
return pattern;
|
|
49
|
+
}
|
|
50
|
+
export function validateProperNames(options) {
|
|
51
|
+
if (
|
|
52
|
+
(options.properNames !== undefined &&
|
|
53
|
+
options.properNames !== 'capitalized') ||
|
|
54
|
+
(options.scripts !== undefined && !Array.isArray(options.scripts))
|
|
55
|
+
) {
|
|
56
|
+
throw failure('ERR_CONFIG');
|
|
57
|
+
}
|
|
58
|
+
scriptPattern(options.scripts);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function isProseLink(token) {
|
|
62
|
+
return (
|
|
63
|
+
/^https?:\/\/[^/]+/iu.test(token) ||
|
|
64
|
+
/^[\p{L}\p{N}](?:[\p{L}\p{N}-]*\.)+[\p{L}]{2,63}(?:[/:?#]|$)/u.test(token)
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
export function isDocumentShareLink(token) {
|
|
68
|
+
return isProseLink(token) && /\/d\/[^/?#]+/u.test(token);
|
|
69
|
+
}
|
|
70
|
+
function proseData(text, options) {
|
|
71
|
+
if (
|
|
72
|
+
scriptPattern(options.scripts).test(text) ||
|
|
73
|
+
dateRange.test(text) ||
|
|
74
|
+
commaMobile.test(text)
|
|
75
|
+
) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
for (const match of text.matchAll(tokenPattern)) {
|
|
79
|
+
if (isDocumentShareLink(match[0])) {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
function quotedSpans(text, emit) {
|
|
86
|
+
let start = -1,
|
|
87
|
+
closing;
|
|
88
|
+
for (let i = 0; i < text.length; i++) {
|
|
89
|
+
if (start >= 0) {
|
|
90
|
+
if (text[i] === closing) {
|
|
91
|
+
emit(start, i + 1, 'PROPER_NAME');
|
|
92
|
+
start = -1;
|
|
93
|
+
}
|
|
94
|
+
} else if (quotePairs.has(text[i])) {
|
|
95
|
+
start = i;
|
|
96
|
+
closing = quotePairs.get(text[i]);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function pathToken(value) {
|
|
101
|
+
return value.includes('/') || value.includes('\\');
|
|
102
|
+
}
|
|
103
|
+
// Keep existing document coverage outside the new replacements. Two naturally
|
|
104
|
+
// ordered runs (quotes, then tokens) provide an interval index without sorting.
|
|
105
|
+
// Overlapping broad document spans must not merge separate prose replacements.
|
|
106
|
+
export function detectProseAndDocuments(text, emit, options, detectDocuments) {
|
|
107
|
+
if (options.properNames !== 'capitalized') {
|
|
108
|
+
detectDocuments(text, emit, options);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const runs = [[]];
|
|
112
|
+
detectProperNames(
|
|
113
|
+
text,
|
|
114
|
+
(finding) => {
|
|
115
|
+
if (runs.at(-1).at(-1)?.end > finding.start) {
|
|
116
|
+
runs.push([]);
|
|
117
|
+
}
|
|
118
|
+
runs.at(-1).push(finding);
|
|
119
|
+
emit(finding);
|
|
120
|
+
},
|
|
121
|
+
options
|
|
122
|
+
);
|
|
123
|
+
detectDocuments(
|
|
124
|
+
text,
|
|
125
|
+
(finding) => {
|
|
126
|
+
if (!runs.some((run) => overlapsRun(run, finding))) {
|
|
127
|
+
emit(finding);
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
options
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
function overlapsRun(run, finding) {
|
|
134
|
+
let low = 0,
|
|
135
|
+
high = run.length;
|
|
136
|
+
while (low < high) {
|
|
137
|
+
const mid = (low + high) >>> 1;
|
|
138
|
+
if (run[mid].end <= finding.start) {
|
|
139
|
+
low = mid + 1;
|
|
140
|
+
} else {
|
|
141
|
+
high = mid;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return low < run.length && run[low].start < finding.end;
|
|
145
|
+
}
|
|
146
|
+
export function detectProperNames(text, emit, options) {
|
|
147
|
+
if (options.properNames !== 'capitalized' || !proseData(text, options)) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
let count = 0;
|
|
151
|
+
const found = (start, end, type) => {
|
|
152
|
+
emit({
|
|
153
|
+
start,
|
|
154
|
+
end,
|
|
155
|
+
type,
|
|
156
|
+
rule: 'proper-name-prose',
|
|
157
|
+
category: 'personal',
|
|
158
|
+
confidence: 0.85,
|
|
159
|
+
});
|
|
160
|
+
count++;
|
|
161
|
+
};
|
|
162
|
+
quotedSpans(text, found);
|
|
163
|
+
const ranges = text.matchAll(new RegExp(dateRange, 'gu'));
|
|
164
|
+
let range = ranges.next().value;
|
|
165
|
+
for (const match of text.matchAll(tokenPattern)) {
|
|
166
|
+
const raw = match[0];
|
|
167
|
+
// Leave sentence punctuation outside the replacement. Classification runs
|
|
168
|
+
// once on a maximal token, including hostile tokens without spaces.
|
|
169
|
+
let length = raw.length;
|
|
170
|
+
while (length && (raw[length - 1] === '.' || raw[length - 1] === ':')) {
|
|
171
|
+
length--;
|
|
172
|
+
}
|
|
173
|
+
const value = raw.slice(0, length);
|
|
174
|
+
const start = match.index,
|
|
175
|
+
end = start + value.length;
|
|
176
|
+
while (range && range.index + range[0].length <= start) {
|
|
177
|
+
range = ranges.next().value;
|
|
178
|
+
}
|
|
179
|
+
if (preserveToken(text, raw, value, start, end, range)) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const type = tokenType(value);
|
|
183
|
+
if (type) {
|
|
184
|
+
found(start, end, type);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
options.debug?.({ event: 'proper-name-prose', count });
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function preserveToken(text, raw, value, start, end, range) {
|
|
192
|
+
let next = start + raw.length;
|
|
193
|
+
while (text[next] === ' ' || text[next] === '\t') {
|
|
194
|
+
next++;
|
|
195
|
+
}
|
|
196
|
+
return (
|
|
197
|
+
!value ||
|
|
198
|
+
(range && start >= range.index && end <= range.index + range[0].length) ||
|
|
199
|
+
(value === 'REDACTED' && text[start - 1] === '[' && text[end] === ']') ||
|
|
200
|
+
raw.endsWith(':') ||
|
|
201
|
+
text[next] === ':'
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
function tokenType(value) {
|
|
205
|
+
if (isProseLink(value)) {
|
|
206
|
+
return 'URL';
|
|
207
|
+
}
|
|
208
|
+
if (pathToken(value)) {
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
if (mobile.test(value)) {
|
|
212
|
+
return 'PHONE';
|
|
213
|
+
}
|
|
214
|
+
if (
|
|
215
|
+
upper.test(value) &&
|
|
216
|
+
/\p{L}/u.test(value) &&
|
|
217
|
+
!/^\d{4}-\d{2}-\d{2}/u.test(value)
|
|
218
|
+
) {
|
|
219
|
+
return 'PROPER_NAME';
|
|
220
|
+
}
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
package/src/record-worker.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { parentPort, workerData } from 'node:worker_threads';
|
|
2
2
|
import { createSanitizer, prepareStreamEngine } from './engines.js';
|
|
3
|
+
import { compileKnown } from './compiled-known.js';
|
|
4
|
+
|
|
5
|
+
// Structured cloning discards the caller's weak compilation metadata.
|
|
6
|
+
if (workerData.knownPersonal) {
|
|
7
|
+
workerData.knownPersonal = compileKnown(workerData.knownPersonal);
|
|
8
|
+
}
|
|
3
9
|
|
|
4
10
|
parentPort.on(
|
|
5
11
|
'message',
|