@link-foundation/sensitive-data-sanitizer 0.18.0 → 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 +6 -0
- package/README.md +8 -0
- package/docs/API.md +205 -33
- package/docs/COVERAGE.md +84 -8
- package/docs/RULES.md +403 -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 +8 -2
- package/src/document-experience.js +9 -4
- package/src/document-extras.js +2 -2
- 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/record-worker.js +6 -0
- package/src/rule-catalog.js +669 -0
- package/src/sanitizer.js +99 -19
- package/src/stream-worker.js +5 -0
- package/src/stream.js +11 -6
- package/src/strict.js +66 -0
- package/src/structured.js +111 -47
- 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/sanitizer.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { generatedValues } from './fake.js';
|
|
2
|
+
import { isCompiledKnown, projectedKnown } from './compiled-known.js';
|
|
3
|
+
import { detectSync, validateDetectors } from './sync-detectors.js';
|
|
4
|
+
import { validatePolicyControls, builtInEnabled } from './policy.js';
|
|
5
|
+
import { compiledVocabulary } from './vocabulary.js';
|
|
2
6
|
import { validateProperNames } from './proper-names.js';
|
|
3
7
|
import { prepareFakeText } from './fake-entities.js';
|
|
4
8
|
import { alignEscapes } from './escape-spans.js';
|
|
@@ -16,7 +20,12 @@ import { projectText } from './projection.js';
|
|
|
16
20
|
import { decodedRuns, encodeRun, encodedValue } from './encoded.js';
|
|
17
21
|
import { URL } from 'node:url';
|
|
18
22
|
import { isPublic } from './public.js';
|
|
19
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
validateTransforms,
|
|
25
|
+
renderReplacement,
|
|
26
|
+
hasCustomTransforms,
|
|
27
|
+
renderCallbackAsync,
|
|
28
|
+
} from './transforms.js';
|
|
20
29
|
import { fakeIdentityValue, isIdentityType } from './identity.js';
|
|
21
30
|
import { rejectRestoreMap } from './restore-map.js';
|
|
22
31
|
import {
|
|
@@ -38,6 +47,7 @@ import {
|
|
|
38
47
|
mapStructuredFindings,
|
|
39
48
|
preserveIdentifierKeys,
|
|
40
49
|
structuredValueRanges,
|
|
50
|
+
structuredStringRanges,
|
|
41
51
|
verifyStructured,
|
|
42
52
|
} from './structured.js';
|
|
43
53
|
|
|
@@ -81,6 +91,14 @@ const optionNames = new Set([
|
|
|
81
91
|
'documentEntities',
|
|
82
92
|
'properNames',
|
|
83
93
|
'scripts',
|
|
94
|
+
'rules',
|
|
95
|
+
'types',
|
|
96
|
+
'fields',
|
|
97
|
+
'allowCredentialDisable',
|
|
98
|
+
'vocabulary',
|
|
99
|
+
'strict',
|
|
100
|
+
'exactFields',
|
|
101
|
+
'allowedFields',
|
|
84
102
|
]);
|
|
85
103
|
|
|
86
104
|
export function applyProfile(options = {}) {
|
|
@@ -108,6 +126,7 @@ function validateCollections(options) {
|
|
|
108
126
|
'fakeValues',
|
|
109
127
|
'structuralFields',
|
|
110
128
|
'sensitiveFields',
|
|
129
|
+
'allowedFields',
|
|
111
130
|
]) {
|
|
112
131
|
if (options[key] !== undefined && !Array.isArray(options[key])) {
|
|
113
132
|
throw failure('ERR_CONFIG');
|
|
@@ -118,6 +137,7 @@ function validateCollections(options) {
|
|
|
118
137
|
'fakeValues',
|
|
119
138
|
'structuralFields',
|
|
120
139
|
'sensitiveFields',
|
|
140
|
+
'allowedFields',
|
|
121
141
|
]) {
|
|
122
142
|
if (
|
|
123
143
|
(options[key] ?? []).some((value) => typeof value !== 'string' || !value)
|
|
@@ -126,7 +146,9 @@ function validateCollections(options) {
|
|
|
126
146
|
}
|
|
127
147
|
}
|
|
128
148
|
for (const entry of [
|
|
129
|
-
...(options.knownPersonal
|
|
149
|
+
...(isCompiledKnown(options.knownPersonal)
|
|
150
|
+
? []
|
|
151
|
+
: (options.knownPersonal ?? [])),
|
|
130
152
|
...(options.confirmedPersonal ?? []),
|
|
131
153
|
]) {
|
|
132
154
|
if (
|
|
@@ -203,6 +225,9 @@ export function validateOptions(options = {}) {
|
|
|
203
225
|
throw failure('ERR_CONFIG');
|
|
204
226
|
}
|
|
205
227
|
validateCollections(options);
|
|
228
|
+
validateDetectors(options.detectors);
|
|
229
|
+
validatePolicyControls(options);
|
|
230
|
+
compiledVocabulary(options.vocabulary);
|
|
206
231
|
validateTransforms(options);
|
|
207
232
|
validateLimits(options);
|
|
208
233
|
for (const entry of options.publicEntities ?? []) {
|
|
@@ -351,7 +376,12 @@ function encodedDetect(text, options, emit) {
|
|
|
351
376
|
}
|
|
352
377
|
const found = [];
|
|
353
378
|
const view = projectText(run.text).text;
|
|
354
|
-
nativeDetect(view, options, (f) =>
|
|
379
|
+
nativeDetect(view, options, (f) =>
|
|
380
|
+
found.push(sanitizeFinding(f, view.length))
|
|
381
|
+
);
|
|
382
|
+
detectSync(view, options, (f) =>
|
|
383
|
+
found.push(sanitizeFinding(f, view.length))
|
|
384
|
+
);
|
|
355
385
|
const sensitive = reviewFindings(view, found, options).filter((f) =>
|
|
356
386
|
actionable(f, options)
|
|
357
387
|
);
|
|
@@ -414,6 +444,7 @@ function inspectText(text, options = {}) {
|
|
|
414
444
|
...options,
|
|
415
445
|
structured: undefined,
|
|
416
446
|
findings: [],
|
|
447
|
+
detectors: [],
|
|
417
448
|
properNames: undefined,
|
|
418
449
|
documentEntities: options.properNames
|
|
419
450
|
? false
|
|
@@ -449,10 +480,12 @@ function inspectText(text, options = {}) {
|
|
|
449
480
|
knownSecrets: (options.knownSecrets ?? []).map(
|
|
450
481
|
(value) => projectText(value).text
|
|
451
482
|
),
|
|
452
|
-
knownPersonal:
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
483
|
+
knownPersonal:
|
|
484
|
+
projectedKnown(options.knownPersonal) ??
|
|
485
|
+
(options.knownPersonal ?? []).map((entry) => ({
|
|
486
|
+
...entry,
|
|
487
|
+
value: projectText(entry.value).text,
|
|
488
|
+
})),
|
|
456
489
|
confirmedPersonal: (options.confirmedPersonal ?? []).map((entry) => ({
|
|
457
490
|
...entry,
|
|
458
491
|
value: projectText(entry.value).text,
|
|
@@ -470,9 +503,15 @@ function inspectText(text, options = {}) {
|
|
|
470
503
|
);
|
|
471
504
|
if (projection.changed) {
|
|
472
505
|
nativeDetect(text, options, emit);
|
|
506
|
+
detectSync(text, options, emit);
|
|
473
507
|
}
|
|
474
508
|
nativeDetect(projection.text, nativeOptions, mapped);
|
|
475
|
-
|
|
509
|
+
detectSync(projection.text, nativeOptions, mapped);
|
|
510
|
+
encodedDetect(projection.text, nativeOptions, (finding) => {
|
|
511
|
+
if (builtInEnabled(finding, options)) {
|
|
512
|
+
mapped(finding);
|
|
513
|
+
}
|
|
514
|
+
});
|
|
476
515
|
for (const f of options.findings ?? []) {
|
|
477
516
|
emit(f);
|
|
478
517
|
}
|
|
@@ -745,22 +784,28 @@ export function redactResult(
|
|
|
745
784
|
const parts = [];
|
|
746
785
|
const faked = [];
|
|
747
786
|
const occurrences = [];
|
|
787
|
+
const jsonRanges = callbackStringRanges(text, options);
|
|
748
788
|
let irreversibleRedactions = 0;
|
|
749
789
|
let outputLength = 0;
|
|
750
790
|
let cursor = 0;
|
|
751
791
|
for (const span of spans) {
|
|
752
792
|
const replacement =
|
|
753
793
|
replacements.get(`${span.start}:${span.end}`) ??
|
|
754
|
-
renderReplacement(
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
794
|
+
renderReplacement(
|
|
795
|
+
text,
|
|
796
|
+
span,
|
|
797
|
+
options,
|
|
798
|
+
(decoded) =>
|
|
799
|
+
sanitize(
|
|
800
|
+
decoded,
|
|
801
|
+
nestedPlaceholderOptions({
|
|
802
|
+
...confirmationOptions(text, findings, options),
|
|
803
|
+
findings: [],
|
|
804
|
+
preserveEncoding: true,
|
|
805
|
+
structured: undefined,
|
|
806
|
+
})
|
|
807
|
+
),
|
|
808
|
+
jsonRanges
|
|
764
809
|
);
|
|
765
810
|
parts.push(text.slice(cursor, span.start), replacement);
|
|
766
811
|
outputLength += span.start - cursor;
|
|
@@ -812,6 +857,20 @@ function retainedReplacement(transform, mode, span, replacement) {
|
|
|
812
857
|
);
|
|
813
858
|
}
|
|
814
859
|
|
|
860
|
+
function callbackStringRanges(text, options) {
|
|
861
|
+
return options.structured && hasCustomTransforms(options)
|
|
862
|
+
? structuredStringRanges(text)
|
|
863
|
+
: undefined;
|
|
864
|
+
}
|
|
865
|
+
function isCallbackSpan(callback, span, run, options) {
|
|
866
|
+
return (
|
|
867
|
+
typeof callback === 'function' &&
|
|
868
|
+
span.type !== 'JSON_VALUE' &&
|
|
869
|
+
(span.type !== 'ENCODED_SENSITIVE' ||
|
|
870
|
+
(!options.preserveEncoding && run?.encoding !== 'json-content'))
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
|
|
815
874
|
export async function redactResultAsync(
|
|
816
875
|
text,
|
|
817
876
|
findings,
|
|
@@ -821,8 +880,22 @@ export async function redactResultAsync(
|
|
|
821
880
|
options = initializePlaceholders(options);
|
|
822
881
|
preparePlaceholderText(text, findings, options);
|
|
823
882
|
const replacements = new Map();
|
|
883
|
+
const jsonRanges = callbackStringRanges(text, options);
|
|
824
884
|
{
|
|
825
885
|
for (const span of validatedSpans(text, findings, options)) {
|
|
886
|
+
const callback =
|
|
887
|
+
options.transformations?.[span.type] ?? options.transformation;
|
|
888
|
+
const run =
|
|
889
|
+
span.type === 'ENCODED_SENSITIVE'
|
|
890
|
+
? encodedValue(text.slice(span.start, span.end))
|
|
891
|
+
: undefined;
|
|
892
|
+
if (isCallbackSpan(callback, span, run, options)) {
|
|
893
|
+
replacements.set(
|
|
894
|
+
`${span.start}:${span.end}`,
|
|
895
|
+
await renderCallbackAsync(text, span, callback, jsonRanges)
|
|
896
|
+
);
|
|
897
|
+
continue;
|
|
898
|
+
}
|
|
826
899
|
if (span.type !== 'ENCODED_SENSITIVE') {
|
|
827
900
|
if (hasPlaceholders(options)) {
|
|
828
901
|
replacements.set(
|
|
@@ -832,7 +905,6 @@ export async function redactResultAsync(
|
|
|
832
905
|
}
|
|
833
906
|
continue;
|
|
834
907
|
}
|
|
835
|
-
const run = encodedValue(text.slice(span.start, span.end));
|
|
836
908
|
if (!run) {
|
|
837
909
|
if (options.preserveEncoding) {
|
|
838
910
|
throw failure('ERR_ENCODING');
|
|
@@ -872,6 +944,14 @@ function sanitizeText(text, options = {}, round = 0) {
|
|
|
872
944
|
options = placeholderKnown(initializePlaceholders(options));
|
|
873
945
|
const findings = inspect(text, options);
|
|
874
946
|
const result = redactResult(text, findings, options);
|
|
947
|
+
if (
|
|
948
|
+
hasCustomTransforms(options) &&
|
|
949
|
+
inspect(result.text, { ...options, findings: [] }).some((f) =>
|
|
950
|
+
actionable(f, options)
|
|
951
|
+
)
|
|
952
|
+
) {
|
|
953
|
+
throw failure('ERR_RESIDUAL');
|
|
954
|
+
}
|
|
875
955
|
const sanitized = redactResult(text, findings, {
|
|
876
956
|
...options,
|
|
877
957
|
transformation: undefined,
|
package/src/stream-worker.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { parentPort, workerData } from 'node:worker_threads';
|
|
2
2
|
import { sanitizeFileToFile } from './stream.js';
|
|
3
|
+
import { compileKnown } from './compiled-known.js';
|
|
3
4
|
try {
|
|
5
|
+
const options = workerData.options.sanitizerOptions ?? workerData.options;
|
|
6
|
+
if (options.knownPersonal) {
|
|
7
|
+
options.knownPersonal = compileKnown(options.knownPersonal);
|
|
8
|
+
}
|
|
4
9
|
parentPort.postMessage({
|
|
5
10
|
value: await sanitizeFileToFile(
|
|
6
11
|
workerData.source,
|
package/src/stream.js
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
} from './engines.js';
|
|
21
21
|
import { failure, internalFailure } from './detection.js';
|
|
22
22
|
import { sanitizeJsonlStream } from './jsonl-stream.js';
|
|
23
|
-
import { workerExecArgv } from './worker-options.js';
|
|
23
|
+
import { workerExecArgv, hasWorkerCallbacks } from './worker-options.js';
|
|
24
24
|
import { streamIdentity } from './stream-identity.js';
|
|
25
25
|
import { workerResult } from './file-worker-result.js';
|
|
26
26
|
import {
|
|
@@ -439,11 +439,7 @@ export async function sanitizeFileToFile(
|
|
|
439
439
|
}
|
|
440
440
|
}
|
|
441
441
|
export async function sanitizeFileBounded(source, target, options = {}) {
|
|
442
|
-
if (
|
|
443
|
-
options.worker === false ||
|
|
444
|
-
options.sanitizer ||
|
|
445
|
-
hasPlaceholders(options.sanitizerOptions ?? options)
|
|
446
|
-
) {
|
|
442
|
+
if (useCoordinator(options)) {
|
|
447
443
|
return sanitizeFileToFile(source, target, options);
|
|
448
444
|
}
|
|
449
445
|
const workerHeapMb = options.workerHeapMb ?? 256,
|
|
@@ -484,3 +480,12 @@ export async function sanitizeFileBounded(source, target, options = {}) {
|
|
|
484
480
|
await rm(staging, { recursive: true, force: true });
|
|
485
481
|
}
|
|
486
482
|
}
|
|
483
|
+
function useCoordinator(options) {
|
|
484
|
+
const policy = options.sanitizerOptions ?? options;
|
|
485
|
+
return (
|
|
486
|
+
options.worker === false ||
|
|
487
|
+
options.sanitizer ||
|
|
488
|
+
hasWorkerCallbacks(policy) ||
|
|
489
|
+
hasPlaceholders(policy)
|
|
490
|
+
);
|
|
491
|
+
}
|
package/src/strict.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { collectMatches } from './detection.js';
|
|
2
|
+
import { months } from './personal-context.js';
|
|
3
|
+
|
|
4
|
+
const boundary = '[\\p{L}\\p{N}_]';
|
|
5
|
+
const attributes =
|
|
6
|
+
/(?<![\p{L}\p{N}_])(?:женщин[аыуеой]{1,2}|мужчин[аыуеой]{1,2}|замужем|женат(?:а|ы)?|(?:один|одна|двое|трое|четверо|пятеро|\d{1,2})[ \t]+(?:детей|реб[её]нок|реб[её]нка)|русск(?:ая|ий|ого|ой|ие)|татар(?:ин|ка|ы)|православн(?:ый|ая|ого|ой)|мусульман(?:ин|ка)|инвалидност[ьи](?:[ \t]+\d[ \t]+группы)?|беременн(?:а|ая|ость)|судимость(?:[ \t]+погашена)?|член[ \t]+партии|woman|women|man|men|male|female|married|divorced|pregnant|pregnancy|disabled|disability|christian|muslim|jewish|orthodox|russian|hispanic|asian|criminal[ \t]+record|party[ \t]+member|(?:one|two|three|\d{1,2})[ \t]+children)(?![\p{L}\p{N}_])/giu;
|
|
7
|
+
const additionalAttributes =
|
|
8
|
+
/(?<![\p{L}\p{N}_])(?:диабет(?:ом|а|у|е)?|ВИЧ(?:-положительн(?:ый|ая|ого|ой))?|болен|больна|больны|католик(?:ом|а|и)?|католичк(?:а|ой|и)|буддист(?:ка|кой|ом|а|ы)?|атеист(?:ка|кой|ом|а|ы)?|украин(?:ец|ка|цы|цем|кой)|белорус(?:ка|кой|ом|а|ы)?|несудим(?:а|ы)?|судим(?:а|ы)?|член[ \t]+политической[ \t]+партии|холост(?:а|ы)?|развед[её]н(?:а|ы)?|в[ \t]+разводе|diabetes|diabetic|HIV(?:[ \t-]+positive)?|cancer|buddhist|catholic|atheist|ukrainian|belarusian|previous[ \t]+conviction|member[ \t]+of[ \t]+(?:a[ \t]+)?(?:political[ \t]+)?party|single|unmarried|widowed)(?![\p{L}\p{N}_])/giu;
|
|
9
|
+
|
|
10
|
+
export function detectStrict(text, emit, options) {
|
|
11
|
+
if (!options.strict) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
for (const pattern of [attributes, additionalAttributes]) {
|
|
15
|
+
collectMatches(text, pattern, emit, {
|
|
16
|
+
type: 'PERSONAL_ATTRIBUTE',
|
|
17
|
+
rule: 'strict-attribute',
|
|
18
|
+
category: 'personal',
|
|
19
|
+
confidence: 0.9,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
const date = new RegExp(
|
|
23
|
+
`(?<!${boundary})(?:\\d{4}-\\d{2}-\\d{2}|\\d{1,2}[./]\\d{1,2}[./]\\d{4}|\\d{1,2}[ \\t]+(?:${months})[ \\t]+\\d{4})(?!${boundary}|[Tt]\\d)`,
|
|
24
|
+
'giu'
|
|
25
|
+
);
|
|
26
|
+
collectMatches(text, date, emit, {
|
|
27
|
+
type: 'DATE',
|
|
28
|
+
rule: 'strict-date',
|
|
29
|
+
category: 'personal',
|
|
30
|
+
confidence: 0.9,
|
|
31
|
+
});
|
|
32
|
+
collectMatches(
|
|
33
|
+
text,
|
|
34
|
+
/(?<![\p{L}\p{N}_])\d{1,3}[ \t]+(?:год|года|лет|years?[ \t]+old)(?![\p{L}\p{N}_])/giu,
|
|
35
|
+
emit,
|
|
36
|
+
{ type: 'AGE', rule: 'strict-age', category: 'personal', confidence: 0.9 },
|
|
37
|
+
0,
|
|
38
|
+
(_value, match) =>
|
|
39
|
+
!/(?:опыт(?:[ \t]+работы)?|стаж|experience|duration|за|через|спустя)[ \t]*$/iu.test(
|
|
40
|
+
text.slice(Math.max(0, match.index - 64), match.index)
|
|
41
|
+
)
|
|
42
|
+
);
|
|
43
|
+
for (const [type, pattern] of [
|
|
44
|
+
[
|
|
45
|
+
'PASSPORT_NUMBER',
|
|
46
|
+
/(?<![\p{L}\p{N}_])(?:\d{2}[ \t]\d{2}[ \t]\d{6}|\d{10})(?![\p{L}\p{N}_])/gu,
|
|
47
|
+
],
|
|
48
|
+
[
|
|
49
|
+
'PHONE',
|
|
50
|
+
/(?<![\p{L}\p{N}_])(?:[78][ \t-]?)?9\d{2}[ \t-]?\d{3}[ \t-]?\d{2}[ \t-]?\d{2}(?![\p{L}\p{N}_])/gu,
|
|
51
|
+
],
|
|
52
|
+
['POSTAL_CODE', /(?<![\p{L}\p{N}_])\d{6}(?![\p{L}\p{N}_])/gu],
|
|
53
|
+
]) {
|
|
54
|
+
collectMatches(
|
|
55
|
+
text,
|
|
56
|
+
pattern,
|
|
57
|
+
emit,
|
|
58
|
+
{ type, rule: 'strict-number', category: 'personal', confidence: 0.9 },
|
|
59
|
+
0,
|
|
60
|
+
(value, match) =>
|
|
61
|
+
!/^[ \t]*(?:ns|ms|µs|us|seconds?|bytes?|kb|mb|gb)\b/iu.test(
|
|
62
|
+
text.slice(match.index + value.length)
|
|
63
|
+
)
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/structured.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { failure } from './detection.js';
|
|
2
|
-
import { documentFieldType
|
|
2
|
+
import { documentFieldType } from './document-fields.js';
|
|
3
3
|
import { hasPlaceholders } from './placeholders.js';
|
|
4
|
+
import { scanField, fieldMatches, builtInEnabled } from './policy.js';
|
|
5
|
+
import { vocabularyField } from './vocabulary.js';
|
|
4
6
|
|
|
5
7
|
// Session metadata is opt-in at the CLI; library callers choose their own
|
|
6
8
|
// fields. These names do not grant exemptions to provider/known credentials.
|
|
@@ -35,28 +37,45 @@ function* lexemes(text) {
|
|
|
35
37
|
}
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
const fieldPath = (frame) => [
|
|
41
|
+
...frame.path,
|
|
42
|
+
frame.array ? String(frame.index) : frame.field,
|
|
43
|
+
];
|
|
44
|
+
function stringToken(match, frame, offset) {
|
|
45
|
+
const key = match.key,
|
|
46
|
+
value = JSON.parse(match.raw);
|
|
47
|
+
if (key) {
|
|
48
|
+
frame.field = value;
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
raw: match.raw,
|
|
52
|
+
start: offset + match.index,
|
|
53
|
+
field: key ? '' : frame.field,
|
|
54
|
+
property: key ? value : undefined,
|
|
55
|
+
path: fieldPath(frame),
|
|
56
|
+
key,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
38
59
|
function tokens(text, offset = 0) {
|
|
39
60
|
const strings = [];
|
|
40
|
-
const stack = [{ field: '' }];
|
|
61
|
+
const stack = [{ field: '', path: [], index: 0, array: false }];
|
|
41
62
|
for (const match of lexemes(text)) {
|
|
42
63
|
const raw = match.raw,
|
|
43
64
|
frame = stack.at(-1);
|
|
44
65
|
if (raw[0] === '"') {
|
|
45
|
-
|
|
46
|
-
const value = JSON.parse(raw);
|
|
47
|
-
if (key) {
|
|
48
|
-
frame.field = value;
|
|
49
|
-
}
|
|
50
|
-
strings.push({
|
|
51
|
-
raw,
|
|
52
|
-
start: offset + match.index,
|
|
53
|
-
field: key ? '' : frame.field,
|
|
54
|
-
key,
|
|
55
|
-
});
|
|
66
|
+
strings.push(stringToken(match, frame, offset));
|
|
56
67
|
} else if (raw === '{' || raw === '[') {
|
|
57
|
-
stack.
|
|
68
|
+
const path = stack.length === 1 ? [] : fieldPath(frame);
|
|
69
|
+
stack.push({
|
|
70
|
+
field: raw === '[' ? frame.field : '',
|
|
71
|
+
path,
|
|
72
|
+
array: raw === '[',
|
|
73
|
+
index: 0,
|
|
74
|
+
});
|
|
58
75
|
} else if (raw === '}' || raw === ']') {
|
|
59
76
|
stack.pop();
|
|
77
|
+
} else if (raw === ',' && frame.array) {
|
|
78
|
+
frame.index++;
|
|
60
79
|
}
|
|
61
80
|
}
|
|
62
81
|
return strings;
|
|
@@ -116,29 +135,47 @@ export function structuredBatches(text, options) {
|
|
|
116
135
|
}
|
|
117
136
|
|
|
118
137
|
// Schema declarations take priority over metadata/public/fake heuristics.
|
|
138
|
+
function configuredFieldType(field, options) {
|
|
139
|
+
if (!options.documentEntities || scanField(field, options)) {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
const personal = vocabularyField(field, options),
|
|
143
|
+
builtin = documentFieldType(field);
|
|
144
|
+
if (personal === true) {
|
|
145
|
+
return 'PERSON';
|
|
146
|
+
}
|
|
147
|
+
return personal === false && builtin === 'PERSON' ? undefined : builtin;
|
|
148
|
+
}
|
|
119
149
|
export function structuredFieldFindings(batch, options) {
|
|
120
150
|
return batch.entries.flatMap((entry) => {
|
|
121
|
-
const type = options
|
|
122
|
-
|
|
123
|
-
|
|
151
|
+
const type = configuredFieldType(entry.field, options);
|
|
152
|
+
const denied =
|
|
153
|
+
options.allowedFields !== undefined &&
|
|
154
|
+
!entry.key &&
|
|
155
|
+
!fieldMatches(
|
|
156
|
+
options.allowedFields,
|
|
157
|
+
entry.field,
|
|
158
|
+
entry.path,
|
|
159
|
+
options.exactFields
|
|
160
|
+
);
|
|
124
161
|
if (
|
|
125
162
|
entry.key ||
|
|
126
|
-
!type ||
|
|
127
|
-
entry.raw.length <= 2 ||
|
|
163
|
+
(!type && !denied) ||
|
|
164
|
+
(!denied && entry.raw.length <= 2) ||
|
|
128
165
|
JSON.parse(entry.raw) === '[REDACTED]'
|
|
129
166
|
) {
|
|
130
167
|
return [];
|
|
131
168
|
}
|
|
132
169
|
return [
|
|
133
170
|
{
|
|
134
|
-
start: entry.start + 1,
|
|
135
|
-
end: entry.start + entry.raw.length - 1,
|
|
136
|
-
type,
|
|
171
|
+
start: entry.start + (denied ? 0 : 1),
|
|
172
|
+
end: entry.start + entry.raw.length - (denied ? 0 : 1),
|
|
173
|
+
type: denied ? 'JSON_VALUE' : type,
|
|
137
174
|
category: 'personal',
|
|
138
175
|
rule: 'known-personal',
|
|
139
176
|
confidence: 1,
|
|
140
177
|
},
|
|
141
|
-
];
|
|
178
|
+
].filter((finding) => denied || builtInEnabled(finding, options));
|
|
142
179
|
});
|
|
143
180
|
}
|
|
144
181
|
|
|
@@ -152,8 +189,19 @@ export function structuredValueRanges(text, options) {
|
|
|
152
189
|
}))
|
|
153
190
|
);
|
|
154
191
|
}
|
|
192
|
+
export function structuredStringRanges(text) {
|
|
193
|
+
return [...lexemes(text)]
|
|
194
|
+
.filter((entry) => entry.raw[0] === '"')
|
|
195
|
+
.map((entry) => ({
|
|
196
|
+
start: entry.index + 1,
|
|
197
|
+
end: entry.index + entry.raw.length - 1,
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
155
200
|
|
|
156
201
|
function weakStructuralFinding(finding, entry, batchText) {
|
|
202
|
+
if (finding.rule.startsWith('strict-')) {
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
157
205
|
if (
|
|
158
206
|
['known-secret', 'known-personal', 'confirmed-personal'].includes(
|
|
159
207
|
finding.rule
|
|
@@ -227,6 +275,13 @@ export function preserveIdentifierKeys(text, findings, options) {
|
|
|
227
275
|
});
|
|
228
276
|
}
|
|
229
277
|
function preserveStructuredEntry(entry, finding, text, options) {
|
|
278
|
+
if (
|
|
279
|
+
!entry.key &&
|
|
280
|
+
scanField(entry.field, options) &&
|
|
281
|
+
(finding.rule === 'label' || finding.rule.startsWith('catalog/'))
|
|
282
|
+
) {
|
|
283
|
+
return finding.category === 'personal';
|
|
284
|
+
}
|
|
230
285
|
if (options.properNames && ordinaryPersonalKey(entry, finding, options)) {
|
|
231
286
|
return true;
|
|
232
287
|
}
|
|
@@ -407,32 +462,41 @@ export function declaredFieldFindings(text, options) {
|
|
|
407
462
|
if (!options.sensitiveFields?.length) {
|
|
408
463
|
return [];
|
|
409
464
|
}
|
|
410
|
-
const fields = new Set(options.sensitiveFields.map(normalizeFieldName));
|
|
411
465
|
const findings = [];
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
466
|
+
const records = options.structured === 'jsonl' ? text.split('\n') : [text];
|
|
467
|
+
let offset = 0;
|
|
468
|
+
for (const record of records) {
|
|
469
|
+
for (const token of tokens(record, offset)) {
|
|
470
|
+
if (
|
|
471
|
+
token.start < (findings.at(-1)?.end ?? 0) ||
|
|
472
|
+
!token.key ||
|
|
473
|
+
!fieldMatches(
|
|
474
|
+
options.sensitiveFields,
|
|
475
|
+
token.property,
|
|
476
|
+
[...token.path.slice(0, -1), token.property],
|
|
477
|
+
options.exactFields
|
|
478
|
+
)
|
|
479
|
+
) {
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
let start = token.start + token.raw.length;
|
|
483
|
+
while (/[\s:]/.test(text[start] ?? '') && start < text.length) {
|
|
484
|
+
start++;
|
|
485
|
+
}
|
|
486
|
+
const end = jsonValueEnd(text, start);
|
|
487
|
+
if (text.slice(start, end) === '"[REDACTED]"') {
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
findings.push({
|
|
491
|
+
start,
|
|
492
|
+
end,
|
|
493
|
+
type: 'JSON_VALUE',
|
|
494
|
+
category: 'personal',
|
|
495
|
+
rule: 'known-personal',
|
|
496
|
+
confidence: 1,
|
|
497
|
+
});
|
|
427
498
|
}
|
|
428
|
-
|
|
429
|
-
start,
|
|
430
|
-
end,
|
|
431
|
-
type: 'JSON_VALUE',
|
|
432
|
-
category: 'personal',
|
|
433
|
-
rule: 'known-personal',
|
|
434
|
-
confidence: 1,
|
|
435
|
-
});
|
|
499
|
+
offset += record.length + 1;
|
|
436
500
|
}
|
|
437
501
|
return findings;
|
|
438
502
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { failure } from './detection.js';
|
|
2
|
+
|
|
3
|
+
export function validateDetectors(detectors = []) {
|
|
4
|
+
if (
|
|
5
|
+
!Array.isArray(detectors) ||
|
|
6
|
+
detectors.some((d) => !d || typeof d.detect !== 'function')
|
|
7
|
+
) {
|
|
8
|
+
throw failure('ERR_CONFIG');
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function detectSync(text, options, emit) {
|
|
13
|
+
if (options.knownOnly) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
for (const detector of options.detectors ?? []) {
|
|
17
|
+
let findings;
|
|
18
|
+
try {
|
|
19
|
+
findings = detector.detect(text);
|
|
20
|
+
} catch {
|
|
21
|
+
throw failure('ERR_ENGINE');
|
|
22
|
+
}
|
|
23
|
+
if (findings && typeof findings.then === 'function') {
|
|
24
|
+
// Observe rejections without allowing async work to publish output.
|
|
25
|
+
Promise.resolve(findings).catch(() => {});
|
|
26
|
+
throw failure('ERR_CONFIG');
|
|
27
|
+
}
|
|
28
|
+
if (!Array.isArray(findings)) {
|
|
29
|
+
throw failure('ERR_ENGINE');
|
|
30
|
+
}
|
|
31
|
+
if (findings.length > (options.maxFindings ?? 100000)) {
|
|
32
|
+
throw failure('ERR_LIMIT');
|
|
33
|
+
}
|
|
34
|
+
for (const finding of findings) {
|
|
35
|
+
emit(finding);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import {
|
|
2
|
+
inspect,
|
|
3
|
+
sanitize,
|
|
4
|
+
validateOptions,
|
|
5
|
+
applyProfile,
|
|
6
|
+
} from './sanitizer.js';
|
|
7
|
+
|
|
8
|
+
/** A reusable synchronous engine with the same verified publication boundary. */
|
|
9
|
+
export function createSyncSanitizer(options = {}) {
|
|
10
|
+
options = applyProfile(options);
|
|
11
|
+
validateOptions(options);
|
|
12
|
+
return Object.freeze({
|
|
13
|
+
inspect: (text) => inspect(text, options),
|
|
14
|
+
sanitize: (text) => sanitize(text, options),
|
|
15
|
+
});
|
|
16
|
+
}
|