pii-mask
v0.1.0
Published
A fast, config-driven Node.js/TypeScript masking adapter that masks PII in structured JSON before it is sent to an LLM.
Maintainers
Readme
pii-mask
A fast, config-driven Node.js/TypeScript adapter that masks PII in structured JSON before it is sent to an LLM, and restores the real values in the LLM's response.
Raw JSON → mask() → Masked JSON → LLM → Masked Response → unmask() → Final ResponseWhat this is — and isn't
pii-mask is a data-minimization masking adapter. It replaces PII values in JSON with reversible tokens before the JSON leaves your process, so an LLM (or any third party) never sees the raw values.
- It is not a KVKK/GDPR (or any other) compliance guarantee. Using this library does not by itself make a system compliant with any privacy law — that depends on your whole system, your legal basis for processing, your retention policy, and more. Treat it as one control among several.
- It is not a PDF/document parser. Input is structured JSON only. If your PII lives in PDFs, scanned images, or free-form documents, extract the JSON first.
- Detectors are deterministic (regex + checksum) — there is no ML/NLP model involved, and free-text detection is limited (see Detectors below).
Quickstart
npm install pii-maskAPI
import { createMasker } from "pii-mask";
import { FileVaultStorage } from "pii-mask/storage";
const masker = createMasker({
storage: new FileVaultStorage({ path: "./vault.enc", secret: process.env.MASKING_SECRET }),
});
const maskResult = await masker.mask(inputJson, {
tenantId: "insurance-company-a",
mode: "reversible", // or "irreversible"
config,
});
const llmResult = await callLLM(maskResult.maskedJson);
const finalResult = await masker.unmask({
sessionId: maskResult.sessionId,
data: llmResult,
});maskResult is { sessionId, maskedJson, report: { maskedFields, warnings } }.
finalResult is { unmaskedJson, warnings }.
CLI
The same flow from the command line, using the files in examples/:
MASKING_SECRET=some-long-random-secret npx pii-mask mask \
examples/insurance-claim.json \
--config examples/mask-config.json \
--mode reversible \
--storage file --vault ./vault.enc \
--out ./claim.masked.json
# claim.masked.json now holds only tokens — send it to the LLM.
# The command also prints { sessionId, report } (counts + warnings) to stdout,
# never the raw input.
# ... call your LLM, save its JSON response as llm-response.json ...
MASKING_SECRET=some-long-random-secret npx pii-mask unmask \
llm-response.json \
--session-id <the sessionId from the mask step> \
--storage file --vault ./vault.enc \
--out ./final-response.jsonFull CLI surface:
pii-mask mask <input.json> --config <mask-config.json> \
--mode reversible|irreversible --storage memory|file \
[--vault <path>] [--tenant <id>] [--ttl <seconds>] --out <output.json>
pii-mask unmask <llm-response.json> --session-id <id> \
--storage file --vault <path> [--tenant <id>] --out <output.json>--storage memory only makes sense with --mode irreversible: each CLI
invocation is a separate process, so an in-memory session cannot be read back
by a later unmask call. Reversible masking always needs --storage file
(or the equivalent FileVaultStorage in code). The secret always comes from
the MASKING_SECRET environment variable — it is never passed as a flag or
logged.
Config reference
{
"fields": {
"customer.name": "NAME",
"customer.tckn": "TCKN",
"customer.phone": "PHONE",
"vehicle.plate": "PLATE",
"payment.iban": "IBAN",
"claim.policyNo": "POLICY_NO",
"documents[].invoiceNo": "INVOICE_NO"
},
"scanTextFields": {
"claim.description": ["NAME", "TCKN", "IBAN", "PHONE", "PLATE"],
"notes[]": ["NAME", "PHONE", "ADDRESS"]
}
}fields— direct masking. The dotted path is looked up and, if present, the whole value is replaced with a token. No regex is used, so this works for any value shape (a number, a UUID, an address — anything).scanTextFields— free-text scanning. The value at the path is scanned with only the detectors listed for that path, and matches are replaced in-place; the surrounding text is preserved byte-for-byte.a.b[].c—[]after a segment means "this segment is an array; apply the rest of the path to each element."notes[](an array of strings) is scanned element-by-element.- A value repeated across the document (e.g. the same TCKN in a direct field
and inside
claim.description) always gets the same token, so the LLM sees one entity per real-world value.
Token format
{{PII:<TYPE>:<ID>}}
e.g. {{PII:NAME:01J4Z7Q9K3D8}} {{PII:TCKN:01J4Z7Q9N6VA}}<ID> is 12 random, non-sequential characters (Crockford Base32, from
crypto.randomBytes) — never a counter, never derived from the value.
Modes
reversible— mappings are persisted (encrypted) sounmask()can restore the real values later, scoped to a session.irreversible— nothing is persisted; the session cannot be unmasked. Useful when you only need one-way redaction.
Storage adapters
InMemoryStorage— process-local, non-persistent. Fine for tests, demos, andirreversiblemode; areversiblesession in memory cannot survive a process restart or be read from another process.FileVaultStorage— a single AES-256-GCM–encrypted file on disk (vault.enc). Key material is derived fromMASKING_SECRETviascrypt; writes are atomic (write-temp-then-rename). See SECURITY.md for its single-process caveat.
Roadmap (not implemented yet, but the StorageAdapter interface is
designed to support them without changing the public API): Postgres, Redis,
MongoDB, AWS KMS, GCP KMS, Azure Key Vault, HashiCorp Vault, and streaming
JSON input (current input is whole-document JSON only).
Detectors
Built-in, deterministic (regex + checksum — no ML/NLP), used by
scanTextFields:
| Type | What it matches | Checksum-validated |
|---------|-------------------------------------------|---------------------|
| TCKN | Turkish national ID (11 digits) | Yes (official algorithm) |
| VKN | Turkish tax ID (10 digits) | Yes |
| IBAN | Turkish IBAN (TR..) | Yes (mod-97) |
| PHONE | Turkish phone numbers (+90/0090/0 prefixes)| Format only |
| PLATE | Turkish vehicle plates | Format only |
A candidate match that fails its checksum is never masked — this avoids false positives from arbitrary 10/11-digit numbers.
Limitation:
NAMEandADDRESShave no built-in text detector in the MVP. There is no reliable regex for free-text name/address detection. Direct field masking (fields) still fully coversNAME/ADDRESSwhen they live at a known JSON path — the gap is only for names/addresses embedded inside free text.scanTextFieldsentries listingNAMEorADDRESSare skipped with a one-time warning (detector not available: NAME (direct field masking still applies)) and reported inreport.warnings. To close this gap, plug in a custom detector viaMaskOptions.detectors(e.g. backed by an NER model) — no library code needs to change.
Security
See SECURITY.md for the full list of guarantees, the threat
model, MASKING_SECRET handling, and responsible disclosure. In short: raw
values are never logged; persisted values are always AES-256-GCM encrypted;
hashes are always HMAC-SHA256(secret, value); sessions are TTL- and
tenant-scoped and fail closed.
License
MIT — see LICENSE.
