|
1 | 1 | import assert from 'node:assert/strict'; |
2 | 2 | import { test } from 'node:test'; |
3 | 3 | import { |
| 4 | + bindEncryptKey, |
| 5 | + policyUsesEncrypt, |
| 6 | + stripInlineEncryptKeys, |
4 | 7 | ANONYMIZE_OPERATORS, |
5 | 8 | buildAnonymizeRequest, |
6 | 9 | byteLength, |
@@ -118,9 +121,11 @@ test('validateOperatorSpec rejects bad operator + bad encrypt keys', () => { |
118 | 121 | assert.equal(validateOperatorSpec(null).ok, false); |
119 | 122 | assert.equal(validateOperatorSpec('string').ok, false); |
120 | 123 |
|
| 124 | + // A KEYLESS encrypt spec is now the PRODUCTION shape — it means "use the org's vaulted key", so the |
| 125 | + // policy row never carries key material. It validates, carrying the intent with no key. |
121 | 126 | const noKey = validateOperatorSpec({ type: 'encrypt' }); |
122 | | - assert.equal(noKey.ok, false); |
123 | | - assert.match((noKey as { error: string }).error, /requires a key/); |
| 127 | + assert.equal(noKey.ok, true); |
| 128 | + assert.deepEqual((noKey as { value: unknown }).value, { type: 'encrypt' }); |
124 | 129 |
|
125 | 130 | const shortKey = validateOperatorSpec({ type: 'encrypt', key: 'short' }); |
126 | 131 | assert.equal(shortKey.ok, false); |
@@ -161,7 +166,8 @@ test('validateAnonymizerPolicy: missing default uses safe fallback; empty is val |
161 | 166 | }); |
162 | 167 |
|
163 | 168 | test('validateAnonymizerPolicy: bad default operator is a hard error', () => { |
164 | | - const res = validateAnonymizerPolicy({ default: { type: 'encrypt' } }); |
| 169 | + // A wrong-LENGTH inline key is still a hard error (a keyless encrypt is legal — vault-backed). |
| 170 | + const res = validateAnonymizerPolicy({ default: { type: 'encrypt', key: 'too-short' } }); |
165 | 171 | assert.equal(res.ok, false); |
166 | 172 | assert.match((res as { error: string }).error, /default operator/); |
167 | 173 | }); |
@@ -325,3 +331,73 @@ test('operator + hash catalogs match the verified live engine contract', () => { |
325 | 331 | // The shipped BFSI default policy is itself valid. |
326 | 332 | assert.equal(validateAnonymizerPolicy(DEFAULT_ANONYMIZER_POLICY).ok, true); |
327 | 333 | }); |
| 334 | + |
| 335 | +// ─── Vault-backed encryption keys ───────────────────────────────────────────────────────────────── |
| 336 | +// The rule: key material never lives in the policy row. The persisted policy carries the encrypt |
| 337 | +// INTENT (keyless spec); the key is bound from the secrets store at call time; an unresolvable key |
| 338 | +// DOWNGRADES to replace (still masked — never plaintext) and is reported, never hidden. |
| 339 | + |
| 340 | +test('policyUsesEncrypt detects encryption in the default or any per-entity spec', () => { |
| 341 | + assert.equal(policyUsesEncrypt({ default: { type: 'replace' }, perEntity: {} }), false); |
| 342 | + assert.equal(policyUsesEncrypt({ default: { type: 'encrypt' }, perEntity: {} }), true); |
| 343 | + assert.equal( |
| 344 | + policyUsesEncrypt({ default: { type: 'replace' }, perEntity: { IN_PAN: { type: 'encrypt' } } }), |
| 345 | + true, |
| 346 | + ); |
| 347 | +}); |
| 348 | + |
| 349 | +test('stripInlineEncryptKeys removes key material but PRESERVES the encrypt intent', () => { |
| 350 | + const key = 'k'.repeat(32); |
| 351 | + const stripped = stripInlineEncryptKeys({ |
| 352 | + default: { type: 'encrypt', key }, |
| 353 | + perEntity: { IN_PAN: { type: 'encrypt', key }, EMAIL_ADDRESS: { type: 'mask', charsToMask: 4 } }, |
| 354 | + }); |
| 355 | + assert.deepEqual(stripped.default, { type: 'encrypt' }, 'default keeps encrypt, loses the key'); |
| 356 | + assert.deepEqual(stripped.perEntity.IN_PAN, { type: 'encrypt' }); |
| 357 | + assert.equal(stripped.perEntity.EMAIL_ADDRESS.charsToMask, 4, 'other operators untouched'); |
| 358 | + // The stripped shape must survive a persist→read round-trip as ENCRYPT (not silently downgraded). |
| 359 | + assert.equal(normalizeAnonymizerPolicy(stripped).perEntity.IN_PAN.type, 'encrypt'); |
| 360 | +}); |
| 361 | + |
| 362 | +test('bindEncryptKey injects the vaulted key into every keyless encrypt spec', () => { |
| 363 | + const key = 'v'.repeat(24); |
| 364 | + const { policy, downgraded } = bindEncryptKey( |
| 365 | + { default: { type: 'encrypt' }, perEntity: { IN_PAN: { type: 'encrypt' }, X: { type: 'redact' } } }, |
| 366 | + key, |
| 367 | + ); |
| 368 | + assert.deepEqual(policy.default, { type: 'encrypt', key }); |
| 369 | + assert.deepEqual(policy.perEntity.IN_PAN, { type: 'encrypt', key }); |
| 370 | + assert.deepEqual(policy.perEntity.X, { type: 'redact' }, 'non-encrypt specs pass through'); |
| 371 | + assert.deepEqual(downgraded, [], 'nothing degraded when the key resolves'); |
| 372 | +}); |
| 373 | + |
| 374 | +test('bindEncryptKey FAILS SAFE: no key ⇒ downgrade to replace (masked, never plaintext) + report', () => { |
| 375 | + const { policy, downgraded } = bindEncryptKey( |
| 376 | + { default: { type: 'replace' }, perEntity: { IN_PAN: { type: 'encrypt' }, AADHAAR: { type: 'encrypt' } } }, |
| 377 | + null, |
| 378 | + ); |
| 379 | + assert.deepEqual(policy.perEntity.IN_PAN, { type: 'replace' }, 'still masked, not passed through'); |
| 380 | + assert.deepEqual(policy.perEntity.AADHAAR, { type: 'replace' }); |
| 381 | + assert.deepEqual(downgraded.sort(), ['AADHAAR', 'IN_PAN'], 'the degradation is reported honestly'); |
| 382 | + // and the produced Presidio operator config is a real masking operator, never an empty-key encrypt |
| 383 | + assert.deepEqual(specToOperatorConfig(policy.perEntity.IN_PAN), { type: 'replace' }); |
| 384 | +}); |
| 385 | + |
| 386 | +test('bindEncryptKey keeps an inline key (bootstrap) in preference to the vaulted one', () => { |
| 387 | + const inline = 'i'.repeat(16); |
| 388 | + const { policy, downgraded } = bindEncryptKey( |
| 389 | + { default: { type: 'encrypt', key: inline }, perEntity: {} }, |
| 390 | + 'v'.repeat(32), |
| 391 | + ); |
| 392 | + assert.deepEqual(policy.default, { type: 'encrypt', key: inline }); |
| 393 | + assert.deepEqual(downgraded, []); |
| 394 | +}); |
| 395 | + |
| 396 | +test('a keyless encrypt policy never reaches Presidio with an empty key', () => { |
| 397 | + // The regression this guards: specToOperatorConfig({type:'encrypt'}) yields key:'' which Presidio |
| 398 | + // rejects; bindEncryptKey must resolve or downgrade FIRST, so an empty key can never be sent. |
| 399 | + const { policy } = bindEncryptKey({ default: { type: 'encrypt' }, perEntity: {} }, null); |
| 400 | + const built = buildAnonymizeRequest('x', [{ entity_type: 'IN_PAN', start: 0, end: 1, score: 1 }], policy); |
| 401 | + assert.notDeepEqual(built.anonymizers.DEFAULT, { type: 'encrypt', key: '' }); |
| 402 | + assert.deepEqual(built.anonymizers.DEFAULT, { type: 'replace' }); |
| 403 | +}); |
0 commit comments