@abhishekvardanbotta/mask 0.1.0 → 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/README.md +7 -7
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/policy.js +2 -1
- package/dist/privacy.d.ts +8 -6
- package/dist/privacy.js +14 -10
- package/dist/tokens.d.ts +20 -0
- package/dist/tokens.js +25 -0
- package/package.json +52 -52
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Keep personal data out of LLM prompts, tool calls, memory and logs, and still get it back where it is allowed.
|
|
4
4
|
|
|
5
5
|
`mask` finds personal data in structured and unstructured values. It replaces each value with a reversible
|
|
6
|
-
token such as `
|
|
6
|
+
token such as `ss1_njpskmng`, keeps the original encrypted in a vault, and restores it only for principals
|
|
7
7
|
granted that kind of data. It has zero runtime dependencies and uses only `node:crypto` and `node:net`.
|
|
8
8
|
|
|
9
9
|
It is the engine behind `privacy: { enabled: true }` in [kiln](https://www.npmjs.com/package/@abhishekvardanbotta/kiln), and works on its own with any LLM stack.
|
|
@@ -19,7 +19,7 @@ const privacy = createPrivacy({ key: process.env.KILN_PRIVACY_KEY }); // 32-by
|
|
|
19
19
|
const session = privacy.session("conversation-123");
|
|
20
20
|
|
|
21
21
|
const prompt = session.protectSync("I'm Priya Sharma, priya@example.com, card 4111 1111 1111 1111, cvv 123");
|
|
22
|
-
// "I'm
|
|
22
|
+
// "I'm ss1_ubjboike, ss1_njpskmng, card ss1_efecfvfz, cvv [BLOCKED:CARD_SECURITY_CODE]" (letters vary with the key)
|
|
23
23
|
|
|
24
24
|
const reply = await callYourModel(prompt); // the model only ever sees placeholders
|
|
25
25
|
session.restore(reply, { allow: ["PII"] }); // email and name restored; the card stays [REDACTED:CREDIT_CARD]
|
|
@@ -80,11 +80,11 @@ createPrivacy({ detectors: [{ name: "ner", detect: async (text) => (await ner(te
|
|
|
80
80
|
|
|
81
81
|
## Tokens, the vault and keys
|
|
82
82
|
|
|
83
|
-
- **Tokens
|
|
84
|
-
- `"
|
|
85
|
-
- `"
|
|
86
|
-
-
|
|
87
|
-
- **
|
|
83
|
+
- **Tokens.** The same value always gets the same token within a scope (looked up by an HMAC-SHA256 fingerprint, RFC 2104), so a model can still tell "the same customer wrote twice" and analytics on masked data still join and group correctly. Different scopes, or different keys, give unrelated tokens. `tokenStyle` picks the format:
|
|
84
|
+
- `"random"` (default): `ss1_njpskmng`, 8 letters from the keyed HMAC. Reveals nothing, not even what kind of value it replaced, and can't be guessed.
|
|
85
|
+
- `"sequential"`: `[EMAIL_1]`, `[PERSON_2]`, numbered per type within the scope. The type is visible and ids are guessable within a scope, but small models copy these most reliably.
|
|
86
|
+
- `"opaque"`: `[EMAIL_3f9a1c0b7e]`, the type plus 40 bits of the keyed HMAC.
|
|
87
|
+
- **Numbers** in JSON stay numbers (amounts, salaries, ages, counts), so aggregates are unchanged. A number is masked only when it is an identifier: in a classified field such as `ssn`, or a long integer that validates as a card or phone number. It restores as a number.
|
|
88
88
|
- **Retention**: `destroy(scope)` deletes a scope; its tokens, wherever they were logged, become permanently opaque. Records also carry `expiresAt` (`retentionMs`, default one hour).
|
|
89
89
|
|
|
90
90
|
```ts
|
package/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ export type { Recognizer } from "./detectors/recognizers.js";
|
|
|
7
7
|
export { aadhaarValid, cardBrand, emailValid, ibanValid, luhnValid, shannonEntropy, ssnValid, verhoeffValid } from "./detectors/validators.js";
|
|
8
8
|
export { inferFieldType } from "./fields.js";
|
|
9
9
|
export { generateKey } from "./crypto.js";
|
|
10
|
+
export { renderToken, TOKEN_REFERENCE } from "./tokens.js";
|
|
11
|
+
export type { TokenStyle } from "./tokens.js";
|
|
10
12
|
export { InMemoryVaultStore } from "./vault.js";
|
|
11
13
|
export type { VaultRecord, VaultStore } from "./vault.js";
|
|
12
14
|
export { PrivacyError } from "./types.js";
|
package/dist/index.js
CHANGED
|
@@ -4,5 +4,6 @@ export { BUILT_IN_RECOGNIZERS } from "./detectors/recognizers.js";
|
|
|
4
4
|
export { aadhaarValid, cardBrand, emailValid, ibanValid, luhnValid, shannonEntropy, ssnValid, verhoeffValid } from "./detectors/validators.js";
|
|
5
5
|
export { inferFieldType } from "./fields.js";
|
|
6
6
|
export { generateKey } from "./crypto.js";
|
|
7
|
+
export { renderToken, TOKEN_REFERENCE } from "./tokens.js";
|
|
7
8
|
export { InMemoryVaultStore } from "./vault.js";
|
|
8
9
|
export { PrivacyError } from "./types.js";
|
package/dist/policy.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { BUILT_IN_RECOGNIZERS } from "./detectors/recognizers.js";
|
|
2
2
|
import { compileFieldRules, isClassification } from "./fields.js";
|
|
3
3
|
import { PrivacyError } from "./types.js";
|
|
4
|
+
import { renderToken } from "./tokens.js";
|
|
4
5
|
/**
|
|
5
6
|
* Default handling per entity type, following NIST SP 800-122's confidentiality-impact guidance, GDPR Art. 9
|
|
6
7
|
* special categories, and PCI DSS v4.0:
|
|
@@ -109,7 +110,7 @@ export function maskValue(type, value) {
|
|
|
109
110
|
}
|
|
110
111
|
export function deniedReplacement(mode, type, value, token) {
|
|
111
112
|
if (mode === "token")
|
|
112
|
-
return
|
|
113
|
+
return renderToken(token);
|
|
113
114
|
if (mode === "mask")
|
|
114
115
|
return maskValue(type, value);
|
|
115
116
|
return `[REDACTED:${type}]`;
|
package/dist/privacy.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type CompiledPolicy } from "./policy.js";
|
|
2
2
|
import { type AuditEvent, type Detection, type EntityType, type PolicyConfig, type PrivacySummary, type ProtectOptions, type RestoreOptions } from "./types.js";
|
|
3
3
|
import { type VaultStore } from "./vault.js";
|
|
4
|
+
import { type TokenStyle } from "./tokens.js";
|
|
4
5
|
export interface PrivacyOptions extends PolicyConfig {
|
|
5
6
|
/**
|
|
6
7
|
* 256-bit master key (32 bytes, or base64 of 32 bytes). Omitted, a random key is generated per process: tokens then
|
|
@@ -13,13 +14,14 @@ export interface PrivacyOptions extends PolicyConfig {
|
|
|
13
14
|
/** How long an idle scope stays restorable, in ms. Default one hour. Records get a matching `expiresAt`. */
|
|
14
15
|
retentionMs?: number;
|
|
15
16
|
/**
|
|
16
|
-
* How
|
|
17
|
-
* - `"
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* How tokens look.
|
|
18
|
+
* - `"random"` (default): `ss1_kqfmzxat` — 8 letters from the keyed HMAC. Reveals nothing, not even what kind of value
|
|
19
|
+
* it replaced, and is unguessable. The same value still gets the same token within a scope.
|
|
20
|
+
* - `"sequential"`: `[EMAIL_1]`, `[PERSON_2]` — type plus a per-scope counter. Small models copy these most reliably,
|
|
21
|
+
* but the type is visible and ids are guessable within a scope.
|
|
22
|
+
* - `"opaque"`: `[EMAIL_3f9a1c0b7e]` — type plus 40 bits of the keyed HMAC.
|
|
21
23
|
*/
|
|
22
|
-
tokenStyle?:
|
|
24
|
+
tokenStyle?: TokenStyle;
|
|
23
25
|
}
|
|
24
26
|
interface Entry {
|
|
25
27
|
token: string;
|
package/dist/privacy.js
CHANGED
|
@@ -4,11 +4,9 @@ import { asNode, inferFieldType, itemSchema, matchFieldRule, nodeAnnotation, pro
|
|
|
4
4
|
import { compilePolicy, deniedReplacement, isGranted, mergePolicyConfig } from "./policy.js";
|
|
5
5
|
import { PrivacyError, } from "./types.js";
|
|
6
6
|
import { InMemoryVaultStore } from "./vault.js";
|
|
7
|
-
|
|
7
|
+
import { randomToken, renderToken, TOKEN_MARKER, TOKEN_REFERENCE } from "./tokens.js";
|
|
8
8
|
const TOKEN_ID_LENGTH = 10;
|
|
9
|
-
const TOKEN_MARKER = /\[(?:[A-Z][A-Z0-9_]*_(?:[0-9a-f]{10}|[1-9]\d{0,5})|BLOCKED:[A-Z][A-Z0-9_]*|REDACTED:[A-Z][A-Z0-9_]*)\]/g;
|
|
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. */
|
|
11
|
-
const TOKEN_REFERENCE = /(?<![A-Za-z0-9_])\[?([A-Z][A-Z0-9_]*_(?:[0-9a-f]{10}|[1-9]\d{0,5}))\]?(?![A-Za-z0-9_])/g;
|
|
12
10
|
const MAX_AUDIT_EVENTS = 5000;
|
|
13
11
|
const MAX_MEMO_ENTRIES = 2000;
|
|
14
12
|
/** Everything one scope knows: shared by every policy view of that scope. */
|
|
@@ -105,9 +103,9 @@ export class Privacy {
|
|
|
105
103
|
this.keys = deriveKeys(normalizeMasterKey(key));
|
|
106
104
|
this.store = store ?? new InMemoryVaultStore();
|
|
107
105
|
this.retentionMs = retentionMs ?? 60 * 60 * 1000;
|
|
108
|
-
if (tokenStyle !== undefined && tokenStyle !== "sequential" && tokenStyle !== "opaque")
|
|
109
|
-
throw new PrivacyError(`mask: tokenStyle must be "sequential" or "opaque".`, "config");
|
|
110
|
-
this.tokenStyle = tokenStyle ?? "
|
|
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";
|
|
111
109
|
this.baseConfig = policy;
|
|
112
110
|
this.basePolicy = compilePolicy(policy);
|
|
113
111
|
}
|
|
@@ -169,7 +167,13 @@ export class Privacy {
|
|
|
169
167
|
return existing;
|
|
170
168
|
}
|
|
171
169
|
let token;
|
|
172
|
-
if (this.tokenStyle === "
|
|
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") {
|
|
173
177
|
let n = state.sequence.get(type) ?? 0;
|
|
174
178
|
do {
|
|
175
179
|
n += 1;
|
|
@@ -462,7 +466,7 @@ export class PrivacySession {
|
|
|
462
466
|
return kind === "number" ? Number(value) : value;
|
|
463
467
|
if (fieldClass.classification === "BLOCK")
|
|
464
468
|
return this.block(type);
|
|
465
|
-
return
|
|
469
|
+
return renderToken(this.engine.tokenFor(this.state, type, value, fieldClass.classification, kind));
|
|
466
470
|
}
|
|
467
471
|
block(type) {
|
|
468
472
|
if (this.policy.onBlock === "throw")
|
|
@@ -486,10 +490,10 @@ export class PrivacySession {
|
|
|
486
490
|
output += text.slice(cursor, d.start);
|
|
487
491
|
const original = text.slice(d.start, d.end);
|
|
488
492
|
if (d.token)
|
|
489
|
-
output +=
|
|
493
|
+
output += renderToken(d.token);
|
|
490
494
|
else {
|
|
491
495
|
const classification = this.policy.classify(d.type);
|
|
492
|
-
output += classification === "BLOCK" ? this.block(d.type) :
|
|
496
|
+
output += classification === "BLOCK" ? this.block(d.type) : renderToken(this.engine.tokenFor(this.state, d.type, original, classification, "string"));
|
|
493
497
|
}
|
|
494
498
|
cursor = d.end;
|
|
495
499
|
}
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,52 +1,52 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@abhishekvardanbotta/mask",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Schema-aware PII classification, validated detection, reversible vault tokenization and authorization-checked restoration for LLM applications. Zero runtime dependencies.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"license": "MIT",
|
|
7
|
-
"author": "Abhishek Vardan Botta",
|
|
8
|
-
"main": "./dist/index.js",
|
|
9
|
-
"types": "./dist/index.d.ts",
|
|
10
|
-
"exports": {
|
|
11
|
-
".": {
|
|
12
|
-
"types": "./dist/index.d.ts",
|
|
13
|
-
"default": "./dist/index.js"
|
|
14
|
-
}
|
|
15
|
-
},
|
|
16
|
-
"files": [
|
|
17
|
-
"dist",
|
|
18
|
-
"README.md",
|
|
19
|
-
"LICENSE"
|
|
20
|
-
],
|
|
21
|
-
"publishConfig": {
|
|
22
|
-
"access": "public"
|
|
23
|
-
},
|
|
24
|
-
"keywords": [
|
|
25
|
-
"pii",
|
|
26
|
-
"privacy",
|
|
27
|
-
"tokenization",
|
|
28
|
-
"pseudonymization",
|
|
29
|
-
"redaction",
|
|
30
|
-
"llm",
|
|
31
|
-
"ai",
|
|
32
|
-
"agents",
|
|
33
|
-
"kiln",
|
|
34
|
-
"gdpr",
|
|
35
|
-
"pci"
|
|
36
|
-
],
|
|
37
|
-
"engines": {
|
|
38
|
-
"node": ">=20.3"
|
|
39
|
-
},
|
|
40
|
-
"scripts": {
|
|
41
|
-
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
42
|
-
"build": "npm run clean && tsc -p tsconfig.json",
|
|
43
|
-
"test": "tsx --test tests/*.test.ts",
|
|
44
|
-
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
45
|
-
"prepublishOnly": "npm run build && npm test"
|
|
46
|
-
},
|
|
47
|
-
"devDependencies": {
|
|
48
|
-
"@types/node": "^22.13.5",
|
|
49
|
-
"tsx": "^4.19.3",
|
|
50
|
-
"typescript": "^5.7.3"
|
|
51
|
-
}
|
|
52
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@abhishekvardanbotta/mask",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Schema-aware PII classification, validated detection, reversible vault tokenization and authorization-checked restoration for LLM applications. Zero runtime dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Abhishek Vardan Botta",
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"pii",
|
|
26
|
+
"privacy",
|
|
27
|
+
"tokenization",
|
|
28
|
+
"pseudonymization",
|
|
29
|
+
"redaction",
|
|
30
|
+
"llm",
|
|
31
|
+
"ai",
|
|
32
|
+
"agents",
|
|
33
|
+
"kiln",
|
|
34
|
+
"gdpr",
|
|
35
|
+
"pci"
|
|
36
|
+
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20.3"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
42
|
+
"build": "npm run clean && tsc -p tsconfig.json",
|
|
43
|
+
"test": "tsx --test tests/*.test.ts",
|
|
44
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
45
|
+
"prepublishOnly": "npm run build && npm test"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "^22.13.5",
|
|
49
|
+
"tsx": "^4.19.3",
|
|
50
|
+
"typescript": "^5.7.3"
|
|
51
|
+
}
|
|
52
|
+
}
|