@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/sanitizer.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
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';
|
|
6
|
+
import { validateProperNames } from './proper-names.js';
|
|
2
7
|
import { prepareFakeText } from './fake-entities.js';
|
|
3
8
|
import { alignEscapes } from './escape-spans.js';
|
|
4
9
|
import { actionable } from './confidence.js';
|
|
@@ -15,7 +20,12 @@ import { projectText } from './projection.js';
|
|
|
15
20
|
import { decodedRuns, encodeRun, encodedValue } from './encoded.js';
|
|
16
21
|
import { URL } from 'node:url';
|
|
17
22
|
import { isPublic } from './public.js';
|
|
18
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
validateTransforms,
|
|
25
|
+
renderReplacement,
|
|
26
|
+
hasCustomTransforms,
|
|
27
|
+
renderCallbackAsync,
|
|
28
|
+
} from './transforms.js';
|
|
19
29
|
import { fakeIdentityValue, isIdentityType } from './identity.js';
|
|
20
30
|
import { rejectRestoreMap } from './restore-map.js';
|
|
21
31
|
import {
|
|
@@ -37,6 +47,7 @@ import {
|
|
|
37
47
|
mapStructuredFindings,
|
|
38
48
|
preserveIdentifierKeys,
|
|
39
49
|
structuredValueRanges,
|
|
50
|
+
structuredStringRanges,
|
|
40
51
|
verifyStructured,
|
|
41
52
|
} from './structured.js';
|
|
42
53
|
|
|
@@ -78,6 +89,16 @@ const optionNames = new Set([
|
|
|
78
89
|
'threshold',
|
|
79
90
|
'profile',
|
|
80
91
|
'documentEntities',
|
|
92
|
+
'properNames',
|
|
93
|
+
'scripts',
|
|
94
|
+
'rules',
|
|
95
|
+
'types',
|
|
96
|
+
'fields',
|
|
97
|
+
'allowCredentialDisable',
|
|
98
|
+
'vocabulary',
|
|
99
|
+
'strict',
|
|
100
|
+
'exactFields',
|
|
101
|
+
'allowedFields',
|
|
81
102
|
]);
|
|
82
103
|
|
|
83
104
|
export function applyProfile(options = {}) {
|
|
@@ -105,6 +126,7 @@ function validateCollections(options) {
|
|
|
105
126
|
'fakeValues',
|
|
106
127
|
'structuralFields',
|
|
107
128
|
'sensitiveFields',
|
|
129
|
+
'allowedFields',
|
|
108
130
|
]) {
|
|
109
131
|
if (options[key] !== undefined && !Array.isArray(options[key])) {
|
|
110
132
|
throw failure('ERR_CONFIG');
|
|
@@ -115,6 +137,7 @@ function validateCollections(options) {
|
|
|
115
137
|
'fakeValues',
|
|
116
138
|
'structuralFields',
|
|
117
139
|
'sensitiveFields',
|
|
140
|
+
'allowedFields',
|
|
118
141
|
]) {
|
|
119
142
|
if (
|
|
120
143
|
(options[key] ?? []).some((value) => typeof value !== 'string' || !value)
|
|
@@ -123,7 +146,9 @@ function validateCollections(options) {
|
|
|
123
146
|
}
|
|
124
147
|
}
|
|
125
148
|
for (const entry of [
|
|
126
|
-
...(options.knownPersonal
|
|
149
|
+
...(isCompiledKnown(options.knownPersonal)
|
|
150
|
+
? []
|
|
151
|
+
: (options.knownPersonal ?? [])),
|
|
127
152
|
...(options.confirmedPersonal ?? []),
|
|
128
153
|
]) {
|
|
129
154
|
if (
|
|
@@ -191,6 +216,7 @@ export function validateOptions(options = {}) {
|
|
|
191
216
|
}
|
|
192
217
|
}
|
|
193
218
|
validateConfidence(options);
|
|
219
|
+
validateProperNames(options);
|
|
194
220
|
validatePolicy(options);
|
|
195
221
|
if (
|
|
196
222
|
options.verifyPublic !== undefined &&
|
|
@@ -199,6 +225,9 @@ export function validateOptions(options = {}) {
|
|
|
199
225
|
throw failure('ERR_CONFIG');
|
|
200
226
|
}
|
|
201
227
|
validateCollections(options);
|
|
228
|
+
validateDetectors(options.detectors);
|
|
229
|
+
validatePolicyControls(options);
|
|
230
|
+
compiledVocabulary(options.vocabulary);
|
|
202
231
|
validateTransforms(options);
|
|
203
232
|
validateLimits(options);
|
|
204
233
|
for (const entry of options.publicEntities ?? []) {
|
|
@@ -347,7 +376,12 @@ function encodedDetect(text, options, emit) {
|
|
|
347
376
|
}
|
|
348
377
|
const found = [];
|
|
349
378
|
const view = projectText(run.text).text;
|
|
350
|
-
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
|
+
);
|
|
351
385
|
const sensitive = reviewFindings(view, found, options).filter((f) =>
|
|
352
386
|
actionable(f, options)
|
|
353
387
|
);
|
|
@@ -410,11 +444,21 @@ function inspectText(text, options = {}) {
|
|
|
410
444
|
...options,
|
|
411
445
|
structured: undefined,
|
|
412
446
|
findings: [],
|
|
447
|
+
detectors: [],
|
|
448
|
+
properNames: undefined,
|
|
449
|
+
documentEntities: options.properNames
|
|
450
|
+
? false
|
|
451
|
+
: options.documentEntities,
|
|
413
452
|
}),
|
|
414
453
|
options
|
|
415
454
|
),
|
|
416
|
-
...inspectStructuredValues(batch, options, (value) =>
|
|
417
|
-
inspect(value, {
|
|
455
|
+
...inspectStructuredValues(batch, options, (value, prose) =>
|
|
456
|
+
inspect(value, {
|
|
457
|
+
...options,
|
|
458
|
+
structured: undefined,
|
|
459
|
+
findings: [],
|
|
460
|
+
properNames: prose ? options.properNames : undefined,
|
|
461
|
+
})
|
|
418
462
|
),
|
|
419
463
|
]);
|
|
420
464
|
return inspectFindingsOnly(
|
|
@@ -436,10 +480,12 @@ function inspectText(text, options = {}) {
|
|
|
436
480
|
knownSecrets: (options.knownSecrets ?? []).map(
|
|
437
481
|
(value) => projectText(value).text
|
|
438
482
|
),
|
|
439
|
-
knownPersonal:
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
483
|
+
knownPersonal:
|
|
484
|
+
projectedKnown(options.knownPersonal) ??
|
|
485
|
+
(options.knownPersonal ?? []).map((entry) => ({
|
|
486
|
+
...entry,
|
|
487
|
+
value: projectText(entry.value).text,
|
|
488
|
+
})),
|
|
443
489
|
confirmedPersonal: (options.confirmedPersonal ?? []).map((entry) => ({
|
|
444
490
|
...entry,
|
|
445
491
|
value: projectText(entry.value).text,
|
|
@@ -457,9 +503,15 @@ function inspectText(text, options = {}) {
|
|
|
457
503
|
);
|
|
458
504
|
if (projection.changed) {
|
|
459
505
|
nativeDetect(text, options, emit);
|
|
506
|
+
detectSync(text, options, emit);
|
|
460
507
|
}
|
|
461
508
|
nativeDetect(projection.text, nativeOptions, mapped);
|
|
462
|
-
|
|
509
|
+
detectSync(projection.text, nativeOptions, mapped);
|
|
510
|
+
encodedDetect(projection.text, nativeOptions, (finding) => {
|
|
511
|
+
if (builtInEnabled(finding, options)) {
|
|
512
|
+
mapped(finding);
|
|
513
|
+
}
|
|
514
|
+
});
|
|
463
515
|
for (const f of options.findings ?? []) {
|
|
464
516
|
emit(f);
|
|
465
517
|
}
|
|
@@ -732,22 +784,28 @@ export function redactResult(
|
|
|
732
784
|
const parts = [];
|
|
733
785
|
const faked = [];
|
|
734
786
|
const occurrences = [];
|
|
787
|
+
const jsonRanges = callbackStringRanges(text, options);
|
|
735
788
|
let irreversibleRedactions = 0;
|
|
736
789
|
let outputLength = 0;
|
|
737
790
|
let cursor = 0;
|
|
738
791
|
for (const span of spans) {
|
|
739
792
|
const replacement =
|
|
740
793
|
replacements.get(`${span.start}:${span.end}`) ??
|
|
741
|
-
renderReplacement(
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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
|
|
751
809
|
);
|
|
752
810
|
parts.push(text.slice(cursor, span.start), replacement);
|
|
753
811
|
outputLength += span.start - cursor;
|
|
@@ -799,6 +857,20 @@ function retainedReplacement(transform, mode, span, replacement) {
|
|
|
799
857
|
);
|
|
800
858
|
}
|
|
801
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
|
+
|
|
802
874
|
export async function redactResultAsync(
|
|
803
875
|
text,
|
|
804
876
|
findings,
|
|
@@ -808,8 +880,22 @@ export async function redactResultAsync(
|
|
|
808
880
|
options = initializePlaceholders(options);
|
|
809
881
|
preparePlaceholderText(text, findings, options);
|
|
810
882
|
const replacements = new Map();
|
|
883
|
+
const jsonRanges = callbackStringRanges(text, options);
|
|
811
884
|
{
|
|
812
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
|
+
}
|
|
813
899
|
if (span.type !== 'ENCODED_SENSITIVE') {
|
|
814
900
|
if (hasPlaceholders(options)) {
|
|
815
901
|
replacements.set(
|
|
@@ -819,7 +905,6 @@ export async function redactResultAsync(
|
|
|
819
905
|
}
|
|
820
906
|
continue;
|
|
821
907
|
}
|
|
822
|
-
const run = encodedValue(text.slice(span.start, span.end));
|
|
823
908
|
if (!run) {
|
|
824
909
|
if (options.preserveEncoding) {
|
|
825
910
|
throw failure('ERR_ENCODING');
|
|
@@ -859,6 +944,14 @@ function sanitizeText(text, options = {}, round = 0) {
|
|
|
859
944
|
options = placeholderKnown(initializePlaceholders(options));
|
|
860
945
|
const findings = inspect(text, options);
|
|
861
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
|
+
}
|
|
862
955
|
const sanitized = redactResult(text, findings, {
|
|
863
956
|
...options,
|
|
864
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
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
|
|
@@ -182,10 +230,10 @@ function weakStructuralFinding(finding, entry, batchText) {
|
|
|
182
230
|
}
|
|
183
231
|
return false;
|
|
184
232
|
}
|
|
185
|
-
function ordinaryPersonalKey(entry, finding) {
|
|
233
|
+
function ordinaryPersonalKey(entry, finding, options = {}) {
|
|
186
234
|
return (
|
|
187
235
|
entry.key &&
|
|
188
|
-
/^[A-Za-z_$][\w$]*$/.test(JSON.parse(entry.raw)) &&
|
|
236
|
+
(options.properNames || /^[A-Za-z_$][\w$]*$/.test(JSON.parse(entry.raw))) &&
|
|
189
237
|
finding.category === 'personal' &&
|
|
190
238
|
finding.rule !== 'known-personal'
|
|
191
239
|
);
|
|
@@ -193,12 +241,17 @@ function ordinaryPersonalKey(entry, finding) {
|
|
|
193
241
|
// Propagation runs again at source offsets. Apply the same key policy after it
|
|
194
242
|
// so a name confirmed in a value cannot rename an ordinary identifier key.
|
|
195
243
|
export function preserveIdentifierKeys(text, findings, options) {
|
|
196
|
-
if (
|
|
244
|
+
if (
|
|
245
|
+
!options.structured ||
|
|
246
|
+
(hasPlaceholders(options) && !options.properNames)
|
|
247
|
+
) {
|
|
197
248
|
return findings;
|
|
198
249
|
}
|
|
199
250
|
const keys = structuredBatches(text, options).flatMap((batch) =>
|
|
200
251
|
batch.entries
|
|
201
|
-
.filter((entry) =>
|
|
252
|
+
.filter((entry) =>
|
|
253
|
+
ordinaryPersonalKey(entry, { category: 'personal' }, options)
|
|
254
|
+
)
|
|
202
255
|
.map((entry) => ({
|
|
203
256
|
start: entry.start,
|
|
204
257
|
end: entry.start + entry.raw.length,
|
|
@@ -222,13 +275,23 @@ export function preserveIdentifierKeys(text, findings, options) {
|
|
|
222
275
|
});
|
|
223
276
|
}
|
|
224
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
|
+
}
|
|
285
|
+
if (options.properNames && ordinaryPersonalKey(entry, finding, options)) {
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
225
288
|
if (hasPlaceholders(options)) {
|
|
226
289
|
return false;
|
|
227
290
|
}
|
|
228
291
|
return (
|
|
229
292
|
(entry.key &&
|
|
230
293
|
['confirmed-personal', 'document-propagation'].includes(finding.rule)) ||
|
|
231
|
-
ordinaryPersonalKey(entry, finding) ||
|
|
294
|
+
ordinaryPersonalKey(entry, finding, options) ||
|
|
232
295
|
(!entry.key &&
|
|
233
296
|
options.structuralFields?.includes(entry.field) &&
|
|
234
297
|
weakStructuralFinding(finding, entry, text))
|
|
@@ -346,28 +409,28 @@ export function* structuredValueViews(
|
|
|
346
409
|
end: entry.start + raw.end,
|
|
347
410
|
});
|
|
348
411
|
};
|
|
349
|
-
|
|
350
|
-
//
|
|
412
|
+
// A serialized container is a transport envelope, not one prose value.
|
|
413
|
+
// Activate the optional scanner only in its independently decoded leaves.
|
|
414
|
+
let batches = [];
|
|
351
415
|
if (depth < 2 && /^[ \t\r\n]*[[{]/.test(value)) {
|
|
352
|
-
let batches;
|
|
353
416
|
try {
|
|
354
417
|
batches = structuredBatches(value, { ...options, structured: 'json' });
|
|
355
418
|
} catch (error) {
|
|
356
419
|
if (error.code !== 'ERR_JSON') {
|
|
357
420
|
throw error;
|
|
358
421
|
}
|
|
359
|
-
batches = [];
|
|
360
|
-
}
|
|
361
|
-
for (const nested of batches) {
|
|
362
|
-
yield* structuredValueViews(nested, options, depth + 1, map);
|
|
363
422
|
}
|
|
364
423
|
}
|
|
424
|
+
yield { text: value, map, prose: !entry.key && batches.length === 0 };
|
|
425
|
+
for (const nested of batches) {
|
|
426
|
+
yield* structuredValueViews(nested, options, depth + 1, map);
|
|
427
|
+
}
|
|
365
428
|
}
|
|
366
429
|
}
|
|
367
430
|
export function inspectStructuredValues(batch, options, inspectValue) {
|
|
368
431
|
const result = [];
|
|
369
432
|
for (const view of structuredValueViews(batch, options)) {
|
|
370
|
-
const findings = inspectValue(view.text);
|
|
433
|
+
const findings = inspectValue(view.text, view.prose);
|
|
371
434
|
options.debug?.({ event: 'structured-value', count: findings.length });
|
|
372
435
|
for (const finding of findings) {
|
|
373
436
|
const mapped = view.map(finding);
|
|
@@ -399,32 +462,41 @@ export function declaredFieldFindings(text, options) {
|
|
|
399
462
|
if (!options.sensitiveFields?.length) {
|
|
400
463
|
return [];
|
|
401
464
|
}
|
|
402
|
-
const fields = new Set(options.sensitiveFields);
|
|
403
465
|
const findings = [];
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
+
});
|
|
419
498
|
}
|
|
420
|
-
|
|
421
|
-
start,
|
|
422
|
-
end,
|
|
423
|
-
type: 'JSON_VALUE',
|
|
424
|
-
category: 'personal',
|
|
425
|
-
rule: 'known-personal',
|
|
426
|
-
confidence: 1,
|
|
427
|
-
});
|
|
499
|
+
offset += record.length + 1;
|
|
428
500
|
}
|
|
429
501
|
return findings;
|
|
430
502
|
}
|