@abhishekvardanbotta/mask 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +142 -3
- package/dist/crypto.d.ts +23 -0
- package/dist/crypto.js +45 -0
- package/dist/detectors/recognizers.d.ts +24 -0
- package/dist/detectors/recognizers.js +274 -0
- package/dist/detectors/validators.d.ts +29 -0
- package/dist/detectors/validators.js +209 -0
- package/dist/fields.d.ts +39 -0
- package/dist/fields.js +219 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +9 -0
- package/dist/policy.d.ts +36 -0
- package/dist/policy.js +117 -0
- package/dist/privacy.d.ts +154 -0
- package/dist/privacy.js +600 -0
- package/dist/tokens.d.ts +20 -0
- package/dist/tokens.js +25 -0
- package/dist/types.d.ts +98 -0
- package/dist/types.js +8 -0
- package/dist/vault.d.ts +30 -0
- package/dist/vault.js +29 -0
- package/package.json +50 -4
package/dist/privacy.js
ADDED
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
import { deriveKeys, fingerprint, normalizeMasterKey, open, seal } from "./crypto.js";
|
|
2
|
+
import { runRecognizer } from "./detectors/recognizers.js";
|
|
3
|
+
import { asNode, inferFieldType, itemSchema, matchFieldRule, nodeAnnotation, propertySchema, typeFromKey } from "./fields.js";
|
|
4
|
+
import { compilePolicy, deniedReplacement, isGranted, mergePolicyConfig } from "./policy.js";
|
|
5
|
+
import { PrivacyError, } from "./types.js";
|
|
6
|
+
import { InMemoryVaultStore } from "./vault.js";
|
|
7
|
+
import { randomToken, renderToken, TOKEN_MARKER, TOKEN_REFERENCE } from "./tokens.js";
|
|
8
|
+
const TOKEN_ID_LENGTH = 10;
|
|
9
|
+
/** Strict `[TYPE_id]` plus a bare `TYPE_id` (models sometimes drop the brackets); only tokens in this scope's vault are ever resolved. */
|
|
10
|
+
const MAX_AUDIT_EVENTS = 5000;
|
|
11
|
+
const MAX_MEMO_ENTRIES = 2000;
|
|
12
|
+
/** Everything one scope knows: shared by every policy view of that scope. */
|
|
13
|
+
class ScopeState {
|
|
14
|
+
scope;
|
|
15
|
+
byToken = new Map();
|
|
16
|
+
byFingerprint = new Map();
|
|
17
|
+
/** Exact plaintexts already tokenized in this scope -> token, so later free-text mentions are caught too. */
|
|
18
|
+
known = new Map();
|
|
19
|
+
knownVersion = 0;
|
|
20
|
+
/** Last sequential id issued per type. */
|
|
21
|
+
sequence = new Map();
|
|
22
|
+
knownRegexCache;
|
|
23
|
+
pending = [];
|
|
24
|
+
audit = [];
|
|
25
|
+
blocked = 0;
|
|
26
|
+
restored = 0;
|
|
27
|
+
denied = 0;
|
|
28
|
+
lastUsed = Date.now();
|
|
29
|
+
constructor(scope) {
|
|
30
|
+
this.scope = scope;
|
|
31
|
+
}
|
|
32
|
+
record(event) {
|
|
33
|
+
if (this.audit.length >= MAX_AUDIT_EVENTS)
|
|
34
|
+
this.audit.shift();
|
|
35
|
+
this.audit.push({ at: new Date().toISOString(), ...event });
|
|
36
|
+
}
|
|
37
|
+
/** Two alternations over the known plaintexts: case-sensitive, and case-folded for emails. */
|
|
38
|
+
knownRegexes() {
|
|
39
|
+
if (this.knownRegexCache?.version === this.knownVersion)
|
|
40
|
+
return this.knownRegexCache;
|
|
41
|
+
const exact = [];
|
|
42
|
+
const folded = [];
|
|
43
|
+
const foldedMap = new Map();
|
|
44
|
+
for (const [value, token] of this.known) {
|
|
45
|
+
if (this.byToken.get(token)?.type === "EMAIL") {
|
|
46
|
+
folded.push(value);
|
|
47
|
+
foldedMap.set(value.toLowerCase(), token);
|
|
48
|
+
}
|
|
49
|
+
else
|
|
50
|
+
exact.push(value);
|
|
51
|
+
}
|
|
52
|
+
const build = (values, flags) => values.length
|
|
53
|
+
? new RegExp(`(?<![\\p{L}\\p{N}_])(?:${values.sort((a, b) => b.length - a.length).map(escapeRegex).join("|")})(?![\\p{L}\\p{N}_])`, flags)
|
|
54
|
+
: undefined;
|
|
55
|
+
this.knownRegexCache = { version: this.knownVersion, exact: build(exact, "gu"), folded: build(folded, "giu"), foldedMap };
|
|
56
|
+
return this.knownRegexCache;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function escapeRegex(value) {
|
|
60
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
61
|
+
}
|
|
62
|
+
function normalizeForType(type, value) {
|
|
63
|
+
const trimmed = value.trim();
|
|
64
|
+
switch (type) {
|
|
65
|
+
case "EMAIL": return trimmed.toLowerCase();
|
|
66
|
+
case "PHONE": return (trimmed.startsWith("+") ? "+" : "") + trimmed.replace(/\D/g, "");
|
|
67
|
+
case "CREDIT_CARD":
|
|
68
|
+
case "US_SSN":
|
|
69
|
+
case "IN_AADHAAR":
|
|
70
|
+
case "CARD_SECURITY_CODE": return trimmed.replace(/\D/g, "");
|
|
71
|
+
case "IBAN":
|
|
72
|
+
case "IN_PAN": return trimmed.replace(/\s+/g, "").toUpperCase();
|
|
73
|
+
default: return trimmed.replace(/\s+/g, " ").toLowerCase();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Plaintexts shorter than this are not used for free-text matching: "Al" or "42" would match everywhere. */
|
|
77
|
+
function knownEligible(value) {
|
|
78
|
+
if (/^[\d\s().+-]+$/.test(value))
|
|
79
|
+
return value.replace(/\D/g, "").length >= 4;
|
|
80
|
+
return value.trim().length >= 3;
|
|
81
|
+
}
|
|
82
|
+
function isPlainObject(value) {
|
|
83
|
+
if (!value || typeof value !== "object")
|
|
84
|
+
return false;
|
|
85
|
+
const proto = Object.getPrototypeOf(value);
|
|
86
|
+
return proto === Object.prototype || proto === null;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The privacy engine: holds the keys, the vault store and the per-scope state. Create one per process (or per
|
|
90
|
+
* tenant, if tenants need separate keys) and open a session per conversation, run or request.
|
|
91
|
+
*/
|
|
92
|
+
export class Privacy {
|
|
93
|
+
keys;
|
|
94
|
+
store;
|
|
95
|
+
retentionMs;
|
|
96
|
+
tokenStyle;
|
|
97
|
+
baseConfig;
|
|
98
|
+
basePolicy;
|
|
99
|
+
scopes = new Map();
|
|
100
|
+
hydrating = new Map();
|
|
101
|
+
constructor(options = {}) {
|
|
102
|
+
const { key, store, retentionMs, tokenStyle, ...policy } = options;
|
|
103
|
+
this.keys = deriveKeys(normalizeMasterKey(key));
|
|
104
|
+
this.store = store ?? new InMemoryVaultStore();
|
|
105
|
+
this.retentionMs = retentionMs ?? 60 * 60 * 1000;
|
|
106
|
+
if (tokenStyle !== undefined && tokenStyle !== "random" && tokenStyle !== "sequential" && tokenStyle !== "opaque")
|
|
107
|
+
throw new PrivacyError(`mask: tokenStyle must be "random", "sequential" or "opaque".`, "config");
|
|
108
|
+
this.tokenStyle = tokenStyle ?? "random";
|
|
109
|
+
this.baseConfig = policy;
|
|
110
|
+
this.basePolicy = compilePolicy(policy);
|
|
111
|
+
}
|
|
112
|
+
/** Compiles a policy layered over this engine's own. Compile once and reuse it across sessions. */
|
|
113
|
+
policy(overrides) {
|
|
114
|
+
return overrides ? compilePolicy(mergePolicyConfig(this.baseConfig, overrides)) : this.basePolicy;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* A session over `scope`, without loading previously persisted records. Tokens are deterministic within a scope
|
|
118
|
+
* and unlinkable across scopes. Use `open()` instead when the scope may have records from an earlier process.
|
|
119
|
+
*/
|
|
120
|
+
session(scope, policy) {
|
|
121
|
+
this.sweep();
|
|
122
|
+
let state = this.scopes.get(scope);
|
|
123
|
+
if (!state) {
|
|
124
|
+
state = new ScopeState(scope);
|
|
125
|
+
this.scopes.set(scope, state);
|
|
126
|
+
}
|
|
127
|
+
return new PrivacySession(this, state, this.resolvePolicy(policy));
|
|
128
|
+
}
|
|
129
|
+
/** Like `session()`, but first loads and decrypts the scope's persisted records from the store. */
|
|
130
|
+
async open(scope, policy) {
|
|
131
|
+
this.sweep();
|
|
132
|
+
let state = this.scopes.get(scope);
|
|
133
|
+
if (!state) {
|
|
134
|
+
let loading = this.hydrating.get(scope);
|
|
135
|
+
if (!loading) {
|
|
136
|
+
loading = this.hydrate(scope).finally(() => this.hydrating.delete(scope));
|
|
137
|
+
this.hydrating.set(scope, loading);
|
|
138
|
+
}
|
|
139
|
+
state = await loading;
|
|
140
|
+
}
|
|
141
|
+
return new PrivacySession(this, state, this.resolvePolicy(policy));
|
|
142
|
+
}
|
|
143
|
+
/** Forgets a scope: drops its in-memory state and deletes its records from the store. Its tokens become unrestorable. */
|
|
144
|
+
async destroy(scope) {
|
|
145
|
+
const state = this.scopes.get(scope);
|
|
146
|
+
if (state)
|
|
147
|
+
await Promise.allSettled(state.pending);
|
|
148
|
+
this.scopes.delete(scope);
|
|
149
|
+
await this.store.deleteScope(scope);
|
|
150
|
+
}
|
|
151
|
+
/** Runs detection only — what would be found in `text`, with confidences — without tokenizing anything. */
|
|
152
|
+
detect(text, policy) {
|
|
153
|
+
return detectSync(text, this.resolvePolicy(policy), []).map(({ type, start, end, score }) => ({ type, start, end, score }));
|
|
154
|
+
}
|
|
155
|
+
/** @internal */
|
|
156
|
+
tokenFor(state, type, value, classification, kind) {
|
|
157
|
+
const fp = fingerprint(this.keys, state.scope, type, normalizeForType(type, value));
|
|
158
|
+
const existing = state.byFingerprint.get(fp);
|
|
159
|
+
if (existing) {
|
|
160
|
+
const entry = state.byToken.get(existing);
|
|
161
|
+
// The stricter classification wins: once any field marked this value SENSITIVE, it stays SENSITIVE.
|
|
162
|
+
if (classification === "SENSITIVE" && entry.classification === "PII") {
|
|
163
|
+
entry.classification = "SENSITIVE";
|
|
164
|
+
this.persist(state, entry);
|
|
165
|
+
}
|
|
166
|
+
this.remember(state, value, existing, type);
|
|
167
|
+
return existing;
|
|
168
|
+
}
|
|
169
|
+
let token;
|
|
170
|
+
if (this.tokenStyle === "random") {
|
|
171
|
+
let attempt = 0;
|
|
172
|
+
token = randomToken(fp, attempt);
|
|
173
|
+
while (state.byToken.has(token))
|
|
174
|
+
token = randomToken(fp, ++attempt);
|
|
175
|
+
}
|
|
176
|
+
else if (this.tokenStyle === "sequential") {
|
|
177
|
+
let n = state.sequence.get(type) ?? 0;
|
|
178
|
+
do {
|
|
179
|
+
n += 1;
|
|
180
|
+
token = `${type}_${n}`;
|
|
181
|
+
} while (state.byToken.has(token));
|
|
182
|
+
state.sequence.set(type, n);
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
let offset = 0;
|
|
186
|
+
token = `${type}_${fp.slice(0, TOKEN_ID_LENGTH)}`;
|
|
187
|
+
while (state.byToken.has(token)) {
|
|
188
|
+
offset += 1;
|
|
189
|
+
token = `${type}_${fp.slice(offset, offset + TOKEN_ID_LENGTH)}`;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const entry = { token, type, classification, value, kind, fp };
|
|
193
|
+
state.byToken.set(token, entry);
|
|
194
|
+
state.byFingerprint.set(fp, token);
|
|
195
|
+
state.record({ action: "tokenize", type, classification, token });
|
|
196
|
+
this.persist(state, entry);
|
|
197
|
+
this.remember(state, value, token, type);
|
|
198
|
+
if (type === "PERSON" && /\s/.test(value.trim())) {
|
|
199
|
+
// "Jane Doe" also makes "Jane" and "Doe" recognizable later on their own.
|
|
200
|
+
for (const part of value.trim().split(/\s+/))
|
|
201
|
+
if (part.length >= 3 && /^\p{Lu}/u.test(part))
|
|
202
|
+
this.tokenFor(state, "PERSON", part, classification, "string");
|
|
203
|
+
}
|
|
204
|
+
return token;
|
|
205
|
+
}
|
|
206
|
+
remember(state, value, token, type) {
|
|
207
|
+
const key = type === "EMAIL" ? value.trim().toLowerCase() : value.trim();
|
|
208
|
+
if (!knownEligible(key) || state.known.get(key) === token)
|
|
209
|
+
return;
|
|
210
|
+
state.known.set(key, token);
|
|
211
|
+
state.knownVersion += 1;
|
|
212
|
+
}
|
|
213
|
+
persist(state, entry) {
|
|
214
|
+
const sealed = seal(this.keys, JSON.stringify({ v: entry.value }), `${state.scope}\u0000${entry.token}`);
|
|
215
|
+
const now = Date.now();
|
|
216
|
+
const record = { token: entry.token, type: entry.type, classification: entry.classification, kind: entry.kind, fp: entry.fp, createdAt: now, expiresAt: now + this.retentionMs, ...sealed };
|
|
217
|
+
try {
|
|
218
|
+
const result = this.store.put(state.scope, record);
|
|
219
|
+
if (result)
|
|
220
|
+
state.pending.push(result.catch((error) => { throw new PrivacyError(`mask: vault write failed: ${error?.message ?? error}`, "vault"); }));
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
state.pending.push(Promise.reject(new PrivacyError(`mask: vault write failed: ${error?.message ?? error}`, "vault")));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async hydrate(scope) {
|
|
227
|
+
const state = new ScopeState(scope);
|
|
228
|
+
for (const record of await this.store.list(scope)) {
|
|
229
|
+
const { v } = JSON.parse(open(this.keys, record, `${scope}\u0000${record.token}`));
|
|
230
|
+
const entry = { token: record.token, type: record.type, classification: record.classification, value: v, kind: record.kind, fp: record.fp };
|
|
231
|
+
state.byToken.set(entry.token, entry);
|
|
232
|
+
state.byFingerprint.set(entry.fp, entry.token);
|
|
233
|
+
const seq = /_([1-9]\d{0,5})$/.exec(entry.token);
|
|
234
|
+
if (seq && entry.token === `${entry.type}_${seq[1]}`)
|
|
235
|
+
state.sequence.set(entry.type, Math.max(state.sequence.get(entry.type) ?? 0, Number(seq[1])));
|
|
236
|
+
this.remember(state, v, entry.token, entry.type);
|
|
237
|
+
}
|
|
238
|
+
this.scopes.set(scope, state);
|
|
239
|
+
return state;
|
|
240
|
+
}
|
|
241
|
+
resolvePolicy(policy) {
|
|
242
|
+
if (!policy)
|
|
243
|
+
return this.basePolicy;
|
|
244
|
+
return typeof policy.classify === "function" ? policy : this.policy(policy);
|
|
245
|
+
}
|
|
246
|
+
/** Drops in-memory scopes idle for longer than the retention period. Their store records expire on the same clock. */
|
|
247
|
+
sweep() {
|
|
248
|
+
const cutoff = Date.now() - this.retentionMs;
|
|
249
|
+
for (const [scope, state] of this.scopes)
|
|
250
|
+
if (state.lastUsed < cutoff)
|
|
251
|
+
this.scopes.delete(scope);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function detectSync(text, policy, extra, state) {
|
|
255
|
+
const protectedSpans = [];
|
|
256
|
+
for (const match of text.matchAll(TOKEN_MARKER))
|
|
257
|
+
protectedSpans.push([match.index, match.index + match[0].length]);
|
|
258
|
+
const candidates = [...extra];
|
|
259
|
+
for (const recognizer of policy.recognizers)
|
|
260
|
+
candidates.push(...runRecognizer(recognizer, text));
|
|
261
|
+
for (const detector of policy.detectors) {
|
|
262
|
+
const result = detector.detect(text);
|
|
263
|
+
if (Array.isArray(result))
|
|
264
|
+
candidates.push(...result);
|
|
265
|
+
// A promise means an asynchronous detector: it ran in protect()'s pre-pass and arrives via `extra`.
|
|
266
|
+
}
|
|
267
|
+
if (state?.known.size) {
|
|
268
|
+
const { exact, folded, foldedMap } = state.knownRegexes();
|
|
269
|
+
if (exact)
|
|
270
|
+
for (const m of text.matchAll(exact)) {
|
|
271
|
+
const token = state.known.get(m[0]);
|
|
272
|
+
if (token)
|
|
273
|
+
candidates.push({ type: state.byToken.get(token).type, start: m.index, end: m.index + m[0].length, score: 1, token });
|
|
274
|
+
}
|
|
275
|
+
if (folded)
|
|
276
|
+
for (const m of text.matchAll(folded)) {
|
|
277
|
+
const token = foldedMap.get(m[0].toLowerCase());
|
|
278
|
+
if (token)
|
|
279
|
+
candidates.push({ type: "EMAIL", start: m.index, end: m.index + m[0].length, score: 1, token });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// One occupancy map for existing tokens and chosen spans: overlap checks cost O(span length), not O(detections),
|
|
283
|
+
// which kept large inputs (thousands of detections) from going quadratic.
|
|
284
|
+
const occupied = new Uint8Array(text.length);
|
|
285
|
+
for (const [s, e] of protectedSpans)
|
|
286
|
+
occupied.fill(1, s, e);
|
|
287
|
+
const free = (d) => { for (let i = d.start; i < d.end; i++)
|
|
288
|
+
if (occupied[i])
|
|
289
|
+
return false; return true; };
|
|
290
|
+
const eligible = candidates.filter((d) => d.end > d.start && d.end <= text.length && d.score >= policy.threshold &&
|
|
291
|
+
(d.token !== undefined || policy.classify(d.type) !== "PUBLIC") &&
|
|
292
|
+
!policy.allowlisted(text.slice(d.start, d.end)));
|
|
293
|
+
// Highest confidence first, then the longest span; keep each one that overlaps neither a token nor a chosen span.
|
|
294
|
+
eligible.sort((a, b) => b.score - a.score || (b.end - b.start) - (a.end - a.start) || a.start - b.start);
|
|
295
|
+
const chosen = [];
|
|
296
|
+
for (const d of eligible)
|
|
297
|
+
if (free(d)) {
|
|
298
|
+
chosen.push(d);
|
|
299
|
+
occupied.fill(1, d.start, d.end);
|
|
300
|
+
}
|
|
301
|
+
return chosen.sort((a, b) => a.start - b.start);
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* A view over one scope, under one policy. `protect` replaces personal data with tokens before data leaves your
|
|
305
|
+
* trust boundary; `restore` puts it back, for exactly the types the principal is granted.
|
|
306
|
+
*/
|
|
307
|
+
export class PrivacySession {
|
|
308
|
+
engine;
|
|
309
|
+
state;
|
|
310
|
+
policy;
|
|
311
|
+
memo = new Map();
|
|
312
|
+
/** @internal — use `Privacy.session()` / `Privacy.open()`. */
|
|
313
|
+
constructor(engine, state, policy) {
|
|
314
|
+
this.engine = engine;
|
|
315
|
+
this.state = state;
|
|
316
|
+
this.policy = policy;
|
|
317
|
+
}
|
|
318
|
+
get scope() {
|
|
319
|
+
return this.state.scope;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Replaces personal data in `value` (a string, or any JSON-shaped value) with vault tokens. Field classifications
|
|
323
|
+
* come from `options.schema`, the policy's `fields`, and key names; free text runs through every detector,
|
|
324
|
+
* including asynchronous ones. Idempotent: protecting already-protected data changes nothing.
|
|
325
|
+
*/
|
|
326
|
+
async protect(value, options = {}) {
|
|
327
|
+
const asyncDetectors = this.policy.detectors;
|
|
328
|
+
let extras;
|
|
329
|
+
if (asyncDetectors.length) {
|
|
330
|
+
const strings = new Set();
|
|
331
|
+
collectStrings(value, strings);
|
|
332
|
+
extras = new Map();
|
|
333
|
+
await Promise.all([...strings].map(async (text) => {
|
|
334
|
+
const found = [];
|
|
335
|
+
for (const detector of asyncDetectors) {
|
|
336
|
+
const result = detector.detect(text);
|
|
337
|
+
if (!Array.isArray(result))
|
|
338
|
+
found.push(...(await result));
|
|
339
|
+
}
|
|
340
|
+
if (found.length)
|
|
341
|
+
extras.set(text, found);
|
|
342
|
+
}));
|
|
343
|
+
}
|
|
344
|
+
return this.walk(value, [], asNode(options.schema), asNode(options.schema), undefined, undefined, extras);
|
|
345
|
+
}
|
|
346
|
+
/** `protect()` without asynchronous detectors — for hot paths such as log lines. Values they found earlier in this scope are still caught. */
|
|
347
|
+
protectSync(value, options = {}) {
|
|
348
|
+
return this.walk(value, [], asNode(options.schema), asNode(options.schema), undefined, undefined, undefined);
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Replaces tokens in `value` with the original values the principal may see. Unknown tokens (another scope's, or
|
|
352
|
+
* made up by a model) are left exactly as they are. BLOCKed values were never stored and cannot come back.
|
|
353
|
+
*/
|
|
354
|
+
restore(value, options = {}) {
|
|
355
|
+
this.state.lastUsed = Date.now();
|
|
356
|
+
const seen = new Set();
|
|
357
|
+
const result = this.restoreValue(value, options, seen);
|
|
358
|
+
for (const token of seen) {
|
|
359
|
+
const entry = this.state.byToken.get(token);
|
|
360
|
+
const allowed = options.trusted === true || isGranted(options.allow, entry.type, entry.classification);
|
|
361
|
+
if (allowed)
|
|
362
|
+
this.state.restored += 1;
|
|
363
|
+
else
|
|
364
|
+
this.state.denied += 1;
|
|
365
|
+
this.state.record({ action: allowed ? "restore" : "deny", type: entry.type, classification: entry.classification, principal: options.principal, token });
|
|
366
|
+
}
|
|
367
|
+
return result;
|
|
368
|
+
}
|
|
369
|
+
/** Whether `text` contains a token issued in this scope. */
|
|
370
|
+
hasTokens(text) {
|
|
371
|
+
for (const match of text.matchAll(TOKEN_REFERENCE))
|
|
372
|
+
if (this.state.byToken.has(match[1]))
|
|
373
|
+
return true;
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
/** Restores a streamed response chunk by chunk, holding back a possible partial token at each chunk's end. */
|
|
377
|
+
streamRestorer(options = {}) {
|
|
378
|
+
return new StreamRestorer((text) => this.restore(text, options));
|
|
379
|
+
}
|
|
380
|
+
/** Waits for vault writes to reach the store; rejects if any failed. */
|
|
381
|
+
async flush() {
|
|
382
|
+
const pending = this.state.pending.splice(0);
|
|
383
|
+
const results = await Promise.allSettled(pending);
|
|
384
|
+
const failed = results.find((r) => r.status === "rejected");
|
|
385
|
+
if (failed)
|
|
386
|
+
throw failed.reason;
|
|
387
|
+
}
|
|
388
|
+
summary() {
|
|
389
|
+
const types = new Set();
|
|
390
|
+
for (const entry of this.state.byToken.values())
|
|
391
|
+
types.add(entry.type);
|
|
392
|
+
for (const event of this.state.audit)
|
|
393
|
+
if (event.action === "block" && event.type)
|
|
394
|
+
types.add(event.type);
|
|
395
|
+
return { tokenized: this.state.byToken.size, blocked: this.state.blocked, restored: this.state.restored, denied: this.state.denied, types: [...types].sort() };
|
|
396
|
+
}
|
|
397
|
+
/** What happened in this scope — tokenizations, blocks, restorations, denials. Holds tokens and types, never plaintext. */
|
|
398
|
+
audit() {
|
|
399
|
+
return [...this.state.audit];
|
|
400
|
+
}
|
|
401
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
402
|
+
walk(value, path, root, node, inherited, parentKey, extras) {
|
|
403
|
+
this.state.lastUsed = Date.now();
|
|
404
|
+
if (typeof value === "string")
|
|
405
|
+
return inherited ? this.whole(value, inherited, "string") : this.protectText(value, extras?.get(value));
|
|
406
|
+
if (typeof value === "number") {
|
|
407
|
+
if (inherited && Number.isFinite(value))
|
|
408
|
+
return this.whole(String(value), inherited, "number");
|
|
409
|
+
if (Number.isInteger(value) && Math.abs(value) >= 1_000_000) {
|
|
410
|
+
// Long integers can be card numbers, phone numbers or IDs stored numerically: only a detection that covers
|
|
411
|
+
// the whole number counts.
|
|
412
|
+
const text = String(value);
|
|
413
|
+
const hit = detectSync(text, this.policy, [], this.state).find((d) => d.start === 0 && d.end === text.length);
|
|
414
|
+
if (hit)
|
|
415
|
+
return this.whole(text, { classification: this.classificationOf(hit), type: hit.type }, "number");
|
|
416
|
+
}
|
|
417
|
+
return value;
|
|
418
|
+
}
|
|
419
|
+
if (value === null || typeof value !== "object")
|
|
420
|
+
return value;
|
|
421
|
+
if (value instanceof Date)
|
|
422
|
+
return value;
|
|
423
|
+
if (Array.isArray(value))
|
|
424
|
+
return value.map((item, index) => this.walk(item, path, root, root && itemSchema(root, node, index), inherited, parentKey, extras));
|
|
425
|
+
const source = isPlainObject(value) ? value : (typeof value.toJSON === "function" ? value.toJSON() : { ...value });
|
|
426
|
+
if (!isPlainObject(source))
|
|
427
|
+
return this.walk(source, path, root, node, inherited, parentKey, extras);
|
|
428
|
+
const out = {};
|
|
429
|
+
for (const [key, child] of Object.entries(source)) {
|
|
430
|
+
const childPath = [...path, key];
|
|
431
|
+
const childNode = root ? propertySchema(root, node, key) : undefined;
|
|
432
|
+
const fieldClass = inherited ?? this.fieldClass(key, childPath, root, childNode, path[path.length - 1] ?? parentKey);
|
|
433
|
+
const outKey = inherited || fieldClass ? key : this.protectText(key, extras?.get(key));
|
|
434
|
+
out[outKey] = fieldClass?.classification === "PUBLIC" ? child : this.walk(child, childPath, root, childNode, fieldClass, key, extras);
|
|
435
|
+
}
|
|
436
|
+
return out;
|
|
437
|
+
}
|
|
438
|
+
fieldClass(key, path, root, node, parentKey) {
|
|
439
|
+
const inferred = () => inferFieldType(key, parentKey);
|
|
440
|
+
const rule = matchFieldRule(this.policy.fieldRules, path);
|
|
441
|
+
if (rule)
|
|
442
|
+
return { classification: rule.classification, type: rule.type ?? inferred() ?? typeFromKey(key) };
|
|
443
|
+
const annotation = root ? nodeAnnotation(root, node) : undefined;
|
|
444
|
+
if (annotation?.classification)
|
|
445
|
+
return { classification: annotation.classification, type: annotation.type ?? inferred() ?? typeFromKey(key) };
|
|
446
|
+
if (annotation?.type)
|
|
447
|
+
return { classification: this.policy.classify(annotation.type), type: annotation.type };
|
|
448
|
+
if (this.policy.inferFromFieldNames) {
|
|
449
|
+
const type = inferred();
|
|
450
|
+
if (type)
|
|
451
|
+
return { classification: this.policy.classify(type), type };
|
|
452
|
+
}
|
|
453
|
+
return undefined;
|
|
454
|
+
}
|
|
455
|
+
classificationOf(detection) {
|
|
456
|
+
return detection.token ? this.state.byToken.get(detection.token).classification : this.policy.classify(detection.type);
|
|
457
|
+
}
|
|
458
|
+
/** Tokenizes a field's entire value as one entity. */
|
|
459
|
+
whole(value, fieldClass, kind) {
|
|
460
|
+
const type = fieldClass.type ?? "FIELD";
|
|
461
|
+
if (fieldClass.classification === "PUBLIC" || value.trim() === "")
|
|
462
|
+
return kind === "number" ? Number(value) : value;
|
|
463
|
+
if (kind === "string" && isMarker(value))
|
|
464
|
+
return value;
|
|
465
|
+
if (this.policy.allowlisted(value))
|
|
466
|
+
return kind === "number" ? Number(value) : value;
|
|
467
|
+
if (fieldClass.classification === "BLOCK")
|
|
468
|
+
return this.block(type);
|
|
469
|
+
return renderToken(this.engine.tokenFor(this.state, type, value, fieldClass.classification, kind));
|
|
470
|
+
}
|
|
471
|
+
block(type) {
|
|
472
|
+
if (this.policy.onBlock === "throw")
|
|
473
|
+
throw new PrivacyError(`mask: input contains ${type}, which the policy blocks.`, "blocked");
|
|
474
|
+
this.state.blocked += 1;
|
|
475
|
+
this.state.record({ action: "block", type, classification: "BLOCK" });
|
|
476
|
+
return `[BLOCKED:${type}]`;
|
|
477
|
+
}
|
|
478
|
+
protectText(text, extra) {
|
|
479
|
+
if (text.length < 3)
|
|
480
|
+
return text;
|
|
481
|
+
if (!extra) {
|
|
482
|
+
const cached = this.memo.get(text);
|
|
483
|
+
if (cached && cached.version === this.state.knownVersion)
|
|
484
|
+
return cached.output;
|
|
485
|
+
}
|
|
486
|
+
const detections = detectSync(text, this.policy, extra ?? [], this.state);
|
|
487
|
+
let output = "";
|
|
488
|
+
let cursor = 0;
|
|
489
|
+
for (const d of detections) {
|
|
490
|
+
output += text.slice(cursor, d.start);
|
|
491
|
+
const original = text.slice(d.start, d.end);
|
|
492
|
+
if (d.token)
|
|
493
|
+
output += renderToken(d.token);
|
|
494
|
+
else {
|
|
495
|
+
const classification = this.policy.classify(d.type);
|
|
496
|
+
output += classification === "BLOCK" ? this.block(d.type) : renderToken(this.engine.tokenFor(this.state, d.type, original, classification, "string"));
|
|
497
|
+
}
|
|
498
|
+
cursor = d.end;
|
|
499
|
+
}
|
|
500
|
+
output += text.slice(cursor);
|
|
501
|
+
if (!extra) {
|
|
502
|
+
if (this.memo.size >= MAX_MEMO_ENTRIES)
|
|
503
|
+
this.memo.clear();
|
|
504
|
+
this.memo.set(text, { version: this.state.knownVersion, output });
|
|
505
|
+
}
|
|
506
|
+
return output;
|
|
507
|
+
}
|
|
508
|
+
restoreValue(value, options, seen) {
|
|
509
|
+
if (typeof value === "string")
|
|
510
|
+
return this.restoreText(value, options, seen);
|
|
511
|
+
if (value === null || typeof value !== "object" || value instanceof Date)
|
|
512
|
+
return value;
|
|
513
|
+
if (Array.isArray(value))
|
|
514
|
+
return value.map((item) => this.restoreValue(item, options, seen));
|
|
515
|
+
if (!isPlainObject(value))
|
|
516
|
+
return value;
|
|
517
|
+
const out = {};
|
|
518
|
+
for (const [key, child] of Object.entries(value))
|
|
519
|
+
out[String(this.restoreText(key, options, seen))] = this.restoreValue(child, options, seen);
|
|
520
|
+
return out;
|
|
521
|
+
}
|
|
522
|
+
restoreText(text, options, seen) {
|
|
523
|
+
if (!text.includes("_"))
|
|
524
|
+
return text;
|
|
525
|
+
const mode = options.onDenied ?? "redact";
|
|
526
|
+
let wholeNumber;
|
|
527
|
+
const restored = text.replace(TOKEN_REFERENCE, (match, token) => {
|
|
528
|
+
const entry = this.state.byToken.get(token);
|
|
529
|
+
if (!entry)
|
|
530
|
+
return match;
|
|
531
|
+
seen.add(token);
|
|
532
|
+
const allowed = options.trusted === true || isGranted(options.allow, entry.type, entry.classification);
|
|
533
|
+
if (!allowed)
|
|
534
|
+
return deniedReplacement(mode, entry.type, entry.value, entry.token);
|
|
535
|
+
if (entry.kind === "number" && match.length === text.length)
|
|
536
|
+
wholeNumber = Number(entry.value);
|
|
537
|
+
return entry.value;
|
|
538
|
+
});
|
|
539
|
+
return wholeNumber ?? restored;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
function isMarker(value) {
|
|
543
|
+
const match = value.match(TOKEN_MARKER);
|
|
544
|
+
return match?.length === 1 && match[0] === value;
|
|
545
|
+
}
|
|
546
|
+
function collectStrings(value, into, depth = 0) {
|
|
547
|
+
if (depth > 64)
|
|
548
|
+
return;
|
|
549
|
+
if (typeof value === "string") {
|
|
550
|
+
if (value.length >= 3)
|
|
551
|
+
into.add(value);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
if (!value || typeof value !== "object")
|
|
555
|
+
return;
|
|
556
|
+
if (Array.isArray(value)) {
|
|
557
|
+
for (const item of value)
|
|
558
|
+
collectStrings(item, into, depth + 1);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
for (const [key, child] of Object.entries(value)) {
|
|
562
|
+
into.add(key);
|
|
563
|
+
collectStrings(child, into, depth + 1);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
/** Longest possible token reference, `[` + type + `_` + id + `]`, with headroom for long custom type names. */
|
|
567
|
+
const MAX_TOKEN_LENGTH = 72;
|
|
568
|
+
/**
|
|
569
|
+
* Restores a stream without ever emitting half a token: text after an unclosed `[`, or a trailing run of token
|
|
570
|
+
* characters, is held back until the next chunk (or `flush()`) decides it.
|
|
571
|
+
*/
|
|
572
|
+
export class StreamRestorer {
|
|
573
|
+
restoreText;
|
|
574
|
+
buffer = "";
|
|
575
|
+
constructor(restoreText) {
|
|
576
|
+
this.restoreText = restoreText;
|
|
577
|
+
}
|
|
578
|
+
push(delta) {
|
|
579
|
+
this.buffer += delta;
|
|
580
|
+
let cut = this.buffer.length;
|
|
581
|
+
const open = this.buffer.lastIndexOf("[");
|
|
582
|
+
if (open !== -1 && !this.buffer.includes("]", open) && this.buffer.length - open <= MAX_TOKEN_LENGTH)
|
|
583
|
+
cut = open;
|
|
584
|
+
const tail = /[A-Za-z0-9_]{1,64}$/.exec(this.buffer.slice(0, cut));
|
|
585
|
+
if (tail)
|
|
586
|
+
cut = tail.index;
|
|
587
|
+
const ready = this.buffer.slice(0, cut);
|
|
588
|
+
this.buffer = this.buffer.slice(cut);
|
|
589
|
+
return ready ? this.restoreText(ready) : "";
|
|
590
|
+
}
|
|
591
|
+
flush() {
|
|
592
|
+
const rest = this.buffer;
|
|
593
|
+
this.buffer = "";
|
|
594
|
+
return rest ? this.restoreText(rest) : "";
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
/** Creates a privacy engine. See `PrivacyOptions`. */
|
|
598
|
+
export function createPrivacy(options = {}) {
|
|
599
|
+
return new Privacy(options);
|
|
600
|
+
}
|
package/dist/tokens.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How tokens look, in one place. Three styles:
|
|
3
|
+
* random (default) ss1_kqfmzxat — keyed-HMAC letters, no type shown; written bare
|
|
4
|
+
* sequential [EMAIL_1] — type plus a per-scope counter; written in brackets
|
|
5
|
+
* opaque [EMAIL_3f9a1c0b7e] — type plus keyed-HMAC hex; written in brackets
|
|
6
|
+
*/
|
|
7
|
+
export type TokenStyle = "random" | "sequential" | "opaque";
|
|
8
|
+
export declare const RANDOM_PREFIX = "ss1_";
|
|
9
|
+
export declare const RANDOM_ID_LENGTH = 8;
|
|
10
|
+
/** Anything already masked: a token in any style, or a BLOCKED/REDACTED marker. Detection never touches these spans. */
|
|
11
|
+
export declare const TOKEN_MARKER: RegExp;
|
|
12
|
+
/**
|
|
13
|
+
* A token reference to restore. Brackets are optional on both forms, since models add or drop them; capture group 1 is
|
|
14
|
+
* the bare token. Only tokens present in the scope's vault are ever resolved, so look-alikes stay untouched.
|
|
15
|
+
*/
|
|
16
|
+
export declare const TOKEN_REFERENCE: RegExp;
|
|
17
|
+
/** How a token is written into text: random tokens bare, typed tokens in brackets. */
|
|
18
|
+
export declare function renderToken(token: string): string;
|
|
19
|
+
/** `ss1_` + 8 letters derived from a hex fingerprint; `attempt` shifts the window to resolve the (rare) collision. */
|
|
20
|
+
export declare function randomToken(fingerprintHex: string, attempt: number): string;
|
package/dist/tokens.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export const RANDOM_PREFIX = "ss1_";
|
|
2
|
+
export const RANDOM_ID_LENGTH = 8;
|
|
3
|
+
const RANDOM_ALPHABET = "abcdefghijklmnopqrstuvwxyz";
|
|
4
|
+
const TYPED = String.raw `[A-Z][A-Z0-9_]*_(?:[0-9a-f]{10}|[1-9]\d{0,5})`;
|
|
5
|
+
const RANDOM = String.raw `ss1_[a-z]{8}`;
|
|
6
|
+
/** Anything already masked: a token in any style, or a BLOCKED/REDACTED marker. Detection never touches these spans. */
|
|
7
|
+
export const TOKEN_MARKER = new RegExp(String.raw `\[(?:${TYPED}|BLOCKED:[A-Z][A-Z0-9_]*|REDACTED:[A-Z][A-Z0-9_]*)\]|(?<![A-Za-z0-9_])\[?${RANDOM}\]?(?![A-Za-z0-9_])`, "g");
|
|
8
|
+
/**
|
|
9
|
+
* A token reference to restore. Brackets are optional on both forms, since models add or drop them; capture group 1 is
|
|
10
|
+
* the bare token. Only tokens present in the scope's vault are ever resolved, so look-alikes stay untouched.
|
|
11
|
+
*/
|
|
12
|
+
export const TOKEN_REFERENCE = new RegExp(String.raw `(?<![A-Za-z0-9_])\[?(${TYPED}|${RANDOM})\]?(?![A-Za-z0-9_])`, "g");
|
|
13
|
+
/** How a token is written into text: random tokens bare, typed tokens in brackets. */
|
|
14
|
+
export function renderToken(token) {
|
|
15
|
+
return token.startsWith(RANDOM_PREFIX) ? token : `[${token}]`;
|
|
16
|
+
}
|
|
17
|
+
/** `ss1_` + 8 letters derived from a hex fingerprint; `attempt` shifts the window to resolve the (rare) collision. */
|
|
18
|
+
export function randomToken(fingerprintHex, attempt) {
|
|
19
|
+
let id = "";
|
|
20
|
+
for (let i = 0; i < RANDOM_ID_LENGTH; i++) {
|
|
21
|
+
const at = (attempt * 2 + i * 2) % (fingerprintHex.length - 1);
|
|
22
|
+
id += RANDOM_ALPHABET[parseInt(fingerprintHex.slice(at, at + 2), 16) % RANDOM_ALPHABET.length];
|
|
23
|
+
}
|
|
24
|
+
return RANDOM_PREFIX + id;
|
|
25
|
+
}
|