@link-foundation/sensitive-data-sanitizer 0.12.2 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +44 -0
- package/bin/placeholders.js +115 -0
- package/bin/sensitive-data-sanitizer.js +47 -16
- package/bin/stream.js +2 -1
- package/docs/API.md +130 -21
- package/docs/COVERAGE.md +50 -0
- package/examples/reversible-placeholders.mjs +35 -0
- package/package.json +4 -2
- package/src/context.js +23 -9
- package/src/detection.js +26 -25
- package/src/document-entities.js +110 -0
- package/src/document-vocabulary.js +71 -0
- package/src/engines.js +42 -18
- package/src/entities.js +7 -0
- package/src/fake-names.js +61 -7
- package/src/fake.js +7 -0
- package/src/index.d.ts +55 -2
- package/src/index.js +8 -0
- package/src/json-string-runs.js +18 -0
- package/src/jsonl-stream.js +5 -0
- package/src/keywords.js +17 -0
- package/src/names.js +1 -1
- package/src/personal-variants.js +94 -0
- package/src/personal.js +6 -1
- package/src/placeholder-types.js +33 -0
- package/src/placeholders.js +405 -0
- package/src/projection.js +6 -2
- package/src/propagation.js +13 -1
- package/src/restore-map.js +220 -0
- package/src/sanitizer.js +119 -18
- package/src/stream.js +23 -2
- package/src/structured.js +5 -1
- package/src/transforms.js +12 -1
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { Parser, Link } from 'links-notation';
|
|
3
|
+
import { failure, escapePattern } from './detection.js';
|
|
4
|
+
import { atomicWrite, readTextFile } from './files.js';
|
|
5
|
+
|
|
6
|
+
export const restoreMapKind = 'sensitive-data-restore-map';
|
|
7
|
+
export const documentDigest = (text) =>
|
|
8
|
+
createHash('sha256').update(text).digest('hex');
|
|
9
|
+
const defaultFormat = '{Type}-{n}';
|
|
10
|
+
export function validatePlaceholderFormat(format = defaultFormat) {
|
|
11
|
+
if (
|
|
12
|
+
typeof format !== 'string' ||
|
|
13
|
+
format.length > 128 ||
|
|
14
|
+
!/\{(?:Type|TYPE|type)\}/.test(format) ||
|
|
15
|
+
!format.includes('{n}') ||
|
|
16
|
+
/[\r\n\0]/.test(format) ||
|
|
17
|
+
(format.match(/\{n\}/g) ?? []).length !== 1 ||
|
|
18
|
+
(format.match(/\{(?:Type|TYPE|type)\}/g) ?? []).length !== 1 ||
|
|
19
|
+
/[{}]/.test(
|
|
20
|
+
format
|
|
21
|
+
.replace(/\{(?:Type|TYPE|type|n)\}/g, '')
|
|
22
|
+
.replaceAll('{{', '')
|
|
23
|
+
.replaceAll('}}', '')
|
|
24
|
+
)
|
|
25
|
+
) {
|
|
26
|
+
throw failure('ERR_CONFIG');
|
|
27
|
+
}
|
|
28
|
+
return format;
|
|
29
|
+
}
|
|
30
|
+
export function placeholderToken(type, number, format = defaultFormat) {
|
|
31
|
+
const title = type
|
|
32
|
+
.toLowerCase()
|
|
33
|
+
.split('_')
|
|
34
|
+
.filter(Boolean)
|
|
35
|
+
.map((word) => word[0].toUpperCase() + word.slice(1))
|
|
36
|
+
.join('');
|
|
37
|
+
return format.replace(
|
|
38
|
+
/\{(Type|TYPE|type|n)\}/g,
|
|
39
|
+
(_, key) =>
|
|
40
|
+
({
|
|
41
|
+
Type: title,
|
|
42
|
+
TYPE: type,
|
|
43
|
+
type: type.toLowerCase(),
|
|
44
|
+
n: String(number),
|
|
45
|
+
})[key]
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
export function createRestoreMap({ format = defaultFormat } = {}) {
|
|
49
|
+
return {
|
|
50
|
+
kind: restoreMapKind,
|
|
51
|
+
version: 1,
|
|
52
|
+
format: validatePlaceholderFormat(format),
|
|
53
|
+
entries: [],
|
|
54
|
+
documents: [],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export function validateRestoreMap(map) {
|
|
58
|
+
if (
|
|
59
|
+
!map ||
|
|
60
|
+
map.kind !== restoreMapKind ||
|
|
61
|
+
map.version !== 1 ||
|
|
62
|
+
!Array.isArray(map.entries) ||
|
|
63
|
+
!Array.isArray(map.documents) ||
|
|
64
|
+
map.entries.length > 100000 ||
|
|
65
|
+
map.documents.length > 100000
|
|
66
|
+
) {
|
|
67
|
+
throw failure('ERR_MAP');
|
|
68
|
+
}
|
|
69
|
+
validatePlaceholderFormat(map.format);
|
|
70
|
+
validateEntries(map);
|
|
71
|
+
for (const doc of map.documents) {
|
|
72
|
+
validateDocument(doc);
|
|
73
|
+
}
|
|
74
|
+
return map;
|
|
75
|
+
}
|
|
76
|
+
function validateEntries(map) {
|
|
77
|
+
const tokens = new Set();
|
|
78
|
+
for (const entry of map.entries) {
|
|
79
|
+
if (
|
|
80
|
+
!entry ||
|
|
81
|
+
!/^[A-Z][A-Z0-9_]{0,63}$/.test(entry.type) ||
|
|
82
|
+
!Number.isSafeInteger(entry.number) ||
|
|
83
|
+
entry.number < 1 ||
|
|
84
|
+
entry.token !== placeholderToken(entry.type, entry.number, map.format) ||
|
|
85
|
+
tokens.has(entry.token) ||
|
|
86
|
+
!nonemptyStrings(entry.values) ||
|
|
87
|
+
!Array.isArray(entry.aliases) ||
|
|
88
|
+
entry.aliases.some((v) => typeof v !== 'string' || !v)
|
|
89
|
+
) {
|
|
90
|
+
throw failure('ERR_MAP');
|
|
91
|
+
}
|
|
92
|
+
tokens.add(entry.token);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function nonemptyStrings(values) {
|
|
96
|
+
return (
|
|
97
|
+
Array.isArray(values) &&
|
|
98
|
+
values.length > 0 &&
|
|
99
|
+
values.every((v) => typeof v === 'string' && v.length > 0)
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
function validateDocument(doc) {
|
|
103
|
+
if (
|
|
104
|
+
!doc ||
|
|
105
|
+
!/^[a-f0-9]{64}$/.test(doc.digest) ||
|
|
106
|
+
!Array.isArray(doc.occurrences)
|
|
107
|
+
) {
|
|
108
|
+
throw failure('ERR_MAP');
|
|
109
|
+
}
|
|
110
|
+
let end = 0;
|
|
111
|
+
for (const occurrence of doc.occurrences) {
|
|
112
|
+
if (
|
|
113
|
+
!occurrence ||
|
|
114
|
+
!Number.isSafeInteger(occurrence.start) ||
|
|
115
|
+
occurrence.start < end ||
|
|
116
|
+
!Number.isSafeInteger(occurrence.end) ||
|
|
117
|
+
occurrence.end <= occurrence.start ||
|
|
118
|
+
typeof occurrence.raw !== 'string' ||
|
|
119
|
+
typeof occurrence.token !== 'string' ||
|
|
120
|
+
!occurrence.token ||
|
|
121
|
+
occurrence.end - occurrence.start !== occurrence.token.length
|
|
122
|
+
) {
|
|
123
|
+
throw failure('ERR_MAP');
|
|
124
|
+
}
|
|
125
|
+
end = occurrence.end;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function serializeRestoreMap(map, format = 'json') {
|
|
130
|
+
validateRestoreMap(map);
|
|
131
|
+
if (format === 'json') {
|
|
132
|
+
return `${JSON.stringify(map, null, 2)}\n`;
|
|
133
|
+
}
|
|
134
|
+
if (format !== 'lino') {
|
|
135
|
+
throw failure('ERR_CONFIG');
|
|
136
|
+
}
|
|
137
|
+
// Let the upstream formatter handle quotes, slashes, newlines and Unicode.
|
|
138
|
+
return `${new Link(restoreMapKind, [new Link(JSON.stringify(map))]).toString()}\n`;
|
|
139
|
+
}
|
|
140
|
+
export function parseRestoreMap(text) {
|
|
141
|
+
try {
|
|
142
|
+
if (
|
|
143
|
+
typeof text !== 'string' ||
|
|
144
|
+
Buffer.byteLength(text) > 32 * 1024 * 1024
|
|
145
|
+
) {
|
|
146
|
+
throw failure('ERR_MAP');
|
|
147
|
+
}
|
|
148
|
+
if (text.trimStart().startsWith('{')) {
|
|
149
|
+
return validateRestoreMap(JSON.parse(text));
|
|
150
|
+
}
|
|
151
|
+
const links = new Parser({
|
|
152
|
+
maxInputSize: 32 * 1024 * 1024,
|
|
153
|
+
maxDepth: 8,
|
|
154
|
+
}).parse(text);
|
|
155
|
+
if (
|
|
156
|
+
links.length !== 1 ||
|
|
157
|
+
links[0].id !== restoreMapKind ||
|
|
158
|
+
links[0].values.length !== 1 ||
|
|
159
|
+
links[0].values[0].values.length
|
|
160
|
+
) {
|
|
161
|
+
throw failure('ERR_MAP');
|
|
162
|
+
}
|
|
163
|
+
return validateRestoreMap(JSON.parse(links[0].values[0].id));
|
|
164
|
+
} catch {
|
|
165
|
+
throw failure('ERR_MAP');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
export const readRestoreMap = async (path) =>
|
|
169
|
+
parseRestoreMap(await readTextFile(path, 32 * 1024 * 1024));
|
|
170
|
+
export async function writeRestoreMap(
|
|
171
|
+
path,
|
|
172
|
+
map,
|
|
173
|
+
{ format = path.endsWith('.lino') ? 'lino' : 'json', replace = false } = {}
|
|
174
|
+
) {
|
|
175
|
+
await atomicWrite(path, serializeRestoreMap(map, format), { replace });
|
|
176
|
+
}
|
|
177
|
+
export function rejectRestoreMap(text) {
|
|
178
|
+
if (
|
|
179
|
+
/"kind"\s*:\s*"sensitive-data-restore-map"|\(["\x27`]?sensitive-data-restore-map["\x27`]?\s*:/.test(
|
|
180
|
+
text
|
|
181
|
+
)
|
|
182
|
+
) {
|
|
183
|
+
throw failure('ERR_PRIVATE_MAP');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
export function restore(text, map) {
|
|
187
|
+
if (typeof text !== 'string') {
|
|
188
|
+
throw failure('ERR_INPUT');
|
|
189
|
+
}
|
|
190
|
+
validateRestoreMap(map);
|
|
191
|
+
const document = map.documents.find(
|
|
192
|
+
(doc) => doc.digest === documentDigest(text)
|
|
193
|
+
);
|
|
194
|
+
if (document) {
|
|
195
|
+
let cursor = 0;
|
|
196
|
+
const parts = [];
|
|
197
|
+
for (const occurrence of document.occurrences) {
|
|
198
|
+
if (text.slice(occurrence.start, occurrence.end) !== occurrence.token) {
|
|
199
|
+
throw failure('ERR_MAP');
|
|
200
|
+
}
|
|
201
|
+
parts.push(text.slice(cursor, occurrence.start), occurrence.raw);
|
|
202
|
+
cursor = occurrence.end;
|
|
203
|
+
}
|
|
204
|
+
return parts.join('') + text.slice(cursor);
|
|
205
|
+
}
|
|
206
|
+
const entries = new Map(
|
|
207
|
+
map.entries.map((entry) => [entry.token, entry.values[0]])
|
|
208
|
+
);
|
|
209
|
+
if (!entries.size) {
|
|
210
|
+
return text;
|
|
211
|
+
}
|
|
212
|
+
const pattern = new RegExp(
|
|
213
|
+
`(?<![\\p{L}\\p{N}_])(?:${[...entries.keys()]
|
|
214
|
+
.sort((a, b) => b.length - a.length)
|
|
215
|
+
.map(escapePattern)
|
|
216
|
+
.join('|')})(?![\\p{L}\\p{N}_-])`,
|
|
217
|
+
'gu'
|
|
218
|
+
);
|
|
219
|
+
return text.replace(pattern, (token) => entries.get(token));
|
|
220
|
+
}
|
package/src/sanitizer.js
CHANGED
|
@@ -16,6 +16,17 @@ import { URL } from 'node:url';
|
|
|
16
16
|
import { isPublic } from './public.js';
|
|
17
17
|
import { validateTransforms, renderReplacement } from './transforms.js';
|
|
18
18
|
import { fakeIdentityValue, isIdentityType } from './identity.js';
|
|
19
|
+
import { rejectRestoreMap } from './restore-map.js';
|
|
20
|
+
import {
|
|
21
|
+
initializePlaceholders,
|
|
22
|
+
placeholderKnown,
|
|
23
|
+
preparePlaceholderText,
|
|
24
|
+
finishPlaceholders,
|
|
25
|
+
hasPlaceholders,
|
|
26
|
+
placeholderValues,
|
|
27
|
+
placeholderNameFindings,
|
|
28
|
+
nestedPlaceholderOptions,
|
|
29
|
+
} from './placeholders.js';
|
|
19
30
|
import {
|
|
20
31
|
structuredBatches,
|
|
21
32
|
mapStructuredFindings,
|
|
@@ -25,9 +36,16 @@ import {
|
|
|
25
36
|
} from './structured.js';
|
|
26
37
|
|
|
27
38
|
const fakedResults = new WeakMap();
|
|
28
|
-
const publicTypes = new Set([
|
|
39
|
+
const publicTypes = new Set([
|
|
40
|
+
'PERSON',
|
|
41
|
+
'ORGANIZATION',
|
|
42
|
+
'EDUCATION',
|
|
43
|
+
'LOCATION',
|
|
44
|
+
'EMAIL',
|
|
45
|
+
]);
|
|
29
46
|
const optionNames = new Set([
|
|
30
47
|
'credentialsOnly',
|
|
48
|
+
'knownOnly',
|
|
31
49
|
'knownSecrets',
|
|
32
50
|
'knownPersonal',
|
|
33
51
|
'confirmedPersonal',
|
|
@@ -53,9 +71,13 @@ const optionNames = new Set([
|
|
|
53
71
|
'minConfidence',
|
|
54
72
|
'threshold',
|
|
55
73
|
'profile',
|
|
74
|
+
'documentEntities',
|
|
56
75
|
]);
|
|
57
76
|
|
|
58
77
|
export function applyProfile(options = {}) {
|
|
78
|
+
if (options?.profile === 'documents') {
|
|
79
|
+
return { ...options, documentEntities: options.documentEntities ?? true };
|
|
80
|
+
}
|
|
59
81
|
return options?.profile === 'publication'
|
|
60
82
|
? {
|
|
61
83
|
...options,
|
|
@@ -145,10 +167,12 @@ export function validateOptions(options = {}) {
|
|
|
145
167
|
for (const key of [
|
|
146
168
|
'paranoid',
|
|
147
169
|
'credentialsOnly',
|
|
170
|
+
'knownOnly',
|
|
148
171
|
'decode',
|
|
149
172
|
'secretlint',
|
|
150
173
|
'publicKnowledge',
|
|
151
174
|
'identityMask',
|
|
175
|
+
'documentEntities',
|
|
152
176
|
]) {
|
|
153
177
|
if (options[key] !== undefined && typeof options[key] !== 'boolean') {
|
|
154
178
|
throw failure('ERR_CONFIG');
|
|
@@ -196,7 +220,7 @@ function validatePolicy(options) {
|
|
|
196
220
|
for (const [key, values] of [
|
|
197
221
|
['fakeIdentity', [false, 'specimen-and-synthetic']],
|
|
198
222
|
['structured', ['json', 'jsonl']],
|
|
199
|
-
['profile', ['publication']],
|
|
223
|
+
['profile', ['publication', 'documents']],
|
|
200
224
|
]) {
|
|
201
225
|
if (options[key] !== undefined && !values.includes(options[key])) {
|
|
202
226
|
throw failure('ERR_CONFIG');
|
|
@@ -212,6 +236,7 @@ export function validateInput(text, options) {
|
|
|
212
236
|
if (typeof text !== 'string') {
|
|
213
237
|
throw failure('ERR_INPUT');
|
|
214
238
|
}
|
|
239
|
+
rejectRestoreMap(text);
|
|
215
240
|
if (text.length > (options.maxInputLength ?? 32 * 1024 * 1024)) {
|
|
216
241
|
throw failure('ERR_LIMIT');
|
|
217
242
|
}
|
|
@@ -423,15 +448,16 @@ function inspectText(text, options = {}) {
|
|
|
423
448
|
|
|
424
449
|
// Reapply policy to the native/required-engine union without rescanning text.
|
|
425
450
|
export function reviewFindings(text, findings, options) {
|
|
451
|
+
findings = placeholderNameFindings(text, findings, options);
|
|
426
452
|
if (findings.length > (options.maxFindings ?? 100000)) {
|
|
427
453
|
throw failure('ERR_LIMIT');
|
|
428
454
|
}
|
|
429
455
|
const unique = new Map();
|
|
456
|
+
const exempt = findings.filter((f) =>
|
|
457
|
+
allowed(text, f, options.publicEntities ?? [])
|
|
458
|
+
);
|
|
430
459
|
for (const f of findings) {
|
|
431
|
-
if (
|
|
432
|
-
!allowed(text, f, options.publicEntities ?? []) &&
|
|
433
|
-
!isPublic(text, f, options)
|
|
434
|
-
) {
|
|
460
|
+
if (!coveredByPublic(f, exempt) && !isPublic(text, f, options)) {
|
|
435
461
|
unique.set(`${f.start}:${f.end}:${f.category}:${f.type}:${f.rule}`, f);
|
|
436
462
|
}
|
|
437
463
|
}
|
|
@@ -449,6 +475,18 @@ export function reviewFindings(text, findings, options) {
|
|
|
449
475
|
options.debug?.({ event: 'detected', count: result.length });
|
|
450
476
|
return result;
|
|
451
477
|
}
|
|
478
|
+
function coveredByPublic(finding, exempt) {
|
|
479
|
+
return (
|
|
480
|
+
finding.category === 'personal' &&
|
|
481
|
+
!['known-personal', 'confirmed-personal'].includes(finding.rule) &&
|
|
482
|
+
exempt.some(
|
|
483
|
+
(span) =>
|
|
484
|
+
span.type === finding.type &&
|
|
485
|
+
span.start <= finding.start &&
|
|
486
|
+
span.end >= finding.end
|
|
487
|
+
)
|
|
488
|
+
);
|
|
489
|
+
}
|
|
452
490
|
|
|
453
491
|
export function inspectFindingsOnly(text, findings, options) {
|
|
454
492
|
const all = [...findings, ...(options.findings ?? [])].map((f) =>
|
|
@@ -484,7 +522,10 @@ function selectedFindings(findings, credentialsOnly) {
|
|
|
484
522
|
|
|
485
523
|
export function markFake(text, findings, options) {
|
|
486
524
|
findings = selectedFindings(findings, options.credentialsOnly);
|
|
487
|
-
const generated =
|
|
525
|
+
const generated = [
|
|
526
|
+
...generatedValues(options),
|
|
527
|
+
...placeholderValues(options),
|
|
528
|
+
];
|
|
488
529
|
if (
|
|
489
530
|
!options.fakeIdentity &&
|
|
490
531
|
!options.fakeValues?.length &&
|
|
@@ -651,37 +692,72 @@ export function redactResult(
|
|
|
651
692
|
) {
|
|
652
693
|
options = applyProfile(options);
|
|
653
694
|
const spans = validatedSpans(text, findings, options);
|
|
695
|
+
options = initializePlaceholders(options);
|
|
696
|
+
preparePlaceholderText(text, findings, options);
|
|
654
697
|
const parts = [];
|
|
655
698
|
const faked = [];
|
|
699
|
+
const occurrences = [];
|
|
700
|
+
let irreversibleRedactions = 0;
|
|
701
|
+
let outputLength = 0;
|
|
656
702
|
let cursor = 0;
|
|
657
703
|
for (const span of spans) {
|
|
658
704
|
const replacement =
|
|
659
705
|
replacements.get(`${span.start}:${span.end}`) ??
|
|
660
706
|
renderReplacement(text, span, options, (decoded) =>
|
|
661
|
-
sanitize(
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
707
|
+
sanitize(
|
|
708
|
+
decoded,
|
|
709
|
+
nestedPlaceholderOptions({
|
|
710
|
+
...confirmationOptions(text, findings, options),
|
|
711
|
+
findings: [],
|
|
712
|
+
preserveEncoding: true,
|
|
713
|
+
structured: undefined,
|
|
714
|
+
})
|
|
715
|
+
)
|
|
667
716
|
);
|
|
668
717
|
parts.push(text.slice(cursor, span.start), replacement);
|
|
718
|
+
outputLength += span.start - cursor;
|
|
669
719
|
const transform =
|
|
670
720
|
options.transformations?.[span.type] ?? options.transformation;
|
|
721
|
+
if (retainedReplacement(transform, 'placeholder', span, replacement)) {
|
|
722
|
+
occurrences.push({
|
|
723
|
+
start: outputLength,
|
|
724
|
+
end: outputLength + replacement.length,
|
|
725
|
+
token: replacement,
|
|
726
|
+
raw: text.slice(span.start, span.end),
|
|
727
|
+
});
|
|
728
|
+
}
|
|
671
729
|
if (
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
(span.category === 'personal' || transform.credentials)
|
|
730
|
+
hasPlaceholders(options) &&
|
|
731
|
+
!retainedReplacement(transform, 'placeholder', span, replacement)
|
|
675
732
|
) {
|
|
733
|
+
irreversibleRedactions++;
|
|
734
|
+
}
|
|
735
|
+
outputLength += replacement.length;
|
|
736
|
+
if (retainedReplacement(transform, 'fake', span, replacement)) {
|
|
676
737
|
faked.push(span);
|
|
677
738
|
}
|
|
678
739
|
cursor = span.end;
|
|
679
740
|
}
|
|
680
741
|
parts.push(text.slice(cursor));
|
|
681
|
-
const result =
|
|
742
|
+
const result = finishPlaceholders(
|
|
743
|
+
{
|
|
744
|
+
text: parts.join(''),
|
|
745
|
+
redactions: spans.length,
|
|
746
|
+
...(hasPlaceholders(options) ? { irreversibleRedactions } : {}),
|
|
747
|
+
},
|
|
748
|
+
occurrences,
|
|
749
|
+
options
|
|
750
|
+
);
|
|
682
751
|
fakedResults.set(result, faked);
|
|
683
752
|
return result;
|
|
684
753
|
}
|
|
754
|
+
function retainedReplacement(transform, mode, span, replacement) {
|
|
755
|
+
return (
|
|
756
|
+
transform?.mode === mode &&
|
|
757
|
+
replacement !== REDACTED &&
|
|
758
|
+
(span.category === 'personal' || transform.credentials)
|
|
759
|
+
);
|
|
760
|
+
}
|
|
685
761
|
|
|
686
762
|
export async function redactResultAsync(
|
|
687
763
|
text,
|
|
@@ -689,10 +765,18 @@ export async function redactResultAsync(
|
|
|
689
765
|
options,
|
|
690
766
|
sanitizeDecoded
|
|
691
767
|
) {
|
|
768
|
+
options = initializePlaceholders(options);
|
|
769
|
+
preparePlaceholderText(text, findings, options);
|
|
692
770
|
const replacements = new Map();
|
|
693
771
|
{
|
|
694
772
|
for (const span of validatedSpans(text, findings, options)) {
|
|
695
773
|
if (span.type !== 'ENCODED_SENSITIVE') {
|
|
774
|
+
if (hasPlaceholders(options)) {
|
|
775
|
+
replacements.set(
|
|
776
|
+
`${span.start}:${span.end}`,
|
|
777
|
+
renderReplacement(text, span, options)
|
|
778
|
+
);
|
|
779
|
+
}
|
|
696
780
|
continue;
|
|
697
781
|
}
|
|
698
782
|
const run = encodedValue(text.slice(span.start, span.end));
|
|
@@ -707,7 +791,7 @@ export async function redactResultAsync(
|
|
|
707
791
|
}
|
|
708
792
|
const result = await sanitizeDecoded(
|
|
709
793
|
run.text,
|
|
710
|
-
confirmationOptions(text, findings, options)
|
|
794
|
+
nestedPlaceholderOptions(confirmationOptions(text, findings, options))
|
|
711
795
|
);
|
|
712
796
|
replacements.set(
|
|
713
797
|
`${span.start}:${span.end}`,
|
|
@@ -731,6 +815,8 @@ export function sanitize(text, options = {}) {
|
|
|
731
815
|
}
|
|
732
816
|
function sanitizeText(text, options = {}, round = 0) {
|
|
733
817
|
options = applyProfile(options);
|
|
818
|
+
validateOptions(options);
|
|
819
|
+
options = placeholderKnown(initializePlaceholders(options));
|
|
734
820
|
const findings = inspect(text, options);
|
|
735
821
|
const result = redactResult(text, findings, options);
|
|
736
822
|
const sanitized = redactResult(text, findings, {
|
|
@@ -752,6 +838,14 @@ function sanitizeText(text, options = {}, round = 0) {
|
|
|
752
838
|
if (options.structured) {
|
|
753
839
|
verifyStructured(result.text, options);
|
|
754
840
|
}
|
|
841
|
+
if (
|
|
842
|
+
hasPlaceholders(options) &&
|
|
843
|
+
inspect(result.text, { ...options, findings: [] }).some((f) =>
|
|
844
|
+
actionable(f, options)
|
|
845
|
+
)
|
|
846
|
+
) {
|
|
847
|
+
throw failure('ERR_RESIDUAL');
|
|
848
|
+
}
|
|
755
849
|
return { ...result, findings: auditFaked(findings, options, result) };
|
|
756
850
|
}
|
|
757
851
|
|
|
@@ -780,6 +874,13 @@ export function redactAgain(result, findings, options, round, rerun) {
|
|
|
780
874
|
const merge = (next) => ({
|
|
781
875
|
...next,
|
|
782
876
|
redactions: result.redactions + next.redactions,
|
|
877
|
+
...(hasPlaceholders(options)
|
|
878
|
+
? {
|
|
879
|
+
irreversibleRedactions:
|
|
880
|
+
(result.irreversibleRedactions ?? 0) +
|
|
881
|
+
(next.irreversibleRedactions ?? 0),
|
|
882
|
+
}
|
|
883
|
+
: {}),
|
|
783
884
|
findings: auditFaked(findings, options, result),
|
|
784
885
|
});
|
|
785
886
|
const next = rerun({ ...options, findings: [] }, round + 1);
|
package/src/stream.js
CHANGED
|
@@ -23,6 +23,11 @@ import { sanitizeJsonlStream } from './jsonl-stream.js';
|
|
|
23
23
|
import { workerExecArgv } from './worker-options.js';
|
|
24
24
|
import { streamIdentity } from './stream-identity.js';
|
|
25
25
|
import { workerResult } from './file-worker-result.js';
|
|
26
|
+
import {
|
|
27
|
+
hasPlaceholders,
|
|
28
|
+
initializePlaceholders,
|
|
29
|
+
beginPlaceholderStream,
|
|
30
|
+
} from './placeholders.js';
|
|
26
31
|
|
|
27
32
|
const streamKeys = new Set([
|
|
28
33
|
'sanitizer',
|
|
@@ -63,7 +68,7 @@ function configuration(options, collecting = false, complete) {
|
|
|
63
68
|
collecting,
|
|
64
69
|
complete
|
|
65
70
|
);
|
|
66
|
-
const sanitizerOptions = identity.options;
|
|
71
|
+
const sanitizerOptions = initializePlaceholders(identity.options);
|
|
67
72
|
const engine = createSanitizer(sanitizerOptions);
|
|
68
73
|
return {
|
|
69
74
|
maxRecordBytes,
|
|
@@ -215,6 +220,18 @@ export async function* sanitizeStream(source, options = {}) {
|
|
|
215
220
|
}
|
|
216
221
|
}
|
|
217
222
|
async function* streamWithConfiguration(source, options, config) {
|
|
223
|
+
const finish = config.collecting
|
|
224
|
+
? () => {}
|
|
225
|
+
: beginPlaceholderStream(config.sanitizerOptions);
|
|
226
|
+
let complete = false;
|
|
227
|
+
try {
|
|
228
|
+
yield* streamBatches(source, options, config);
|
|
229
|
+
complete = true;
|
|
230
|
+
} finally {
|
|
231
|
+
finish(complete);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
async function* streamBatches(source, options, config) {
|
|
218
235
|
if (config.jsonl) {
|
|
219
236
|
yield* sanitizeJsonlStream(source, options, config);
|
|
220
237
|
return;
|
|
@@ -422,7 +439,11 @@ export async function sanitizeFileToFile(
|
|
|
422
439
|
}
|
|
423
440
|
}
|
|
424
441
|
export async function sanitizeFileBounded(source, target, options = {}) {
|
|
425
|
-
if (
|
|
442
|
+
if (
|
|
443
|
+
options.worker === false ||
|
|
444
|
+
options.sanitizer ||
|
|
445
|
+
hasPlaceholders(options.sanitizerOptions ?? options)
|
|
446
|
+
) {
|
|
426
447
|
return sanitizeFileToFile(source, target, options);
|
|
427
448
|
}
|
|
428
449
|
const workerHeapMb = options.workerHeapMb ?? 256,
|
package/src/structured.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { failure } from './detection.js';
|
|
2
|
+
import { hasPlaceholders } from './placeholders.js';
|
|
2
3
|
|
|
3
4
|
// Session metadata is opt-in at the CLI; library callers choose their own
|
|
4
5
|
// fields. These names do not grant exemptions to provider/known credentials.
|
|
@@ -164,7 +165,7 @@ function ordinaryPersonalKey(entry, finding) {
|
|
|
164
165
|
// Propagation runs again at source offsets. Apply the same key policy after it
|
|
165
166
|
// so a name confirmed in a value cannot rename an ordinary identifier key.
|
|
166
167
|
export function preserveIdentifierKeys(text, findings, options) {
|
|
167
|
-
if (!options.structured) {
|
|
168
|
+
if (!options.structured || hasPlaceholders(options)) {
|
|
168
169
|
return findings;
|
|
169
170
|
}
|
|
170
171
|
const keys = structuredBatches(text, options).flatMap((batch) =>
|
|
@@ -193,6 +194,9 @@ export function preserveIdentifierKeys(text, findings, options) {
|
|
|
193
194
|
});
|
|
194
195
|
}
|
|
195
196
|
function preserveStructuredEntry(entry, finding, text, options) {
|
|
197
|
+
if (hasPlaceholders(options)) {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
196
200
|
return (
|
|
197
201
|
(entry.key &&
|
|
198
202
|
['confirmed-personal', 'document-propagation'].includes(finding.rule)) ||
|
package/src/transforms.js
CHANGED
|
@@ -3,6 +3,7 @@ import { createHmac } from 'node:crypto';
|
|
|
3
3
|
import { failure, REDACTED } from './detection.js';
|
|
4
4
|
import { encodedValue, encodeRun } from './encoded.js';
|
|
5
5
|
import { isIdentityType } from './identity.js';
|
|
6
|
+
import { validatePlaceholder, placeholderReplacement } from './placeholders.js';
|
|
6
7
|
|
|
7
8
|
const modes = new Set([
|
|
8
9
|
'redact',
|
|
@@ -13,6 +14,7 @@ const modes = new Set([
|
|
|
13
14
|
'format-preserving',
|
|
14
15
|
'date-shift',
|
|
15
16
|
'bucket',
|
|
17
|
+
'placeholder',
|
|
16
18
|
]);
|
|
17
19
|
export function validateTransforms(options) {
|
|
18
20
|
if (
|
|
@@ -43,6 +45,9 @@ function validateTransform(transform) {
|
|
|
43
45
|
if (!transform || !modes.has(transform.mode)) {
|
|
44
46
|
throw failure('ERR_CONFIG');
|
|
45
47
|
}
|
|
48
|
+
if (transform.mode === 'placeholder') {
|
|
49
|
+
validatePlaceholder(transform);
|
|
50
|
+
}
|
|
46
51
|
const checks = {
|
|
47
52
|
fake: () =>
|
|
48
53
|
typeof transform.key === 'string' &&
|
|
@@ -177,6 +182,9 @@ export function renderReplacement(text, finding, options, encodedSanitize) {
|
|
|
177
182
|
throw failure('ERR_ENCODING');
|
|
178
183
|
}
|
|
179
184
|
}
|
|
185
|
+
return renderValue(text, finding, transform, options);
|
|
186
|
+
}
|
|
187
|
+
function renderValue(text, finding, transform, options) {
|
|
180
188
|
let raw = text.slice(finding.start, finding.end);
|
|
181
189
|
const jsonQuoted =
|
|
182
190
|
text[finding.start - 1] === '"' && text[finding.end] === '"';
|
|
@@ -187,6 +195,9 @@ export function renderReplacement(text, finding, options, encodedSanitize) {
|
|
|
187
195
|
return REDACTED;
|
|
188
196
|
}
|
|
189
197
|
}
|
|
190
|
-
const result =
|
|
198
|
+
const result =
|
|
199
|
+
transform?.mode === 'placeholder'
|
|
200
|
+
? placeholderReplacement(raw, finding, transform, options)
|
|
201
|
+
: transformed(raw, finding, transform);
|
|
191
202
|
return jsonQuoted ? JSON.stringify(result).slice(1, -1) : result;
|
|
192
203
|
}
|