@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Abhishek Vardan Botta
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,142 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# @abhishekvardanbotta/mask
|
|
2
|
+
|
|
3
|
+
Keep personal data out of LLM prompts, tool calls, memory and logs, and still get it back where it is allowed.
|
|
4
|
+
|
|
5
|
+
`mask` finds personal data in structured and unstructured values. It replaces each value with a reversible
|
|
6
|
+
token such as `ss1_njpskmng`, keeps the original encrypted in a vault, and restores it only for principals
|
|
7
|
+
granted that kind of data. It has zero runtime dependencies and uses only `node:crypto` and `node:net`.
|
|
8
|
+
|
|
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.
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install @abhishekvardanbotta/mask # Node 20.3+
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { createPrivacy } from "@abhishekvardanbotta/mask";
|
|
17
|
+
|
|
18
|
+
const privacy = createPrivacy({ key: process.env.KILN_PRIVACY_KEY }); // 32-byte base64 key; omit for a per-process random one
|
|
19
|
+
const session = privacy.session("conversation-123");
|
|
20
|
+
|
|
21
|
+
const prompt = session.protectSync("I'm Priya Sharma, priya@example.com, card 4111 1111 1111 1111, cvv 123");
|
|
22
|
+
// "I'm ss1_ubjboike, ss1_njpskmng, card ss1_efecfvfz, cvv [BLOCKED:CARD_SECURITY_CODE]" (letters vary with the key)
|
|
23
|
+
|
|
24
|
+
const reply = await callYourModel(prompt); // the model only ever sees placeholders
|
|
25
|
+
session.restore(reply, { allow: ["PII"] }); // email and name restored; the card stays [REDACTED:CREDIT_CARD]
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Classifications
|
|
29
|
+
|
|
30
|
+
| Classification | What happens | Restorable by |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| `PII` | Replaced with a vault token | grants `"PII"`, `"*"`, or the entity type |
|
|
33
|
+
| `SENSITIVE` | Replaced with a vault token | grants `"SENSITIVE"` or the entity type. **`"*"` does not cover it.** |
|
|
34
|
+
| `PUBLIC` | Left untouched. On a field, detection is switched off for that subtree | — |
|
|
35
|
+
| `BLOCK` | Removed (`[BLOCKED:TYPE]`) and never stored | nobody, ever |
|
|
36
|
+
|
|
37
|
+
Default policy per type (override with `policies`):
|
|
38
|
+
|
|
39
|
+
- **PII**: `EMAIL`, `PHONE`, `PERSON`, `ADDRESS`, `LOCATION`, `DATE_OF_BIRTH`, `IP_ADDRESS`, `USERNAME`
|
|
40
|
+
- **SENSITIVE**: `CREDIT_CARD`, `IBAN`, `BANK_ACCOUNT`, `US_SSN`, `IN_AADHAAR`, `IN_PAN`, `NATIONAL_ID`, `PASSPORT`, `DRIVERS_LICENSE`, `HEALTH`, `SPECIAL_CATEGORY`
|
|
41
|
+
- **BLOCK**: `CREDENTIAL` (API keys, JWTs, passwords, private keys) and `CARD_SECURITY_CODE`
|
|
42
|
+
- Custom types default to PII.
|
|
43
|
+
|
|
44
|
+
## How data is found
|
|
45
|
+
|
|
46
|
+
Detection does not rely on a single regex per type. It combines five layers:
|
|
47
|
+
|
|
48
|
+
1. **Schema annotations.** In a JSON Schema passed as `protect(value, { schema })`:
|
|
49
|
+
- `"x-privacy": "SENSITIVE"`
|
|
50
|
+
- `"x-privacy": { "classification": "SENSITIVE", "type": "HEALTH" }`
|
|
51
|
+
- a `privacy:CLASS[:TYPE]` tag in `description` (what zod's `.describe("privacy:SENSITIVE:HEALTH")` produces)
|
|
52
|
+
- standard `format: "email" | "ipv4" | "ipv6"`
|
|
53
|
+
|
|
54
|
+
Local `$ref`s, `allOf`/`anyOf`/`oneOf`, `items`/`prefixItems` and `additionalProperties` are followed.
|
|
55
|
+
2. **Field paths in config.** For example `fields: { "customer.ssn": "BLOCK", "**.notes": { classification: "SENSITIVE", type: "NOTE" } }`. `*` matches one key and `**` any depth. A bare key matches at any depth.
|
|
56
|
+
3. **Field-name semantics.** `billingEmail`, `phone_number`, `dateOfBirth`, `apiKey` and `diagnosis` are classified from their trailing words, so `emailVerified` and `phoneType` are not. A bare `name` counts as a person's name only under a person-like parent such as `customer.name`. Switch this off with `inferFromFieldNames: false`.
|
|
57
|
+
4. **Validated recognizers for free text.** A candidate pattern only proposes a match; a validator decides:
|
|
58
|
+
|
|
59
|
+
| Type | Validation |
|
|
60
|
+
|---|---|
|
|
61
|
+
| Cards | Luhn (ISO/IEC 7812) plus issuer ranges |
|
|
62
|
+
| IBAN | ISO 13616 per-country length plus mod-97 |
|
|
63
|
+
| US SSN | SSA area, group and serial rules |
|
|
64
|
+
| Aadhaar | Verhoeff check digit (UIDAI) |
|
|
65
|
+
| Email | RFC 5321 length and label limits |
|
|
66
|
+
| IP addresses | `node:net` |
|
|
67
|
+
| JWT | decoded header with `alg` (RFC 7519) |
|
|
68
|
+
| Secrets | Shannon entropy |
|
|
69
|
+
|
|
70
|
+
Context words then raise or lower the confidence. "SSN", "born" and "card" raise it; "order", "invoice" and "version" lower it. A finding counts only above `threshold` (default 0.5).
|
|
71
|
+
5. **Known-value propagation.** Once a value is tokenized anywhere in a scope (for example a name from a `fullName` field), later free-text mentions of it, and of the parts of a person's name, get the same token.
|
|
72
|
+
|
|
73
|
+
Add your own detectors, synchronous or asynchronous, such as an NER model or a customer-ID dictionary:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
createPrivacy({ detectors: [{ name: "ner", detect: async (text) => (await ner(text)).map(e => ({ type: "PERSON", start: e.start, end: e.end, score: e.score })) }] });
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`privacy.detect(text)` shows what would be found, with confidences, without tokenizing.
|
|
80
|
+
|
|
81
|
+
## Tokens, the vault and keys
|
|
82
|
+
|
|
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
|
+
- **Retention**: `destroy(scope)` deletes a scope; its tokens, wherever they were logged, become permanently opaque. Records also carry `expiresAt` (`retentionMs`, default one hour).
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
const privacy = createPrivacy({ key, store: myStore, retentionMs: 24 * 3600_000 });
|
|
92
|
+
const session = await privacy.open("chat-42"); // loads and decrypts persisted records first
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Restoring, with authorization
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
session.restore(value, { allow: ["PII", "US_SSN"], principal: "user:42" }); // only what is granted
|
|
99
|
+
session.restore(value, { allow: ["EMAIL"], onDenied: "mask" }); // others masked: "***-**-6789"
|
|
100
|
+
session.restore(value, { trusted: true }); // everything restorable — your own code only
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
- **Denied values** become `[REDACTED:TYPE]` (default), a mask (`onDenied: "mask"`), or stay as the token (`"token"`). Masks show at most the last four digits of a card, per PCI DSS 3.4.1.
|
|
104
|
+
- **Bare tokens** without brackets are restored too, since models sometimes drop the brackets.
|
|
105
|
+
- **Unknown tokens are never restored**: another scope's, or a made-up one, is left exactly as it is.
|
|
106
|
+
- **The audit trail** (`session.audit()`) records each tokenize, block, restore and deny by token and type, never by plaintext.
|
|
107
|
+
- **Streaming**: `session.streamRestorer(options)` restores chunk by chunk without ever emitting half a token.
|
|
108
|
+
|
|
109
|
+
## API summary
|
|
110
|
+
|
|
111
|
+
| | |
|
|
112
|
+
|---|---|
|
|
113
|
+
| `createPrivacy(options)` | engine: `key`, `store`, `retentionMs`, `tokenStyle`, plus any `PolicyConfig` |
|
|
114
|
+
| `privacy.session(scope, policy?)` / `await privacy.open(scope, policy?)` | a session over a scope (`open` hydrates from the store) |
|
|
115
|
+
| `privacy.policy(overrides)` | compile a policy layered on the engine's (reuse it across sessions) |
|
|
116
|
+
| `await session.protect(value, { schema? })` | tokenize a string or any JSON value; runs async detectors |
|
|
117
|
+
| `session.protectSync(value, { schema? })` | same, synchronous detectors only (plus every value already known in the scope) |
|
|
118
|
+
| `session.restore(value, { allow?, trusted?, onDenied?, principal? })` | authorization-checked restoration |
|
|
119
|
+
| `session.summary()` / `session.audit()` | counts and the audit trail |
|
|
120
|
+
| `await session.flush()` | wait for vault writes; rejects if the store failed |
|
|
121
|
+
| `await privacy.destroy(scope)` | crypto-shred a scope |
|
|
122
|
+
|
|
123
|
+
`PolicyConfig` options: `policies`, `fields`, `detectors`, `disable` (built-in recognizer names), `threshold`, `inferFromFieldNames`, `allowlist`, `onBlock: "redact" | "throw"`.
|
|
124
|
+
|
|
125
|
+
## Threat model and limits
|
|
126
|
+
|
|
127
|
+
**What it protects.** The goal is to keep personal data from leaving your process towards an LLM provider, a tool or API, a memory store, or a log sink. It also stops code outside your trust boundary from turning tokens back into data.
|
|
128
|
+
|
|
129
|
+
**What it does not do:**
|
|
130
|
+
|
|
131
|
+
- **Detection is not perfect.**
|
|
132
|
+
- A name or street address in free text with no supporting cue ("Priya called yesterday") is not found by the built-in recognizers. Use a schema or field classification (values then propagate into free text), or plug in an NER detector.
|
|
133
|
+
- Free-text health or other special-category content is only caught through field classification or your own detector.
|
|
134
|
+
- Run `privacy.detect()` on representative data and tune `threshold`, `policies` and `fields`.
|
|
135
|
+
- **Tokens are pseudonymous data, not anonymous data** (GDPR Art. 4(5)). While the vault and key exist, they are reversible by design.
|
|
136
|
+
- **Scope and model behavior.** Inside a scope, deterministic tokens reveal equality: two mentions of the same email share a token. That is what preserves reasoning. A model can also misuse placeholders, for example by combining two into one; it never sees the values, but the task can fail. Measure on your own model.
|
|
137
|
+
- **Key protection is your job.** Anyone with the master key and the store can decrypt it. Keep the key in a secrets manager.
|
|
138
|
+
- **No certification.** It has not been audited or certified for any standard (PCI DSS, HIPAA or GDPR compliance is a property of your whole system). The references above describe which algorithms and rules it follows, not a compliance claim.
|
|
139
|
+
|
|
140
|
+
## License
|
|
141
|
+
|
|
142
|
+
MIT
|
package/dist/crypto.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two independent subkeys derived from one 256-bit master key with HKDF-SHA256 (RFC 5869), so the key that
|
|
3
|
+
* encrypts vault values (AES-256-GCM, NIST SP 800-38D) is never the key that derives tokens (HMAC-SHA256,
|
|
4
|
+
* RFC 2104 / FIPS 198-1).
|
|
5
|
+
*/
|
|
6
|
+
export interface DerivedKeys {
|
|
7
|
+
readonly encryption: Buffer;
|
|
8
|
+
readonly mac: Buffer;
|
|
9
|
+
}
|
|
10
|
+
/** A fresh random 256-bit master key, base64-encoded — suitable for `KILN_PRIVACY_KEY` or a secrets manager. */
|
|
11
|
+
export declare function generateKey(): string;
|
|
12
|
+
export declare function normalizeMasterKey(key: string | Uint8Array | undefined): Buffer;
|
|
13
|
+
export declare function deriveKeys(master: Buffer): DerivedKeys;
|
|
14
|
+
/** Keyed fingerprint of a normalized value within a scope. Without the key it supports no dictionary attack. */
|
|
15
|
+
export declare function fingerprint(keys: DerivedKeys, scope: string, type: string, normalized: string): string;
|
|
16
|
+
export interface Sealed {
|
|
17
|
+
iv: string;
|
|
18
|
+
ct: string;
|
|
19
|
+
tag: string;
|
|
20
|
+
}
|
|
21
|
+
/** AES-256-GCM with a random IV per value. `aad` binds the ciphertext to its scope and token, so records cannot be swapped. */
|
|
22
|
+
export declare function seal(keys: DerivedKeys, plaintext: string, aad: string): Sealed;
|
|
23
|
+
export declare function open(keys: DerivedKeys, sealed: Sealed, aad: string): string;
|
package/dist/crypto.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHmac, hkdfSync, randomBytes } from "node:crypto";
|
|
2
|
+
import { PrivacyError } from "./types.js";
|
|
3
|
+
const HKDF_SALT = Buffer.from("mask/v1", "utf8");
|
|
4
|
+
const IV_BYTES = 12; // 96-bit IV, the GCM recommendation (SP 800-38D §5.2.1.1)
|
|
5
|
+
/** A fresh random 256-bit master key, base64-encoded — suitable for `KILN_PRIVACY_KEY` or a secrets manager. */
|
|
6
|
+
export function generateKey() {
|
|
7
|
+
return randomBytes(32).toString("base64");
|
|
8
|
+
}
|
|
9
|
+
export function normalizeMasterKey(key) {
|
|
10
|
+
if (key === undefined)
|
|
11
|
+
return randomBytes(32);
|
|
12
|
+
const bytes = typeof key === "string" ? Buffer.from(key, "base64") : Buffer.from(key);
|
|
13
|
+
if (bytes.length !== 32)
|
|
14
|
+
throw new PrivacyError(`mask: the master key must be 32 bytes (got ${bytes.length}). Generate one with generateKey().`, "config");
|
|
15
|
+
return bytes;
|
|
16
|
+
}
|
|
17
|
+
export function deriveKeys(master) {
|
|
18
|
+
return {
|
|
19
|
+
encryption: Buffer.from(hkdfSync("sha256", master, HKDF_SALT, "vault-encryption", 32)),
|
|
20
|
+
mac: Buffer.from(hkdfSync("sha256", master, HKDF_SALT, "token-derivation", 32)),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** Keyed fingerprint of a normalized value within a scope. Without the key it supports no dictionary attack. */
|
|
24
|
+
export function fingerprint(keys, scope, type, normalized) {
|
|
25
|
+
return createHmac("sha256", keys.mac).update(scope).update("\u0000").update(type).update("\u0000").update(normalized).digest("hex");
|
|
26
|
+
}
|
|
27
|
+
/** AES-256-GCM with a random IV per value. `aad` binds the ciphertext to its scope and token, so records cannot be swapped. */
|
|
28
|
+
export function seal(keys, plaintext, aad) {
|
|
29
|
+
const iv = randomBytes(IV_BYTES);
|
|
30
|
+
const cipher = createCipheriv("aes-256-gcm", keys.encryption, iv);
|
|
31
|
+
cipher.setAAD(Buffer.from(aad, "utf8"));
|
|
32
|
+
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
33
|
+
return { iv: iv.toString("base64"), ct: ct.toString("base64"), tag: cipher.getAuthTag().toString("base64") };
|
|
34
|
+
}
|
|
35
|
+
export function open(keys, sealed, aad) {
|
|
36
|
+
try {
|
|
37
|
+
const decipher = createDecipheriv("aes-256-gcm", keys.encryption, Buffer.from(sealed.iv, "base64"));
|
|
38
|
+
decipher.setAAD(Buffer.from(aad, "utf8"));
|
|
39
|
+
decipher.setAuthTag(Buffer.from(sealed.tag, "base64"));
|
|
40
|
+
return Buffer.concat([decipher.update(Buffer.from(sealed.ct, "base64")), decipher.final()]).toString("utf8");
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw new PrivacyError("mask: a vault record failed authentication (wrong key, or the record was altered).", "vault");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Detection, EntityType } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* A built-in recognizer: a cheap candidate pattern, then a validator that scores (or rejects) each candidate, then a
|
|
4
|
+
* context pass that raises the score when supporting words sit nearby ("SSN", "card", "born") and lowers it when
|
|
5
|
+
* contradicting words do ("order", "invoice", "version"). The pattern only proposes; the validator and context decide.
|
|
6
|
+
*/
|
|
7
|
+
export interface Recognizer {
|
|
8
|
+
readonly name: string;
|
|
9
|
+
readonly type: EntityType;
|
|
10
|
+
readonly pattern: RegExp;
|
|
11
|
+
/** Capture group holding the value (the rest of the match is context, e.g. "password:"). Default 0. */
|
|
12
|
+
readonly group?: number;
|
|
13
|
+
/** Returns a base confidence, or undefined to reject the candidate. */
|
|
14
|
+
score(value: string): number | undefined;
|
|
15
|
+
readonly context?: string[];
|
|
16
|
+
/** Added when a context word appears within the window. Default 0.35. */
|
|
17
|
+
readonly contextBoost?: number;
|
|
18
|
+
/** How far past the match to look for context, in characters. Default 24. */
|
|
19
|
+
readonly contextAfter?: number;
|
|
20
|
+
readonly negativeContext?: string[];
|
|
21
|
+
}
|
|
22
|
+
export declare const BUILT_IN_RECOGNIZERS: Recognizer[];
|
|
23
|
+
/** Runs one recognizer over `text`, applying its validator and context scoring. */
|
|
24
|
+
export declare function runRecognizer(recognizer: Recognizer, text: string): Detection[];
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { aadhaarValid, cardBrand, emailValid, ibanValid, ipVersion, isNonPublicIPv4, jwtValid, luhnValid, parseDate, shannonEntropy, ssnValid, } from "./validators.js";
|
|
2
|
+
const CONTEXT_BEFORE = 48;
|
|
3
|
+
const CONTEXT_AFTER = 24;
|
|
4
|
+
const NEGATIVE_PENALTY = 0.35;
|
|
5
|
+
const PHONE_CONTEXT = ["phone", "mobile", "cell", "call", "tel", "telephone", "contact", "whatsapp", "sms", "text", "reach", "fax", "ph", "number"];
|
|
6
|
+
const ID_NEGATIVE = ["order", "invoice", "ref", "reference", "ticket", "tracking", "sku", "isbn", "serial", "version", "po", "case", "transaction", "txn", "build", "batch"];
|
|
7
|
+
function digitsOf(value) {
|
|
8
|
+
return value.replace(/\D/g, "");
|
|
9
|
+
}
|
|
10
|
+
export const BUILT_IN_RECOGNIZERS = [
|
|
11
|
+
{
|
|
12
|
+
name: "email",
|
|
13
|
+
type: "EMAIL",
|
|
14
|
+
pattern: /[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+/g,
|
|
15
|
+
score: (v) => (emailValid(v) ? 0.95 : undefined),
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: "credit-card",
|
|
19
|
+
type: "CREDIT_CARD",
|
|
20
|
+
pattern: /(?<![\d-])\d(?:[ -]?\d){12,18}(?![\d])/g,
|
|
21
|
+
score: (v) => {
|
|
22
|
+
const digits = digitsOf(v);
|
|
23
|
+
if (digits.length < 13 || digits.length > 19 || !luhnValid(digits) || /^(\d)\1+$/.test(digits))
|
|
24
|
+
return undefined;
|
|
25
|
+
return cardBrand(digits) ? 0.9 : 0.4;
|
|
26
|
+
},
|
|
27
|
+
context: ["card", "credit", "debit", "visa", "mastercard", "amex", "cc", "payment", "pan"],
|
|
28
|
+
contextBoost: 0.4,
|
|
29
|
+
negativeContext: ID_NEGATIVE,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: "card-security-code",
|
|
33
|
+
type: "CARD_SECURITY_CODE",
|
|
34
|
+
pattern: /\b(?:cvv2?|cvc2?|cid|csc|security code)\b\s*(?:is|[:#=-])?\s*(\d{3,4})\b/gi,
|
|
35
|
+
group: 1,
|
|
36
|
+
score: () => 0.9,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "iban",
|
|
40
|
+
type: "IBAN",
|
|
41
|
+
pattern: /\b[A-Z]{2}\d{2}(?:[ ]?[A-Z0-9]{4}){2,7}(?:[ ]?[A-Z0-9]{1,3})?\b/g,
|
|
42
|
+
score: (v) => (ibanValid(v) ? 0.95 : undefined),
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "us-ssn",
|
|
46
|
+
type: "US_SSN",
|
|
47
|
+
pattern: /(?<![\d-])\d{3}([- ]?)\d{2}\1\d{4}(?![\d-])/g,
|
|
48
|
+
score: (v) => {
|
|
49
|
+
if (!ssnValid(digitsOf(v)))
|
|
50
|
+
return undefined;
|
|
51
|
+
if (v.includes("-"))
|
|
52
|
+
return 0.6;
|
|
53
|
+
return v.includes(" ") ? 0.35 : 0.15;
|
|
54
|
+
},
|
|
55
|
+
context: ["ssn", "social security", "social sec", "ss#", "ss no", "taxpayer", "tin"],
|
|
56
|
+
contextBoost: 0.45,
|
|
57
|
+
negativeContext: ID_NEGATIVE,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: "in-aadhaar",
|
|
61
|
+
type: "IN_AADHAAR",
|
|
62
|
+
pattern: /(?<![\d-])[2-9]\d{3}([ -]?)\d{4}\1\d{4}(?![\d-])/g,
|
|
63
|
+
score: (v) => (aadhaarValid(digitsOf(v)) ? (/[ -]/.test(v) ? 0.6 : 0.35) : undefined),
|
|
64
|
+
context: ["aadhaar", "aadhar", "adhaar", "uidai", "uid"],
|
|
65
|
+
contextBoost: 0.4,
|
|
66
|
+
negativeContext: ID_NEGATIVE,
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: "in-pan",
|
|
70
|
+
type: "IN_PAN",
|
|
71
|
+
// 4th character is the holder type defined by the Income Tax Department (P = person, C = company, ...).
|
|
72
|
+
pattern: /\b[A-Z]{3}[PCHFATBLJG][A-Z]\d{4}[A-Z]\b/g,
|
|
73
|
+
score: () => 0.6,
|
|
74
|
+
context: ["pan", "permanent account", "income tax"],
|
|
75
|
+
contextBoost: 0.3,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: "phone",
|
|
79
|
+
type: "PHONE",
|
|
80
|
+
pattern: /(?<![\w+.@/-])(?:\+\d{1,3}[ .-]?)?(?:\(\d{1,4}\)[ .-]?)?\d{2,5}(?:[ .-]?\d{2,5}){1,4}(?![\w@]|[.-]\d)/g,
|
|
81
|
+
score: (v) => {
|
|
82
|
+
const digits = digitsOf(v);
|
|
83
|
+
if (digits.length < 7 || digits.length > 15 || /^(\d)\1+$/.test(digits))
|
|
84
|
+
return undefined;
|
|
85
|
+
if (parseDate(v) || ipVersion(v) || /^\d+\.\d+$/.test(v))
|
|
86
|
+
return undefined;
|
|
87
|
+
const separators = v.replace(/[\d+()]/g, "");
|
|
88
|
+
if (separators.length && new Set(separators.replace(/ /g, "")).size > 1)
|
|
89
|
+
return undefined; // mixed "-" and "." is not a phone format
|
|
90
|
+
if (v.startsWith("+"))
|
|
91
|
+
return digits.length >= 8 ? 0.8 : undefined;
|
|
92
|
+
const formatted = /[ .()-]/.test(v);
|
|
93
|
+
const national = digits.length === 11 && digits.startsWith("1") ? digits.slice(1) : digits;
|
|
94
|
+
if (national.length === 10 && /^[2-9]\d{2}[2-9]\d{6}$/.test(national) && formatted)
|
|
95
|
+
return 0.65; // NANP (area + exchange rules)
|
|
96
|
+
if (digits.length === 10 && /^[6-9]/.test(digits))
|
|
97
|
+
return 0.5; // Indian mobile numbering plan (TRAI): 10 digits, leading 6-9
|
|
98
|
+
if (national.length === 10 && /^[2-9]\d{2}[2-9]\d{6}$/.test(national))
|
|
99
|
+
return 0.35;
|
|
100
|
+
return formatted ? 0.3 : 0.15;
|
|
101
|
+
},
|
|
102
|
+
context: PHONE_CONTEXT,
|
|
103
|
+
contextBoost: 0.35,
|
|
104
|
+
negativeContext: [...ID_NEGATIVE, "account", "acct", "id", "zip", "pin", "price", "amount", "total"],
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: "ipv4",
|
|
108
|
+
type: "IP_ADDRESS",
|
|
109
|
+
// A trailing "." ends the sentence, not the address: only a dot followed by a digit means a longer number.
|
|
110
|
+
pattern: /(?<![\d.])(?:\d{1,3}\.){3}\d{1,3}(?!\d|\.\d)/g,
|
|
111
|
+
score: (v) => (ipVersion(v) === 4 ? (isNonPublicIPv4(v) ? 0.3 : 0.6) : undefined),
|
|
112
|
+
context: ["ip", "address", "client", "host", "login", "from", "remote", "source"],
|
|
113
|
+
contextBoost: 0.3,
|
|
114
|
+
negativeContext: ["version", "v", "release", "build"],
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: "ipv6",
|
|
118
|
+
type: "IP_ADDRESS",
|
|
119
|
+
pattern: /(?<![\w:])(?=[A-Fa-f0-9:]*[A-Fa-f0-9]:[A-Fa-f0-9:]*:[A-Fa-f0-9])[A-Fa-f0-9:]{6,39}(?![\w:])/g,
|
|
120
|
+
score: (v) => {
|
|
121
|
+
if (ipVersion(v) !== 6)
|
|
122
|
+
return undefined;
|
|
123
|
+
if (v.split(":").filter(Boolean).length < 3)
|
|
124
|
+
return undefined;
|
|
125
|
+
return /^fe80:/i.test(v) ? 0.4 : 0.6;
|
|
126
|
+
},
|
|
127
|
+
context: ["ip", "address", "client", "host", "remote"],
|
|
128
|
+
contextBoost: 0.3,
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: "date-of-birth",
|
|
132
|
+
type: "DATE_OF_BIRTH",
|
|
133
|
+
pattern: /\b(?:\d{4}[-/.]\d{1,2}[-/.]\d{1,2}|\d{1,2}[-/.]\d{1,2}[-/.]\d{4}|\d{1,2}(?:st|nd|rd|th)?\s+[A-Z][a-z]{2,8}\.?,?\s+\d{4}|[A-Z][a-z]{2,8}\.?\s+\d{1,2}(?:st|nd|rd|th)?,?\s+\d{4})\b/g,
|
|
134
|
+
// A date is not personal data on its own; only "born"/"DOB"-style context lifts it over the threshold.
|
|
135
|
+
score: (v) => {
|
|
136
|
+
const date = parseDate(v);
|
|
137
|
+
if (!date)
|
|
138
|
+
return undefined;
|
|
139
|
+
const year = date.getUTCFullYear();
|
|
140
|
+
return year >= 1900 && date.getTime() <= Date.now() ? 0.1 : undefined;
|
|
141
|
+
},
|
|
142
|
+
context: ["born", "birth", "dob", "d.o.b", "birthday", "birthdate", "date of birth"],
|
|
143
|
+
contextBoost: 0.6,
|
|
144
|
+
// "shipped 2024-01-15. I was born ..." — the next sentence's context must not leak backwards onto this date.
|
|
145
|
+
contextAfter: 0,
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: "jwt",
|
|
149
|
+
type: "CREDENTIAL",
|
|
150
|
+
pattern: /\beyJ[A-Za-z0-9_-]{4,}\.eyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{8,}/g,
|
|
151
|
+
score: (v) => (jwtValid(v) ? 0.95 : undefined),
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
name: "private-key",
|
|
155
|
+
type: "CREDENTIAL",
|
|
156
|
+
pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
157
|
+
score: () => 0.99,
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: "vendor-api-key",
|
|
161
|
+
type: "CREDENTIAL",
|
|
162
|
+
// Vendor-published, fixed-prefix credential formats (OpenAI/Anthropic, GitHub, AWS, Slack, Google, Stripe).
|
|
163
|
+
pattern: /\b(?:sk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}|gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,}|(?:AKIA|ASIA)[0-9A-Z]{16}|xox[abposr]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{35}|(?:sk|rk)_live_[0-9A-Za-z]{16,})/g,
|
|
164
|
+
score: () => 0.95,
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: "secret-assignment",
|
|
168
|
+
type: "CREDENTIAL",
|
|
169
|
+
pattern: /\b(?:password|passwd|pwd|passphrase|secret|client[_-]?secret|api[_-]?key|access[_-]?token|auth[_-]?token|refresh[_-]?token|bearer|private[_-]?key)\b["']?\s*(?:[:=]|\bis\b)?\s*["']?([^\s"',;]{4,})/gi,
|
|
170
|
+
group: 1,
|
|
171
|
+
// "password is incorrect" captures "incorrect": a plain lowercase word is not scored as a secret.
|
|
172
|
+
score: (v) => (/[\d\W_]/.test(v) || /[A-Z]/.test(v.slice(1)) || v.length >= 16 ? 0.85 : undefined),
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: "high-entropy-secret",
|
|
176
|
+
type: "CREDENTIAL",
|
|
177
|
+
pattern: /(?<![A-Za-z0-9+/_=[-])[A-Za-z0-9+/_=-]{24,}(?![A-Za-z0-9+/_=\]-])/g,
|
|
178
|
+
score: (v) => {
|
|
179
|
+
const classes = [/[a-z]/, /[A-Z]/, /\d/, /[+/_=-]/].filter((re) => re.test(v)).length;
|
|
180
|
+
return classes >= 3 && shannonEntropy(v) >= 4 ? 0.45 : undefined;
|
|
181
|
+
},
|
|
182
|
+
context: ["key", "token", "secret", "bearer", "authorization", "credential", "api", "password", "auth"],
|
|
183
|
+
contextBoost: 0.4,
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
name: "person-introduced",
|
|
187
|
+
type: "PERSON",
|
|
188
|
+
pattern: /\b(?:[Mm]y name is|[Mm]y name's|[Nn]ame\s*(?:is|:)|[Ss]igned(?: by)?,?|[Cc]ustomer\s*:|[Pp]atient\s*:|[Cc]ontact\s*:|[Dd]ear)\s+((?:[A-Z][a-z'’-]+)(?:\s+(?:[A-Z][a-z'’-]+|[A-Z]\.)){0,3})/g,
|
|
189
|
+
group: 1,
|
|
190
|
+
score: (v) => (NON_NAME_WORDS.has(v.split(/\s+/)[0]) ? undefined : 0.75),
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
name: "person-role",
|
|
194
|
+
type: "PERSON",
|
|
195
|
+
// "Customer Priya Sharma reported..." — a role noun followed by a first-plus-last shape. One capitalized word is not
|
|
196
|
+
// enough ("Customer Service"), and department words are excluded.
|
|
197
|
+
pattern: /\b(?:[Cc]ustomer|[Cc]lient|[Pp]atient|[Uu]ser|[Mm]ember|[Ee]mployee|[Aa]pplicant|[Gg]uest|[Pp]assenger|[Tt]enant|[Ss]tudent|[Cc]andidate)\s+([A-Z][a-z'’-]+\s+[A-Z][a-z'’-]+)\b/g,
|
|
198
|
+
group: 1,
|
|
199
|
+
score: (v) => (v.split(/\s+/).some((w) => NON_NAME_WORDS.has(w)) ? undefined : 0.6),
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
name: "person-self-reference",
|
|
203
|
+
type: "PERSON",
|
|
204
|
+
// "I am Excited" is not a name; "I am Priya Sharma" probably is. Only a first-plus-last shape clears the threshold.
|
|
205
|
+
pattern: /\b(?:[Ii] am|[Ii]'m|[Tt]his is)\s+((?:[A-Z][a-z'’-]+)(?:\s+[A-Z][a-z'’-]+){0,2})/g,
|
|
206
|
+
group: 1,
|
|
207
|
+
score: (v) => (NON_NAME_WORDS.has(v.split(/\s+/)[0]) ? undefined : v.includes(" ") ? 0.55 : 0.3),
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
name: "person-honorific",
|
|
211
|
+
type: "PERSON",
|
|
212
|
+
pattern: /\b(?:Mr|Mrs|Ms|Miss|Mx|Dr|Prof|Shri|Smt)\.?\s+([A-Z][a-z'’-]+(?:\s+[A-Z][a-z'’-]+){0,2})/g,
|
|
213
|
+
group: 1,
|
|
214
|
+
score: (v) => (NON_NAME_WORDS.has(v.split(/\s+/)[0]) ? undefined : 0.8),
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
name: "person-signoff",
|
|
218
|
+
type: "PERSON",
|
|
219
|
+
pattern: /\b(?:Regards|Sincerely|Thanks|Thank you|Best|Cheers|Warmly|Yours truly)[,!.]?[ \t]*\r?\n+[ \t]*([A-Z][a-z'’-]+(?:[ \t]+[A-Z][a-z'’-]+){0,2})/g,
|
|
220
|
+
group: 1,
|
|
221
|
+
score: (v) => (NON_NAME_WORDS.has(v.split(/\s+/)[0]) ? undefined : 0.6),
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
name: "street-address",
|
|
225
|
+
type: "ADDRESS",
|
|
226
|
+
// USPS Publication 28 street suffixes (plus common Indian/UK forms), after a house number and capitalized words.
|
|
227
|
+
pattern: /\b\d{1,6}[A-Za-z]?,?\s+(?:[A-Z][A-Za-z.'-]*\s+){1,4}(?:Street|St|Avenue|Ave|Road|Rd|Boulevard|Blvd|Lane|Ln|Drive|Dr|Court|Ct|Way|Place|Pl|Terrace|Ter|Circle|Cir|Highway|Hwy|Parkway|Pkwy|Square|Sq|Trail|Trl|Crescent|Close|Marg|Nagar)\b\.?(?:,?\s*(?:Apt|Apartment|Suite|Ste|Unit|Flat|#)\.?\s*[A-Za-z0-9-]+)?/g,
|
|
228
|
+
score: () => 0.7,
|
|
229
|
+
},
|
|
230
|
+
];
|
|
231
|
+
/** Capitalized words that follow "Hi"/"I am"/"Dear" without being a name. */
|
|
232
|
+
const NON_NAME_WORDS = new Set([
|
|
233
|
+
"Team", "All", "There", "Everyone", "World", "Sir", "Madam", "Support", "Customer", "Guys", "Folks", "Bot", "Assistant",
|
|
234
|
+
"The", "A", "An", "Not", "Very", "So", "Just", "Happy", "Sorry", "Here", "Going", "Looking", "Writing", "Calling",
|
|
235
|
+
"Trying", "Unable", "Interested", "New", "Please", "Hello", "Hi", "Thanks", "Thank", "User", "Admin", "Available",
|
|
236
|
+
"Service", "Services", "Success", "Care", "Experience", "Relations", "Portal", "Account", "Accounts", "Data", "Feedback",
|
|
237
|
+
"Satisfaction", "Journey", "Segment", "Profile", "Record", "Records", "Id", "Number", "Name", "Group", "Manager", "Interface",
|
|
238
|
+
]);
|
|
239
|
+
const contextRegexCache = new WeakMap();
|
|
240
|
+
function contextRegex(words) {
|
|
241
|
+
let re = contextRegexCache.get(words);
|
|
242
|
+
if (!re) {
|
|
243
|
+
re = new RegExp(`(?:^|[^a-z0-9])(?:${words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})(?:$|[^a-z0-9])`, "i");
|
|
244
|
+
contextRegexCache.set(words, re);
|
|
245
|
+
}
|
|
246
|
+
return re;
|
|
247
|
+
}
|
|
248
|
+
function window(text, start, end, after) {
|
|
249
|
+
return text.slice(Math.max(0, start - CONTEXT_BEFORE), start) + " " + text.slice(end, end + after);
|
|
250
|
+
}
|
|
251
|
+
/** Runs one recognizer over `text`, applying its validator and context scoring. */
|
|
252
|
+
export function runRecognizer(recognizer, text) {
|
|
253
|
+
const detections = [];
|
|
254
|
+
const pattern = new RegExp(recognizer.pattern.source, recognizer.pattern.flags.includes("d") ? recognizer.pattern.flags : recognizer.pattern.flags + "d");
|
|
255
|
+
for (const match of text.matchAll(pattern)) {
|
|
256
|
+
const group = recognizer.group ?? 0;
|
|
257
|
+
const value = match[group];
|
|
258
|
+
const indices = match.indices?.[group];
|
|
259
|
+
if (!value || !indices)
|
|
260
|
+
continue;
|
|
261
|
+
const base = recognizer.score(value);
|
|
262
|
+
if (base === undefined)
|
|
263
|
+
continue;
|
|
264
|
+
const [start, end] = indices;
|
|
265
|
+
let score = base;
|
|
266
|
+
const around = window(text, start, end, recognizer.contextAfter ?? CONTEXT_AFTER);
|
|
267
|
+
if (recognizer.context && contextRegex(recognizer.context).test(around))
|
|
268
|
+
score += recognizer.contextBoost ?? 0.35;
|
|
269
|
+
if (recognizer.negativeContext && contextRegex(recognizer.negativeContext).test(text.slice(Math.max(0, start - CONTEXT_BEFORE), start)))
|
|
270
|
+
score -= NEGATIVE_PENALTY;
|
|
271
|
+
detections.push({ type: recognizer.type, start, end, score: Math.max(0, Math.min(1, score)) });
|
|
272
|
+
}
|
|
273
|
+
return detections;
|
|
274
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** ISO/IEC 7812-1 Annex B (Luhn mod 10). */
|
|
2
|
+
export declare function luhnValid(digits: string): boolean;
|
|
3
|
+
/** Card brand from the issuer identification number (IIN) ranges published by the networks. Undefined if unknown. */
|
|
4
|
+
export declare function cardBrand(digits: string): string | undefined;
|
|
5
|
+
/** ISO 13616 / ISO 7064 MOD 97-10: country length check plus the mod-97 checksum. */
|
|
6
|
+
export declare function ibanValid(raw: string): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* US SSN structural rules (SSA): area not 000, 666 or 900-999; group not 00; serial not 0000. Also rejects the
|
|
9
|
+
* well-known advertising/test numbers that are frequently used as fake data.
|
|
10
|
+
*/
|
|
11
|
+
export declare function ssnValid(digits: string): boolean;
|
|
12
|
+
/** Verhoeff checksum — the check digit UIDAI uses for Aadhaar numbers. */
|
|
13
|
+
export declare function verhoeffValid(digits: string): boolean;
|
|
14
|
+
/** Aadhaar: 12 digits, first digit 2-9, valid Verhoeff check digit, not a repeated digit. */
|
|
15
|
+
export declare function aadhaarValid(digits: string): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* RFC 5321/5322 structural limits: local part at most 64 octets, address at most 254, no leading, trailing or
|
|
18
|
+
* doubled dots in the local part, each domain label 1-63 chars without edge hyphens, and an alphabetic TLD.
|
|
19
|
+
*/
|
|
20
|
+
export declare function emailValid(candidate: string): boolean;
|
|
21
|
+
export declare function ipVersion(candidate: string): 4 | 6 | 0;
|
|
22
|
+
/** RFC 1918 private, loopback, link-local, CGNAT and unspecified IPv4 ranges — less identifying than public addresses. */
|
|
23
|
+
export declare function isNonPublicIPv4(ip: string): boolean;
|
|
24
|
+
/** Shannon entropy in bits per character. */
|
|
25
|
+
export declare function shannonEntropy(text: string): number;
|
|
26
|
+
/** RFC 7519: a JWT's first segment is base64url JSON with an `alg` member. */
|
|
27
|
+
export declare function jwtValid(candidate: string): boolean;
|
|
28
|
+
/** Parses the common written date shapes; returns undefined for anything that is not a real calendar date. */
|
|
29
|
+
export declare function parseDate(text: string): Date | undefined;
|