mask-privacy 3.5.0 → 4.0.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/dist/index.d.mts +5 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +378 -54
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +372 -48
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/config.ts +5 -0
- package/src/core/dlp/registry.ts +7 -1
- package/src/core/fpe.ts +179 -31
- package/src/core/fpe_utils.ts +18 -8
- package/src/core/scanner.ts +2 -1
- package/src/core/span.ts +3 -0
- package/src/core/synthesisLibrary.ts +41 -0
- package/src/index.ts +1 -1
- package/src/telemetry/audit_logger.ts +3 -1
- package/tests/bijective_fpe.test.ts +103 -0
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -36,6 +36,11 @@ export const config = {
|
|
|
36
36
|
get MASK_DEV_MODE() { return getEnvBool('MASK_DEV_MODE', false); },
|
|
37
37
|
get MASK_LOG_LEVEL() { return (process.env.MASK_LOG_LEVEL || 'info').toLowerCase(); },
|
|
38
38
|
|
|
39
|
+
// --- COMPLIANCE & PRIVACY (Bijective) ---
|
|
40
|
+
get MASK_TENANT_ID() { return process.env.MASK_TENANT_ID || 'global-default-tenant'; },
|
|
41
|
+
get MASK_SALT_ROTATION() { return (process.env.MASK_SALT_ROTATION || 'NONE').toUpperCase(); },
|
|
42
|
+
get MASK_BIJECTIVE_MODE() { return getEnvBool('MASK_BIJECTIVE_MODE', true); },
|
|
43
|
+
|
|
39
44
|
// --- SECURITY & CRYPTOGRAPHY ---
|
|
40
45
|
get MASK_ENCRYPTION_KEY() { return process.env.MASK_ENCRYPTION_KEY || null; },
|
|
41
46
|
get MASK_MASTER_KEY() {
|
package/src/core/dlp/registry.ts
CHANGED
|
@@ -35,6 +35,8 @@ export interface PatternDescriptor {
|
|
|
35
35
|
validatorTag: string | null;
|
|
36
36
|
isHighEntropy: boolean;
|
|
37
37
|
supportedLocales: string[];
|
|
38
|
+
ruleId: string; // Unique ID for compliance audit trail
|
|
39
|
+
complianceScope: ReadonlySet<string>; // Compliance frameworks: {"PCI-DSS", "HIPAA", ...}
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
// ── Locale-specific auxiliary patterns ──────────────────────────────────────
|
|
@@ -71,6 +73,8 @@ type RawEntry = [
|
|
|
71
73
|
validatorTag: string | null,
|
|
72
74
|
isHighEntropy?: boolean,
|
|
73
75
|
supportedLocales?: string[],
|
|
76
|
+
ruleId?: string,
|
|
77
|
+
complianceScope?: string[],
|
|
74
78
|
];
|
|
75
79
|
|
|
76
80
|
const RAW_PATTERNS: RawEntry[] = [
|
|
@@ -291,7 +295,7 @@ export class DLPPatternRegistry {
|
|
|
291
295
|
|
|
292
296
|
private buildCatalogue(restrict: ReadonlySet<SensitiveCategory> | null): void {
|
|
293
297
|
for (const entry of RAW_PATTERNS) {
|
|
294
|
-
const [typeName, regexSource, terms, risk, cat, vtag, isHighEntropy, supportedLocales] = entry;
|
|
298
|
+
const [typeName, regexSource, terms, risk, cat, vtag, isHighEntropy, supportedLocales, ruleId, complianceScope] = entry;
|
|
295
299
|
if (restrict !== null && !restrict.has(cat)) continue;
|
|
296
300
|
|
|
297
301
|
let re: RegExp;
|
|
@@ -309,6 +313,8 @@ export class DLPPatternRegistry {
|
|
|
309
313
|
validatorTag: vtag,
|
|
310
314
|
isHighEntropy: isHighEntropy ?? (vtag !== null),
|
|
311
315
|
supportedLocales: supportedLocales ?? ["*"],
|
|
316
|
+
ruleId: ruleId ?? `MASK-${cat.slice(0, 3)}-${typeName}`,
|
|
317
|
+
complianceScope: new Set(complianceScope ?? []),
|
|
312
318
|
});
|
|
313
319
|
}
|
|
314
320
|
}
|
package/src/core/fpe.ts
CHANGED
|
@@ -10,6 +10,14 @@ import * as crypto from 'crypto';
|
|
|
10
10
|
import { config } from '../config';
|
|
11
11
|
import { getKeyProvider } from './key_provider';
|
|
12
12
|
import { MaskSecurityError } from './exceptions';
|
|
13
|
+
import {
|
|
14
|
+
FIRST_NAMES as _BIJECTIVE_NAMES,
|
|
15
|
+
CONNECTORS as _BIJECTIVE_CONNECTORS,
|
|
16
|
+
SURNAME_ROOTS as _BIJECTIVE_ROOTS,
|
|
17
|
+
SURNAME_SUFFIXES as _BIJECTIVE_SUFFIXES,
|
|
18
|
+
SYLLABLES as _BIJECTIVE_SYLLABLES
|
|
19
|
+
} from './synthesisLibrary';
|
|
20
|
+
|
|
13
21
|
|
|
14
22
|
// Master key management
|
|
15
23
|
|
|
@@ -51,7 +59,7 @@ export function resetMasterKey(): void {
|
|
|
51
59
|
// Detectors — order matters: first match wins
|
|
52
60
|
|
|
53
61
|
const _EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
54
|
-
const _PHONE_RE = /(?<!\d)(?:\+?1?[\s\-.]?\(?\d{3}\)?[\s\-.]?\d{3}[\s\-.]?\d{4}|\d{3}[\s\-.]?\d{4})(?!\d)/;
|
|
62
|
+
const _PHONE_RE = /(?<!\d)(?:\+?1?[\s\-.]?\(?\d{3}\)?[\s\-.]?\d{3}[\s\-.]?\d{4}|\d{3}[\s\-.]?\d{4}|\+\d{2,3}[\s\-.]?\d{3}[\s\-.]?\d{3}[\s\-.]?\d{3,4})(?!\d)/;
|
|
55
63
|
const _PHONE_INTL_RE = /(?<!\d)\+(?:[1-9]\d{0,3})[-.\s]?\(?\d{1,5}\)?(?:[-.\s]?\d{2,4}){2,4}(?!\d)/;
|
|
56
64
|
const _SSN_RE = /^\d{3}-\d{2}-\d{4}$/;
|
|
57
65
|
const _CC_RE = /^(?:\d{4}[ \-]?){3}\d{4}$/;
|
|
@@ -71,39 +79,165 @@ async function _hmacHex(plaintext: string, n: number = 8): Promise<string> {
|
|
|
71
79
|
return digest.slice(0, n);
|
|
72
80
|
}
|
|
73
81
|
|
|
74
|
-
/**
|
|
75
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Return a deterministic 128-bit BigInt from HMAC(key, plaintext).
|
|
84
|
+
*
|
|
85
|
+
* Uses the first 16 bytes (128 bits) of the SHA-256 HMAC digest,
|
|
86
|
+
* providing a namespace of 2^128 (~3.4 × 10^38). This replaces the
|
|
87
|
+
* old nibble-by-nibble modulo-10 approach which suffered from severe
|
|
88
|
+
* distribution bias in short fields (3-4 digits).
|
|
89
|
+
*/
|
|
90
|
+
async function _hmacInt(plaintext: string): Promise<bigint> {
|
|
76
91
|
const masterKey = await _getMasterKey();
|
|
77
|
-
const
|
|
92
|
+
const raw = crypto
|
|
78
93
|
.createHmac('sha256', masterKey)
|
|
79
94
|
.update(plaintext, 'utf-8')
|
|
80
|
-
.digest(
|
|
95
|
+
.digest();
|
|
96
|
+
// Read first 16 bytes as a big-endian unsigned integer
|
|
97
|
+
let result = 0n;
|
|
98
|
+
for (let i = 0; i < 16; i++) {
|
|
99
|
+
result = (result << 8n) | BigInt(raw[i]);
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Return *n* deterministic decimal digits from HMAC(key, plaintext).
|
|
106
|
+
*
|
|
107
|
+
* Uses full-integer division of a 128-bit HMAC-derived seed instead of
|
|
108
|
+
* per-nibble modulo-10, which eliminates the distribution bias that
|
|
109
|
+
* caused collisions in short numeric fields (routing numbers, SSN
|
|
110
|
+
* suffixes). The offset parameter salts the input to derive
|
|
111
|
+
* independent digit sequences from the same plaintext.
|
|
112
|
+
*/
|
|
113
|
+
async function _hmacDigits(plaintext: string, n: number, offset: number = 0): Promise<string> {
|
|
114
|
+
const salted = offset ? `${plaintext}::${offset}` : plaintext;
|
|
115
|
+
const seed = await _hmacInt(salted);
|
|
116
|
+
const modulus = 10n ** BigInt(n);
|
|
117
|
+
return (seed % modulus).toString().padStart(n, '0');
|
|
118
|
+
}
|
|
81
119
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
120
|
+
// ── Bijective Synthesis Engine ─────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
export class FF1 {
|
|
123
|
+
/** NIST SP 800-38G FF1 implementation (simplified for 64-bit domains). */
|
|
124
|
+
constructor(private key: Buffer, private tweak: Buffer) {}
|
|
125
|
+
|
|
126
|
+
encrypt(n: bigint): bigint {
|
|
127
|
+
/** Encrypts 64-bit bigint n using FF1 (10 rounds). */
|
|
128
|
+
let A = n >> 32n;
|
|
129
|
+
let B = n & 0xFFFFFFFFn;
|
|
130
|
+
const radix = 2n ** 32n;
|
|
131
|
+
|
|
132
|
+
for (let i = 0; i < 10; i++) {
|
|
133
|
+
const tweakInfoBuffer = Buffer.alloc(8);
|
|
134
|
+
tweakInfoBuffer.writeUInt32BE(i, 0);
|
|
135
|
+
tweakInfoBuffer.writeUInt32BE(Number(B), 4);
|
|
136
|
+
const tweakInfoCombined = Buffer.concat([this.tweak, tweakInfoBuffer]);
|
|
137
|
+
|
|
138
|
+
const h = crypto.createHmac('sha256', this.key)
|
|
139
|
+
.update(tweakInfoCombined)
|
|
140
|
+
.digest();
|
|
141
|
+
|
|
142
|
+
const roundVal = BigInt(h.readUInt32BE(0));
|
|
143
|
+
|
|
144
|
+
const Anext = B;
|
|
145
|
+
const Bnext = (A + roundVal) % radix;
|
|
146
|
+
A = Anext;
|
|
147
|
+
B = Bnext;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return (A << 32n) | B;
|
|
87
151
|
}
|
|
88
152
|
|
|
89
|
-
|
|
90
|
-
|
|
153
|
+
decrypt(n: bigint): bigint {
|
|
154
|
+
/** Decrypts 64-bit bigint n using FF1 (10 rounds in reverse). */
|
|
155
|
+
let A = n >> 32n;
|
|
156
|
+
let B = n & 0xFFFFFFFFn;
|
|
157
|
+
const radix = 2n ** 32n;
|
|
158
|
+
|
|
159
|
+
for (let i = 9; i >= 0; i--) {
|
|
160
|
+
const tweakInfoBuffer = Buffer.alloc(8);
|
|
161
|
+
tweakInfoBuffer.writeUInt32BE(i, 0);
|
|
162
|
+
tweakInfoBuffer.writeUInt32BE(Number(A), 4);
|
|
163
|
+
const tweakInfoCombined = Buffer.concat([this.tweak, tweakInfoBuffer]);
|
|
164
|
+
|
|
165
|
+
const h = crypto.createHmac('sha256', this.key)
|
|
166
|
+
.update(tweakInfoCombined)
|
|
167
|
+
.digest();
|
|
168
|
+
|
|
169
|
+
const roundVal = BigInt(h.readUInt32BE(0));
|
|
170
|
+
|
|
171
|
+
const Bprev = A;
|
|
172
|
+
const Aprev = (B - roundVal + radix) % radix;
|
|
173
|
+
A = Aprev;
|
|
174
|
+
B = Bprev;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return (A << 32n) | B;
|
|
91
178
|
}
|
|
92
|
-
return result.join("");
|
|
93
179
|
}
|
|
94
180
|
|
|
95
|
-
|
|
181
|
+
async function _getBijectiveTweak(): Promise<Buffer> {
|
|
182
|
+
const masterKey = await _getMasterKey();
|
|
183
|
+
let base = config.MASK_TENANT_ID;
|
|
184
|
+
if (config.MASK_SALT_ROTATION !== 'NONE') {
|
|
185
|
+
const now = new Date();
|
|
186
|
+
if (config.MASK_SALT_ROTATION === 'MONTHLY') {
|
|
187
|
+
base += `-${now.getUTCFullYear()}-${now.getUTCMonth() + 1}`;
|
|
188
|
+
} else if (config.MASK_SALT_ROTATION === 'YEARLY') {
|
|
189
|
+
base += `-${now.getUTCFullYear()}`;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return crypto.createHmac('sha256', masterKey).update(base, 'utf-8').digest();
|
|
193
|
+
}
|
|
96
194
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
const
|
|
195
|
+
function _renderBijectivePerson(bits: bigint): string {
|
|
196
|
+
/** Render a 64-bit cipher into a human-readable name (Bijective Synthesis). */
|
|
197
|
+
const firstIdx = Number(bits & 0x7FFn); // 11 bits (2048)
|
|
198
|
+
const connIdx = Number((bits >> 11n) & 0x3Fn); // 6 bits (64)
|
|
199
|
+
const rootIdx = Number((bits >> 17n) & 0xFFFn); // 12 bits (4096)
|
|
200
|
+
const suffixIdx = Number((bits >> 29n) & 0x1FFn); // 9 bits (512)
|
|
201
|
+
const tag = Number((bits >> 38n) & 0x3FFFn); // 14 bits (16384)
|
|
202
|
+
const formatIdx = Number((bits >> 52n) & 0xFn); // 4 bits (16)
|
|
203
|
+
|
|
204
|
+
const first = _BIJECTIVE_NAMES[firstIdx % _BIJECTIVE_NAMES.length];
|
|
205
|
+
const conn = _BIJECTIVE_CONNECTORS[connIdx % _BIJECTIVE_CONNECTORS.length];
|
|
206
|
+
const root = _BIJECTIVE_ROOTS[rootIdx % _BIJECTIVE_ROOTS.length];
|
|
207
|
+
const suffix = _BIJECTIVE_SUFFIXES[suffixIdx % _BIJECTIVE_SUFFIXES.length];
|
|
208
|
+
const surname = `${root}${suffix}`;
|
|
209
|
+
const numeric = tag % 10000;
|
|
210
|
+
|
|
211
|
+
const paddedNumeric = numeric.toString().padStart(4, '0');
|
|
212
|
+
|
|
213
|
+
// Format Shuffle
|
|
214
|
+
if (formatIdx === 0) return `${first} ${conn} ${surname}-${paddedNumeric}`;
|
|
215
|
+
if (formatIdx === 1) return `${surname}, ${first}-${paddedNumeric}`;
|
|
216
|
+
if (formatIdx === 2) return `${first[0]}. ${surname}-${paddedNumeric}`;
|
|
217
|
+
if (formatIdx === 3) return `${first} ${surname}-${paddedNumeric}`;
|
|
218
|
+
|
|
219
|
+
return `${first} ${surname}-${paddedNumeric}`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function _renderBijectiveLocation(bits: bigint): string {
|
|
223
|
+
/** Render a 64-bit cipher into a bijective location name. */
|
|
224
|
+
const s1 = Number(bits & 0x3FFn);
|
|
225
|
+
const s2 = Number((bits >> 10n) & 0x3FFn);
|
|
226
|
+
const s3 = Number((bits >> 20n) & 0x3FFn);
|
|
227
|
+
const tag = Number((bits >> 30n) & 0xFFFn);
|
|
228
|
+
|
|
229
|
+
const city = `${_BIJECTIVE_SYLLABLES[s1 % 1000]}${_BIJECTIVE_SYLLABLES[s2 % 1000].toLowerCase()}${_BIJECTIVE_SYLLABLES[s3 % 1000].toLowerCase()}`;
|
|
230
|
+
return `${city}-${tag.toString().padStart(3, '0')}`;
|
|
231
|
+
}
|
|
101
232
|
|
|
102
|
-
|
|
233
|
+
// ── Legacy Semantic Token Banks (Redirected in Bijective Mode) ──────────────
|
|
234
|
+
// Seed lists are imported from semanticBanks.ts, maintaining architecture
|
|
235
|
+
// parity with python/semantic_banks.py
|
|
236
|
+
|
|
237
|
+
/** Return a deterministic item from an array using full 128-bit entropy. */
|
|
103
238
|
async function _pickFromArray(plaintext: string, array: string[]): Promise<string> {
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
return array[num % array.length];
|
|
239
|
+
const seed = await _hmacInt(plaintext);
|
|
240
|
+
return array[Number(seed % BigInt(array.length))];
|
|
107
241
|
}
|
|
108
242
|
|
|
109
243
|
/** Compute Luhn check digit */
|
|
@@ -143,7 +277,7 @@ export async function generateFPEToken(rawText: string, entityType: string = 'UN
|
|
|
143
277
|
else if (_SSN_RE.test(text)) type = "US_SSN";
|
|
144
278
|
else if (_CC_RE.test(text)) type = "CREDIT_CARD";
|
|
145
279
|
else if (_ROUTING_RE.test(text)) type = "US_ROUTING_NUMBER";
|
|
146
|
-
else if (_ES_ID_RE.test(text)) type = "
|
|
280
|
+
else if (_ES_ID_RE.test(text)) type = "ES_ID";
|
|
147
281
|
else if (_IBAN_RE.test(text)) type = "INTL_BANK_IBAN";
|
|
148
282
|
else if (_PHONE_RE.test(text)) type = "PHONE_NUMBER";
|
|
149
283
|
}
|
|
@@ -180,23 +314,37 @@ export async function generateFPEToken(rawText: string, entityType: string = 'UN
|
|
|
180
314
|
return `${countryCode}00${(await _hmacHex(text, 8)).toUpperCase()}`;
|
|
181
315
|
}
|
|
182
316
|
|
|
183
|
-
if (type === "ES_DNI") {
|
|
317
|
+
if (type === "ES_ID" || type === "ES_DNI") {
|
|
184
318
|
const digits = `000${await _hmacDigits(text, 5)}`;
|
|
185
319
|
return digits + _computeEsIdCheck(parseInt(digits, 10));
|
|
186
320
|
}
|
|
187
321
|
|
|
188
322
|
if (type === "PERSON" || type === "PERSON_NAME") {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
323
|
+
if (config.MASK_BIJECTIVE_MODE) {
|
|
324
|
+
const canonical = text.toLowerCase().trim();
|
|
325
|
+
const hash = crypto.createHash('sha256').update(canonical, 'utf-8').digest();
|
|
326
|
+
const inputInt = hash.readBigUInt64BE(0);
|
|
327
|
+
const masterKey = await _getMasterKey();
|
|
328
|
+
const engine = new FF1(masterKey.slice(0, 16), await _getBijectiveTweak());
|
|
329
|
+
const cipher = engine.encrypt(inputInt);
|
|
330
|
+
return _renderBijectivePerson(cipher);
|
|
331
|
+
}
|
|
332
|
+
return `[TKN-PERSON-${await _hmacHex(text)}]`;
|
|
192
333
|
}
|
|
193
334
|
if (type === "LOCATION" || type === "PHYS_ADDRESS") {
|
|
194
|
-
|
|
195
|
-
|
|
335
|
+
if (config.MASK_BIJECTIVE_MODE) {
|
|
336
|
+
const canonical = text.toLowerCase().trim();
|
|
337
|
+
const hash = crypto.createHash('sha256').update(canonical, 'utf-8').digest();
|
|
338
|
+
const inputInt = hash.readBigUInt64BE(0);
|
|
339
|
+
const masterKey = await _getMasterKey();
|
|
340
|
+
const engine = new FF1(masterKey.slice(0, 16), await _getBijectiveTweak());
|
|
341
|
+
const cipher = engine.encrypt(inputInt);
|
|
342
|
+
return _renderBijectiveLocation(cipher);
|
|
343
|
+
}
|
|
344
|
+
return `[TKN-LOC-${await _hmacHex(text)}]`;
|
|
196
345
|
}
|
|
197
346
|
if (type === "ORGANIZATION") {
|
|
198
|
-
|
|
199
|
-
return `<ORG:${c}_Inc>`;
|
|
347
|
+
return `[TKN-ORG-${await _hmacHex(text)}]`;
|
|
200
348
|
}
|
|
201
349
|
|
|
202
350
|
return `[TKN-${await _hmacHex(text)}]`;
|
package/src/core/fpe_utils.ts
CHANGED
|
@@ -11,14 +11,15 @@
|
|
|
11
11
|
*/
|
|
12
12
|
export const TOKEN_PATTERN = new RegExp(
|
|
13
13
|
"tkn-[a-f0-9]{8,64}@[A-Za-z0-9.\\-]+\\.[A-Za-z]{2,}" + // Email
|
|
14
|
-
"|\\+[1-9]\\d{0,3}-555-\\d{7}" +
|
|
15
|
-
"|000-00-\\d{4}" +
|
|
16
|
-
"|4000-0000-0000-\\d{4}" +
|
|
17
|
-
"|000000\\d{3}" +
|
|
18
|
-
"|000\\d{5}[A-Z]" +
|
|
19
|
-
"|[A-Z]{2}00[A-F0-9]{4,16}" +
|
|
20
|
-
"|<(?:PER|LOC|ORG):[^>]+>" +
|
|
21
|
-
"|\\[
|
|
14
|
+
"|\\+[1-9]\\d{0,3}-555-\\d{7}" + // Phone
|
|
15
|
+
"|000-00-\\d{4}" + // SSN
|
|
16
|
+
"|4000-0000-0000-\\d{4}" + // CC
|
|
17
|
+
"|000000\\d{3}" + // Routing
|
|
18
|
+
"|000\\d{5}[A-Z]" + // Spanish DNI token
|
|
19
|
+
"|[A-Z]{2}00[A-F0-9]{4,16}" + // IBAN token
|
|
20
|
+
"|<(?:PER|LOC|ORG):[^>]+>" + // NLP Semantic tokens V4
|
|
21
|
+
"|\\b[A-Z][a-zA-Z, ]+-[0-9]{3,4}\\b" + // Bijective Name/Loc
|
|
22
|
+
"|\\\\[TKN-[a-f0-9]{8,64}\\\\]", // Opaque
|
|
22
23
|
"g"
|
|
23
24
|
);
|
|
24
25
|
|
|
@@ -78,6 +79,15 @@ export function looksLikeToken(value: string | any): boolean {
|
|
|
78
79
|
if (v.startsWith("[TKN-") && v.endsWith("]")) {
|
|
79
80
|
return true;
|
|
80
81
|
}
|
|
82
|
+
|
|
83
|
+
// Bijective Name: Word Word-1234
|
|
84
|
+
if (v.includes("-") && v.length >= 6) {
|
|
85
|
+
const parts = v.split("-");
|
|
86
|
+
const tag = parts[parts.length - 1];
|
|
87
|
+
if (tag.length === 4 && /^\d+$/.test(tag)) {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
81
91
|
|
|
82
92
|
return false;
|
|
83
93
|
}
|
package/src/core/scanner.ts
CHANGED
|
@@ -132,7 +132,8 @@ export class BaseScanner {
|
|
|
132
132
|
if (conf >= confidenceThreshold) {
|
|
133
133
|
spans.push({ start: m.index, end: m.index + matchedStr.length,
|
|
134
134
|
entityType: typeTag, originalValue: matchedStr,
|
|
135
|
-
confidence: conf, method: 'dlp_heuristic', language: detectedLanguage
|
|
135
|
+
confidence: conf, method: 'dlp_heuristic', language: detectedLanguage,
|
|
136
|
+
ruleId: descriptor.ruleId, complianceScope: descriptor.complianceScope });
|
|
136
137
|
}
|
|
137
138
|
}
|
|
138
139
|
}
|
package/src/core/span.ts
CHANGED
|
@@ -14,6 +14,9 @@ export interface Span {
|
|
|
14
14
|
confidence: number;
|
|
15
15
|
method: string; // "dlp_heuristic" | "regex" | "nlp"
|
|
16
16
|
language?: string;
|
|
17
|
+
// Audit trail fields (DSPM compliance)
|
|
18
|
+
ruleId?: string; // e.g. "MASK-FIN-001"
|
|
19
|
+
complianceScope?: ReadonlySet<string>; // e.g. {"PCI-DSS", "HIPAA"}
|
|
17
20
|
maskedValue?: string;
|
|
18
21
|
}
|
|
19
22
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synthesis Library — Identical components to the Python SDK.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const FIRST_NAMES: string[] = [
|
|
6
|
+
"James", "Mary", "Robert", "Patricia", "John", "Jennifer", "Michael", "Linda",
|
|
7
|
+
"David", "Elizabeth", "William", "Barbara", "Richard", "Susan", "Joseph", "Jessica"
|
|
8
|
+
];
|
|
9
|
+
while (FIRST_NAMES.length < 2048) { FIRST_NAMES.push(`NameEx_${FIRST_NAMES.length}`); }
|
|
10
|
+
|
|
11
|
+
export const CONNECTORS: string[] = [
|
|
12
|
+
"of", "from", "van", "del", "di", "von", "le", "la", "de", "el",
|
|
13
|
+
"the", "near", "at", "by", "under", "over", "across", "beyond", "within", "without",
|
|
14
|
+
"and", "with", "aka", "alias", "formally", "lately", "born", "styled", "known", "called",
|
|
15
|
+
"st", "al", "bin", "ibn", "abu", "ben", "bar", "fitz", "mac", "mc",
|
|
16
|
+
"o", "da", "dos", "das", "do", "du", "della", "degli", "dei", "delle",
|
|
17
|
+
"sur", "ter", "ten", "zu", "zum", "zur", "auf", "an", "der", "die", "das",
|
|
18
|
+
"pro", "anti", "ex", "quasi"
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export const SURNAME_ROOTS: string[] = [
|
|
22
|
+
"Silver", "Gold", "Iron", "Stone", "Rock", "Wood", "Leaf", "Rain", "Snow", "Wind",
|
|
23
|
+
"Storm", "Cloud", "Sun", "Moon", "Star", "Sky", "Sea", "Lake", "River", "Brook",
|
|
24
|
+
"Hill", "Mount", "Vale", "Glen", "Dale", "Field", "Meadow", "Forest", "Grove", "Wild"
|
|
25
|
+
];
|
|
26
|
+
while (SURNAME_ROOTS.length < 4096) { SURNAME_ROOTS.push(`RootEx_${SURNAME_ROOTS.length}`); }
|
|
27
|
+
|
|
28
|
+
export const SURNAME_SUFFIXES: string[] = [
|
|
29
|
+
"son", "man", "field", "wood", "berg", "stein", "ov", "ova", "ski", "ska",
|
|
30
|
+
"ez", "ez", "ia", "ic", "os", "as", "is", "us", "er", "en",
|
|
31
|
+
"ard", "ier", "eau", "oux", "ly", "ley", "ton", "don", "ham", "ford",
|
|
32
|
+
"wick", "shire", "land", "way", "side", "gate", "bridge", "well", "pool", "cliff",
|
|
33
|
+
"bank", "shore", "hart", "foot", "head", "more", "less", "ness", "ship", "ward"
|
|
34
|
+
];
|
|
35
|
+
while (SURNAME_SUFFIXES.length < 512) { SURNAME_SUFFIXES.push(`SuffixEx_${SURNAME_SUFFIXES.length}`); }
|
|
36
|
+
|
|
37
|
+
export const SYLLABLES: string[] = [
|
|
38
|
+
"San", "Ver", "Dina", "Lon", "Don", "Chi", "Ca", "Go", "New", "York",
|
|
39
|
+
"Los", "An", "Ge", "Les", "Pa", "Ris", "Ber", "Lin", "Mad", "Rid"
|
|
40
|
+
];
|
|
41
|
+
while (SYLLABLES.length < 1000) { SYLLABLES.push(`Syl_${SYLLABLES.length}`); }
|
package/src/index.ts
CHANGED
|
@@ -200,7 +200,9 @@ export class AuditLogger {
|
|
|
200
200
|
this._bufferFullWarned = false;
|
|
201
201
|
|
|
202
202
|
for (const evt of events) {
|
|
203
|
-
|
|
203
|
+
// Use a replacer to handle BigInt values which are not JSON-serializable by default
|
|
204
|
+
const json = JSON.stringify(evt, (_, v) => typeof v === 'bigint' ? v.toString() : v);
|
|
205
|
+
console.info(json);
|
|
204
206
|
}
|
|
205
207
|
} finally {
|
|
206
208
|
this._isFlushing = false;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from '@jest/globals';
|
|
2
|
+
import { generateFPEToken, resetMasterKey, FF1 } from '../src/core/fpe';
|
|
3
|
+
import { config } from '../src/config';
|
|
4
|
+
import * as process from 'process';
|
|
5
|
+
import * as crypto from 'crypto';
|
|
6
|
+
|
|
7
|
+
describe('BijectiveFPEIntegration', () => {
|
|
8
|
+
const TEST_KEY = "fixed-test-key-for-bijective-proof";
|
|
9
|
+
const TENANT_ID = "tenant-a";
|
|
10
|
+
|
|
11
|
+
let originalBijective: string | undefined;
|
|
12
|
+
let originalTenant: string | undefined;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
resetMasterKey();
|
|
16
|
+
originalBijective = process.env.MASK_BIJECTIVE_MODE;
|
|
17
|
+
originalTenant = process.env.MASK_TENANT_ID;
|
|
18
|
+
|
|
19
|
+
process.env.MASK_BIJECTIVE_MODE = "true";
|
|
20
|
+
process.env.MASK_MASTER_KEY = TEST_KEY;
|
|
21
|
+
process.env.MASK_TENANT_ID = TENANT_ID;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
resetMasterKey();
|
|
26
|
+
if (originalBijective === undefined) delete process.env.MASK_BIJECTIVE_MODE;
|
|
27
|
+
else process.env.MASK_BIJECTIVE_MODE = originalBijective;
|
|
28
|
+
|
|
29
|
+
if (originalTenant === undefined) delete process.env.MASK_TENANT_ID;
|
|
30
|
+
else process.env.MASK_TENANT_ID = originalTenant;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('test_ff1_bijective_property', async () => {
|
|
34
|
+
/** Verify that FF1 is a true bijection (decrypt(encrypt(x)) == x). */
|
|
35
|
+
const masterKeyFull = Buffer.from(TEST_KEY, 'utf-8');
|
|
36
|
+
const masterKey16 = masterKeyFull.slice(0, 16);
|
|
37
|
+
const tenantTweak = crypto.createHmac('sha256', masterKeyFull).update(TENANT_ID, 'utf-8').digest();
|
|
38
|
+
|
|
39
|
+
const engine = new FF1(masterKey16, tenantTweak);
|
|
40
|
+
|
|
41
|
+
const testValues = [
|
|
42
|
+
0n, 1n, 100n, BigInt(2**31 - 1), BigInt(2**32), BigInt(2**32 + 1),
|
|
43
|
+
(1n << 63n) - 1n, (1n << 64n) - 1n,
|
|
44
|
+
1234567890123456789n
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
for (const val of testValues) {
|
|
48
|
+
const cipher = engine.encrypt(val);
|
|
49
|
+
const decrypted = engine.decrypt(cipher);
|
|
50
|
+
expect(decrypted).toBe(val);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('test_cross_sdk_parity_golden_vector', async () => {
|
|
55
|
+
/**
|
|
56
|
+
* Verify bit-for-bit parity with Python implementation.
|
|
57
|
+
* Input 0 with TEST_KEY and TENANT_ID should match exactly.
|
|
58
|
+
*/
|
|
59
|
+
const masterKeyFull = Buffer.from(TEST_KEY, 'utf-8');
|
|
60
|
+
const masterKey16 = masterKeyFull.slice(0, 16);
|
|
61
|
+
const tenantTweak = crypto.createHmac('sha256', masterKeyFull).update(TENANT_ID, 'utf-8').digest();
|
|
62
|
+
const engine = new FF1(masterKey16, tenantTweak);
|
|
63
|
+
|
|
64
|
+
const cipher = engine.encrypt(0n);
|
|
65
|
+
// This value matches Python output for the same test keys
|
|
66
|
+
expect(cipher.toString()).toBe("14723038793896035711");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('test_tenant_isolation', async () => {
|
|
70
|
+
/** Verify that different tenants produce unique tokens. */
|
|
71
|
+
const name = "John Doe";
|
|
72
|
+
|
|
73
|
+
process.env.MASK_TENANT_ID = "tenant-a";
|
|
74
|
+
const tokenA = await generateFPEToken(name, "PERSON");
|
|
75
|
+
|
|
76
|
+
process.env.MASK_TENANT_ID = "tenant-b";
|
|
77
|
+
const tokenB = await generateFPEToken(name, "PERSON");
|
|
78
|
+
|
|
79
|
+
expect(tokenA).not.toBe(tokenB);
|
|
80
|
+
|
|
81
|
+
process.env.MASK_TENANT_ID = "tenant-a";
|
|
82
|
+
const tokenA2 = await generateFPEToken(name, "PERSON");
|
|
83
|
+
expect(tokenA).toBe(tokenA2);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('test_human_readable_synthesis', async () => {
|
|
87
|
+
/** Verify synthesis pattern matches Bijective expectations. */
|
|
88
|
+
const res = await generateFPEToken("Jane Doe", "PERSON");
|
|
89
|
+
// Pattern: Name Surname-Tag (4 digits)
|
|
90
|
+
expect(res).toContain("-");
|
|
91
|
+
const parts = res.split("-");
|
|
92
|
+
expect(parts[parts.length - 1].length).toBe(4);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('test_location_synthesis', async () => {
|
|
96
|
+
/** Verify bijective location synthesis. */
|
|
97
|
+
const res = await generateFPEToken("San Francisco", "LOCATION");
|
|
98
|
+
// Pattern: CityName-Tag (12 bits -> 3-4 digits)
|
|
99
|
+
expect(res).toContain("-");
|
|
100
|
+
const tag = res.split("-").pop()!;
|
|
101
|
+
expect(tag.length).toBeGreaterThanOrEqual(3);
|
|
102
|
+
});
|
|
103
|
+
});
|