@link-foundation/sensitive-data-sanitizer 0.18.0 → 0.19.1
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 +17 -0
- package/README.md +8 -0
- package/docs/API.md +216 -39
- package/docs/COVERAGE.md +122 -15
- package/docs/RULES.md +405 -0
- package/examples/custom-log-policy.mjs +23 -0
- package/package.json +17 -2
- package/src/compiled-known.js +55 -0
- package/src/detection.js +9 -0
- package/src/document-catalog.js +246 -0
- package/src/document-entities.js +10 -2
- package/src/document-experience.js +9 -4
- package/src/document-extras.js +2 -2
- package/src/document-fields.js +12 -2
- package/src/document-patterns.js +22 -2
- package/src/document-person-cues.js +35 -0
- package/src/document-vocabulary.js +80 -68
- package/src/engines.js +81 -34
- package/src/index.d.ts +52 -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/policy.js +107 -0
- package/src/propagation.js +2 -1
- package/src/proper-names.js +26 -3
- package/src/record-worker.js +6 -0
- package/src/rule-catalog.js +672 -0
- package/src/rules.js +5 -0
- package/src/sanitizer.js +99 -19
- package/src/stream-worker.js +5 -0
- package/src/stream.js +11 -6
- package/src/strict.js +113 -0
- package/src/structured.js +133 -48
- package/src/sync-detectors.js +38 -0
- package/src/sync-sanitizer.js +16 -0
- package/src/transforms.js +85 -7
- package/src/vocabulary.js +226 -0
- package/src/worker-options.js +12 -0
package/src/engines.js
CHANGED
|
@@ -16,6 +16,8 @@ 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 { hasCustomTransforms } from './transforms.js';
|
|
20
|
+
import { builtInEnabled } from './policy.js';
|
|
19
21
|
import {
|
|
20
22
|
initializePlaceholders,
|
|
21
23
|
placeholderKnown,
|
|
@@ -70,7 +72,8 @@ async function secretlintDetect(text) {
|
|
|
70
72
|
}));
|
|
71
73
|
}
|
|
72
74
|
|
|
73
|
-
|
|
75
|
+
const secretlintEngine = { detect: secretlintDetect };
|
|
76
|
+
async function detect(engine, text, limit, options) {
|
|
74
77
|
try {
|
|
75
78
|
const found = await engine.detect(text);
|
|
76
79
|
if (!Array.isArray(found)) {
|
|
@@ -79,7 +82,12 @@ async function detect(engine, text, limit) {
|
|
|
79
82
|
if (found.length > limit) {
|
|
80
83
|
throw failure('ERR_LIMIT');
|
|
81
84
|
}
|
|
82
|
-
return found
|
|
85
|
+
return found
|
|
86
|
+
.map((finding) => sanitizeFinding(finding, text.length))
|
|
87
|
+
.filter(
|
|
88
|
+
(finding) =>
|
|
89
|
+
engine !== secretlintEngine || builtInEnabled(finding, options)
|
|
90
|
+
);
|
|
83
91
|
} catch {
|
|
84
92
|
throw failure('ERR_ENGINE');
|
|
85
93
|
}
|
|
@@ -88,7 +96,7 @@ async function detect(engine, text, limit) {
|
|
|
88
96
|
async function engineFindings(engine, projection, options, emit) {
|
|
89
97
|
const limit = options.maxFindings ?? 100000;
|
|
90
98
|
if (projection.changed) {
|
|
91
|
-
for (const f of await detect(engine, projection.text, limit)) {
|
|
99
|
+
for (const f of await detect(engine, projection.text, limit, options)) {
|
|
92
100
|
emit({
|
|
93
101
|
...f,
|
|
94
102
|
start: projection.starts[f.start],
|
|
@@ -106,7 +114,7 @@ async function engineFindings(engine, projection, options, emit) {
|
|
|
106
114
|
const view = projectText(run.text).text;
|
|
107
115
|
const decoded = markFake(
|
|
108
116
|
view,
|
|
109
|
-
await detect(engine, view, limit),
|
|
117
|
+
await detect(engine, view, limit, options),
|
|
110
118
|
options
|
|
111
119
|
).filter((f) => actionable(f, options));
|
|
112
120
|
if (!decoded.length) {
|
|
@@ -124,6 +132,28 @@ async function engineFindings(engine, projection, options, emit) {
|
|
|
124
132
|
}
|
|
125
133
|
}
|
|
126
134
|
|
|
135
|
+
async function collectEngineFindings(text, engines, options, emit) {
|
|
136
|
+
const projection = engines.length ? projectText(text) : undefined;
|
|
137
|
+
for (const engine of engines) {
|
|
138
|
+
if (engine !== secretlintEngine && !options.detectors?.includes(engine)) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
for (const finding of await detect(
|
|
142
|
+
engine,
|
|
143
|
+
text,
|
|
144
|
+
options.maxFindings ?? 100000,
|
|
145
|
+
options
|
|
146
|
+
)) {
|
|
147
|
+
emit(finding);
|
|
148
|
+
}
|
|
149
|
+
await engineFindings(engine, projection, options, (finding) => {
|
|
150
|
+
if (builtInEnabled(finding, options)) {
|
|
151
|
+
emit(finding);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
127
157
|
async function analyzeStructured(
|
|
128
158
|
text,
|
|
129
159
|
initial,
|
|
@@ -142,6 +172,7 @@ async function analyzeStructured(
|
|
|
142
172
|
true,
|
|
143
173
|
{
|
|
144
174
|
...options,
|
|
175
|
+
detectors: [],
|
|
145
176
|
properNames: undefined,
|
|
146
177
|
documentEntities: options.properNames
|
|
147
178
|
? false
|
|
@@ -199,9 +230,7 @@ export function createSanitizer(options = {}) {
|
|
|
199
230
|
}
|
|
200
231
|
}
|
|
201
232
|
const engines =
|
|
202
|
-
options.secretlint === false || options.knownOnly
|
|
203
|
-
? []
|
|
204
|
-
: [{ detect: secretlintDetect }];
|
|
233
|
+
options.secretlint === false || options.knownOnly ? [] : [secretlintEngine];
|
|
205
234
|
if (!options.knownOnly) {
|
|
206
235
|
engines.push(...(options.detectors ?? []));
|
|
207
236
|
}
|
|
@@ -230,20 +259,15 @@ export function createSanitizer(options = {}) {
|
|
|
230
259
|
}
|
|
231
260
|
// Native inspection performs its own normalization. Additional views are
|
|
232
261
|
// needed here only when a required external engine is enabled.
|
|
233
|
-
|
|
234
|
-
for (const engine of engines) {
|
|
235
|
-
for (const finding of await detect(
|
|
236
|
-
engine,
|
|
237
|
-
text,
|
|
238
|
-
options.maxFindings ?? 100000
|
|
239
|
-
)) {
|
|
240
|
-
emit(finding);
|
|
241
|
-
}
|
|
242
|
-
await engineFindings(engine, projection, options, emit);
|
|
243
|
-
}
|
|
262
|
+
await collectEngineFindings(text, engines, options, emit);
|
|
244
263
|
const detected =
|
|
245
264
|
nativeFindings === undefined
|
|
246
|
-
? inspect(text, {
|
|
265
|
+
? inspect(text, {
|
|
266
|
+
...options,
|
|
267
|
+
structured: undefined,
|
|
268
|
+
detectors: [],
|
|
269
|
+
findings,
|
|
270
|
+
})
|
|
247
271
|
: reviewFindings(text, [...nativeFindings, ...findings], options);
|
|
248
272
|
return options.verifyPublic
|
|
249
273
|
? verifyFindings(text, detected, options.verifyPublic)
|
|
@@ -297,16 +321,17 @@ export function createSanitizer(options = {}) {
|
|
|
297
321
|
findings,
|
|
298
322
|
fullRedaction ? fullOptions : options,
|
|
299
323
|
(decoded, recursiveOptions) =>
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
)
|
|
308
|
-
: Promise.resolve({ text: '[REDACTED]' })
|
|
324
|
+
sanitizeNested(
|
|
325
|
+
sanitizeText,
|
|
326
|
+
decoded,
|
|
327
|
+
depth,
|
|
328
|
+
fullRedaction,
|
|
329
|
+
recursiveOptions
|
|
330
|
+
)
|
|
309
331
|
);
|
|
332
|
+
if (await customResidual(result.text, options, plain, analyze)) {
|
|
333
|
+
throw failure('ERR_RESIDUAL');
|
|
334
|
+
}
|
|
310
335
|
const sanitized = fullRedaction
|
|
311
336
|
? result.text
|
|
312
337
|
: (
|
|
@@ -315,9 +340,13 @@ export function createSanitizer(options = {}) {
|
|
|
315
340
|
findings,
|
|
316
341
|
fullOptions,
|
|
317
342
|
(decoded, recursiveOptions) =>
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
343
|
+
sanitizeNested(
|
|
344
|
+
sanitizeText,
|
|
345
|
+
decoded,
|
|
346
|
+
depth,
|
|
347
|
+
true,
|
|
348
|
+
recursiveOptions
|
|
349
|
+
)
|
|
321
350
|
)
|
|
322
351
|
).text;
|
|
323
352
|
// Verification at the publication boundary runs every enabled engine.
|
|
@@ -330,15 +359,21 @@ export function createSanitizer(options = {}) {
|
|
|
330
359
|
sanitizeText(result.text, depth, fullRedaction, plain, next, nextRound)
|
|
331
360
|
);
|
|
332
361
|
}
|
|
333
|
-
|
|
334
|
-
verifyStructured(result.text, options);
|
|
335
|
-
}
|
|
362
|
+
verifyStructuredResult(result.text, options, plain);
|
|
336
363
|
if (await placeholderResidual(result.text, options, plain, analyze)) {
|
|
337
364
|
throw failure('ERR_RESIDUAL');
|
|
338
365
|
}
|
|
339
366
|
return { ...result, findings: auditFaked(findings, options, result) };
|
|
340
367
|
}
|
|
341
368
|
}
|
|
369
|
+
async function customResidual(text, options, plain, analyze) {
|
|
370
|
+
return (
|
|
371
|
+
hasCustomTransforms(options) &&
|
|
372
|
+
(await analyze(text, [], plain, options)).some((f) =>
|
|
373
|
+
actionable(f, options)
|
|
374
|
+
)
|
|
375
|
+
);
|
|
376
|
+
}
|
|
342
377
|
|
|
343
378
|
function guarded(callback, cleanup = () => {}) {
|
|
344
379
|
return async (...args) => {
|
|
@@ -409,3 +444,15 @@ async function verifyFindings(text, findings, verifier) {
|
|
|
409
444
|
}
|
|
410
445
|
return retained;
|
|
411
446
|
}
|
|
447
|
+
|
|
448
|
+
function sanitizeNested(sanitizeText, decoded, depth, fullRedaction, options) {
|
|
449
|
+
return depth < 2
|
|
450
|
+
? sanitizeText(decoded, depth + 1, fullRedaction, true, options)
|
|
451
|
+
: Promise.resolve({ text: '[REDACTED]' });
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function verifyStructuredResult(text, options, plain) {
|
|
455
|
+
if (options.structured && !plain) {
|
|
456
|
+
verifyStructured(text, options);
|
|
457
|
+
}
|
|
458
|
+
}
|
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,18 +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'>;
|
|
46
69
|
/** Aggressive structural proper names in prose values; opt-in, off by default. */
|
|
47
70
|
properNames?: 'capitalized';
|
|
48
71
|
/** Unicode Script aliases that activate prose detection; default ['Cyrillic']. */
|
|
49
72
|
scripts?: string[];
|
|
50
73
|
confirmedPersonal?: Array<{ value: string; type: string }>;
|
|
51
74
|
knownSecrets?: string[];
|
|
52
|
-
knownPersonal?: PersonalValue[];
|
|
75
|
+
knownPersonal?: readonly PersonalValue[];
|
|
53
76
|
publicEntities?: PublicEntity[];
|
|
54
77
|
/** Built-in exact public entities/resolvers and role mail on known domains. */
|
|
55
78
|
publicKnowledge?: boolean;
|
|
@@ -67,8 +90,12 @@ export interface SanitizerOptions {
|
|
|
67
90
|
structured?: 'json' | 'jsonl';
|
|
68
91
|
/** Property names whose metadata heuristics may be suppressed. */
|
|
69
92
|
structuralFields?: string[];
|
|
70
|
-
/**
|
|
93
|
+
/** Normalized property names or dotted paths with *; entire JSON/JSONL values redact. */
|
|
71
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[];
|
|
72
99
|
/** Personal replacement threshold, default 0.5. Inspect retains lower scores. */
|
|
73
100
|
minConfidence?: number;
|
|
74
101
|
/** Alias for minConfidence; specifying both requires equal values. */
|
|
@@ -84,7 +111,7 @@ export interface SanitizerOptions {
|
|
|
84
111
|
maxInputLength?: number;
|
|
85
112
|
maxFindings?: number;
|
|
86
113
|
debug?: (event: DebugEvent) => void;
|
|
87
|
-
/**
|
|
114
|
+
/** Required callbacks; synchronous functions reject Promise-returning detectors. */
|
|
88
115
|
detectors?: Detector[];
|
|
89
116
|
/** Required by default in createSanitizer. */
|
|
90
117
|
secretlint?: boolean;
|
|
@@ -111,6 +138,22 @@ export interface Sanitizer {
|
|
|
111
138
|
inspect(text: string): Promise<Finding[]>;
|
|
112
139
|
sanitize(text: string): Promise<SanitizedText>;
|
|
113
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[])[];
|
|
114
157
|
export interface JsonlSanitizer extends Sanitizer {
|
|
115
158
|
sanitizeJsonl(text: string): Promise<SanitizedText>;
|
|
116
159
|
}
|
|
@@ -229,7 +272,12 @@ export declare function auditGitHistory(
|
|
|
229
272
|
maxTotalBytes?: number;
|
|
230
273
|
}
|
|
231
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>;
|
|
232
279
|
export type Transformation =
|
|
280
|
+
| TransformationCallback
|
|
233
281
|
| { mode: 'redact' | 'hive-mask' }
|
|
234
282
|
| {
|
|
235
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/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', 'document-person-cue'],
|
|
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
|
@@ -35,7 +35,8 @@ function eligibleSeed(finding, options) {
|
|
|
35
35
|
return (
|
|
36
36
|
!options.knownOnly &&
|
|
37
37
|
finding.category === 'personal' &&
|
|
38
|
-
|
|
38
|
+
// Whole JSON values include syntax; they cannot seed string-content spans.
|
|
39
|
+
!['ENCODED_SENSITIVE', 'JSON_VALUE'].includes(finding.type) &&
|
|
39
40
|
![
|
|
40
41
|
'confirmed-personal',
|
|
41
42
|
'document-propagation',
|