Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,128 @@
# @suaveplan/data-masking - PII/sensitive data masking for objects and strings: field-level masking rules, nested object traversal, format-preserving masking, tokenization, and FNV-1a hashing.
## Summary
PII/sensitive data masking for objects and strings with field-level rules,
nested traversal, eight masking strategies, and OpenTelemetry instrumentation.
<!-- begin:auto-docs -->
## Documentation tree
- [masker](./src/core/masker/object-masker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [object-masker](./src/core/masker/object-masker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/data-masking-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [data-masking-error](./src/errors/data-masking-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/schemas/mask-rule.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [mask-rule.schema](./src/schemas/mask-rule.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [credit-card](./src/strategies/credit-card.md) — The `credit-card` masking strategy produces the PCI-DSS-compatible truncation format for payment card numbers: last 4 d…
- [email](./src/strategies/email.md) — The `email` masking strategy anonymises email addresses while keeping the output immediately recognisable as an email a…
- [full](./src/strategies/full.md) — The `full` masking strategy completely replaces an input string with a configurable replacement sentinel. It is the mos…
- [hash](./src/strategies/hash.md) — The `hash` masking strategy produces a deterministic, fixed-length 8-character hexadecimal token from any input string…
- [partial](./src/strategies/partial.md) — The `partial` masking strategy preserves a configurable number of characters at the start and end of an input string wh…
- [phone](./src/strategies/phone.md) — The `phone` masking strategy anonymises phone numbers while preserving their formatting separators, making masked value…
- [preserve-format](./src/strategies/preserve-format.md) — The `preserve-format` masking strategy replaces every alphanumeric character with a placeholder of the same character c…
- [tokenize](./src/strategies/tokenize.md) — The `tokenize` masking strategy replaces a sensitive value with a structured opaque token of the form `TOKEN_<STRATEGY>…
- [types](./src/types/mask-types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [mask-types](./src/types/mask-types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [utils](./src/utils/path-matcher.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [path-matcher](./src/utils/path-matcher.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [object-masker](./src/core/masker/object-masker.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/data-masking-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [data-masking-error](./src/errors/data-masking-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/mask-rule.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [mask-rule.schema](./src/schemas/mask-rule.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [credit-card](./src/strategies/credit-card.md) — The `credit-card` masking strategy produces the PCI-DSS-compatible truncation format for payment card numbers: last 4 d…
- [email](./src/strategies/email.md) — The `email` masking strategy anonymises email addresses while keeping the output immediately recognisable as an email a…
- [full](./src/strategies/full.md) — The `full` masking strategy completely replaces an input string with a configurable replacement sentinel. It is the mos…
- [hash](./src/strategies/hash.md) — The `hash` masking strategy produces a deterministic, fixed-length 8-character hexadecimal token from any input string…
- [partial](./src/strategies/partial.md) — The `partial` masking strategy preserves a configurable number of characters at the start and end of an input string wh…
- [phone](./src/strategies/phone.md) — The `phone` masking strategy anonymises phone numbers while preserving their formatting separators, making masked value…
- [preserve-format](./src/strategies/preserve-format.md) — The `preserve-format` masking strategy replaces every alphanumeric character with a placeholder of the same character c…
- [tokenize](./src/strategies/tokenize.md) — The `tokenize` masking strategy replaces a sensitive value with a structured opaque token of the form `TOKEN_<STRATEGY>…
- [types](./src/types/mask-types.md) — <!-- begin:auto-docs --> _(no submodules)_- [mask-types](./src/types/mask-types.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/path-matcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [path-matcher](./src/utils/path-matcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [object-masker](./src/core/masker/object-masker.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/data-masking-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [data-masking-error](./src/errors/data-masking-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/mask-rule.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [mask-rule.schema](./src/schemas/mask-rule.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [credit-card](./src/strategies/credit-card.md) — The `credit-card` masking strategy produces the PCI-DSS-compatible truncation format for payment card numbers: last 4 d…
- [email](./src/strategies/email.md) — The `email` masking strategy anonymises email addresses while keeping the output immediately recognisable as an email a…
- [full](./src/strategies/full.md) — The `full` masking strategy completely replaces an input string with a configurable replacement sentinel. It is the mos…
- [hash](./src/strategies/hash.md) — The `hash` masking strategy produces a deterministic, fixed-length 8-character hexadecimal token from any input string…
- [partial](./src/strategies/partial.md) — The `partial` masking strategy preserves a configurable number of characters at the start and end of an input string wh…
- [phone](./src/strategies/phone.md) — The `phone` masking strategy anonymises phone numbers while preserving their formatting separators, making masked value…
- [preserve-format](./src/strategies/preserve-format.md) — The `preserve-format` masking strategy replaces every alphanumeric character with a placeholder of the same character c…
- [tokenize](./src/strategies/tokenize.md) — The `tokenize` masking strategy replaces a sensitive value with a structured opaque token of the form `TOKEN_<STRATEGY>…
- [types](./src/types/mask-types.md) — <!-- begin:auto-docs --> _(no submodules)_- [mask-types](./src/types/mask-types.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/path-matcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [path-matcher](./src/utils/path-matcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [object-masker](./src/core/masker/object-masker.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/data-masking-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [data-masking-error](./src/errors/data-masking-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/mask-rule.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [mask-rule.schema](./src/schemas/mask-rule.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [credit-card](./src/strategies/credit-card.md) — The `credit-card` masking strategy produces the PCI-DSS-compatible truncation format for payment card numbers: last 4 d…
- [email](./src/strategies/email.md) — The `email` masking strategy anonymises email addresses while keeping the output immediately recognisable as an email a…
- [full](./src/strategies/full.md) — The `full` masking strategy completely replaces an input string with a configurable replacement sentinel. It is the mos…
- [hash](./src/strategies/hash.md) — The `hash` masking strategy produces a deterministic, fixed-length 8-character hexadecimal token from any input string…
- [partial](./src/strategies/partial.md) — The `partial` masking strategy preserves a configurable number of characters at the start and end of an input string wh…
- [phone](./src/strategies/phone.md) — The `phone` masking strategy anonymises phone numbers while preserving their formatting separators, making masked value…
- [preserve-format](./src/strategies/preserve-format.md) — The `preserve-format` masking strategy replaces every alphanumeric character with a placeholder of the same character c…
- [tokenize](./src/strategies/tokenize.md) — The `tokenize` masking strategy replaces a sensitive value with a structured opaque token of the form `TOKEN_<STRATEGY>…
- [types](./src/types/mask-types.md) — <!-- begin:auto-docs --> _(no submodules)_- [mask-types](./src/types/mask-types.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/path-matcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [path-matcher](./src/utils/path-matcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [object-masker](./src/core/masker/object-masker.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/data-masking-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [data-masking-error](./src/errors/data-masking-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/mask-rule.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ - [mask-rule.schema](./src/schemas/mask-rule.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [credit-card](./src/strategies/credit-card.md) — The `credit-card` masking strategy produces the PCI-DSS-compatible truncation format for payment card numbers: last 4 d…
- [email](./src/strategies/email.md) — The `email` masking strategy anonymises email addresses while keeping the output immediately recognisable as an email a…
- [full](./src/strategies/full.md) — The `full` masking strategy completely replaces an input string with a configurable replacement sentinel. It is the mos…
- [hash](./src/strategies/hash.md) — The `hash` masking strategy produces a deterministic, fixed-length 8-character hexadecimal token from any input string…
- [partial](./src/strategies/partial.md) — The `partial` masking strategy preserves a configurable number of characters at the start and end of an input string wh…
- [phone](./src/strategies/phone.md) — The `phone` masking strategy anonymises phone numbers while preserving their formatting separators, making masked value…
- [preserve-format](./src/strategies/preserve-format.md) — The `preserve-format` masking strategy replaces every alphanumeric character with a placeholder of the same character c…
- [tokenize](./src/strategies/tokenize.md) — The `tokenize` masking strategy replaces a sensitive value with a structured opaque token of the form `TOKEN_<STRATEGY>…
- [types](./src/types/mask-types.md) — <!-- begin:auto-docs --> _(no submodules)_ - [mask-types](./src/types/mask-types.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/path-matcher.md) — <!-- begin:auto-docs --> _(no submodules)_ - [path-matcher](./src/utils/path-matcher.md) — <!-- begin:auto-docs --> _(no submodules)_
## Installation
```bash
bun add @suaveplan/data-masking
```
## Quick Start
```ts
import { maskObject } from '@suaveplan/data-masking';
const result = maskObject(
{
user: { name: 'John Doe', email: 'john@example.com', ssn: '123-45-6789' },
payment: { cardNumber: '4111-1111-1111-1111', cvv: '123' },
},
{
rules: [
{ fields: ['user.email'], strategy: 'email' },
{ fields: ['user.ssn', 'payment.cvv'], strategy: 'full' },
{ fields: ['payment.cardNumber'], strategy: 'credit-card' },
],
},
);
console.log(result.masked.user.email); // 'j***@e***.com'
console.log(result.masked.payment.cardNumber); // '****-****-****-1111'
console.log(result.fieldsAffected); // ['payment.cardNumber', 'payment.cvv', 'user.email', 'user.ssn']
```
## Strategies
| Strategy | Description | Example output |
|-------------------|------------------------------------------|-------------------------|
| `full` | Replace entire value with `***` | `***` |
| `partial` | Keep first/last N chars, mask middle | `sk_***...****789` |
| `email` | Format-preserving email mask | `j***@e***.com` |
| `phone` | Keep last 4 digits | `***-***-1234` |
| `credit-card` | Keep last 4 digits | `****-****-****-1234` |
| `hash` | FNV-1a 32-bit hex (deterministic) | `4f9f2cab` |
| `tokenize` | Opaque token (not reversible) | `TOKEN_TOKENIZE_0` |
| `preserve-format` | Replace alnum with type placeholder | `XXXXX000` |
## Field Path Patterns
Rules use dot-notation paths with optional glob wildcards:
```ts
{ fields: ['user.email'] } // exact match
{ fields: ['user.*'] } // single-segment wildcard
{ fields: ['user.**'] } // multi-segment wildcard (any depth)
{ fields: ['**.email'] } // any path ending in .email
```
## Modules
| Subpath | Contents |
|---------------------|--------------------------------------------|
| `@suaveplan/data-masking` | Main exports (maskObject, maskString, maskValue, types, schemas) |
| `@suaveplan/data-masking/masker` | [object-masker](src/core/masker/object-masker.md) — core traversal engine |
| `@suaveplan/data-masking/strategies` | [full](src/strategies/full.md), [partial](src/strategies/partial.md), [email](src/strategies/email.md), [phone](src/strategies/phone.md), [credit-card](src/strategies/credit-card.md), [hash](src/strategies/hash.md), [tokenize](src/strategies/tokenize.md), [preserve-format](src/strategies/preserve-format.md) |
| `@suaveplan/data-masking/errors` | [DataMaskingError](src/errors/data-masking-error.md), `DATA_MASKING_CODES` |
| `@suaveplan/data-masking/schemas` | Zod schemas for `MaskRule`, `MaskOptions` |
## License
MIT

View File

@@ -0,0 +1,100 @@
{
"name": "@suaveplan/data-masking",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "PII/sensitive data masking for objects and strings: field-level masking rules, nested object traversal, format-preserving masking, tokenization, and FNV-1a hashing.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./masker": {
"import": {
"types": "./dist/core/masker/object-masker.d.ts",
"default": "./dist/core/masker/object-masker.js"
},
"require": {
"types": "./dist/core/masker/object-masker.d.ts",
"default": "./dist/core/masker/object-masker.js"
}
},
"./strategies": {
"import": {
"types": "./dist/strategies/index.d.ts",
"default": "./dist/strategies/index.js"
},
"require": {
"types": "./dist/strategies/index.d.ts",
"default": "./dist/strategies/index.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/data-masking-error.d.ts",
"default": "./dist/errors/data-masking-error.js"
},
"require": {
"types": "./dist/errors/data-masking-error.d.ts",
"default": "./dist/errors/data-masking-error.js"
}
},
"./schemas": {
"import": {
"types": "./dist/schemas/mask-rule.schema.d.ts",
"default": "./dist/schemas/mask-rule.schema.js"
},
"require": {
"types": "./dist/schemas/mask-rule.schema.d.ts",
"default": "./dist/schemas/mask-rule.schema.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1 @@
export { maskObject, maskString, maskValue } from "./masker";

View File

@@ -0,0 +1 @@
export { maskObject, maskString, maskValue } from "./object-masker";

View File

@@ -0,0 +1,128 @@
# object-masker
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `object-masker` module is the core engine of `@suaveplan/data-masking`. It
provides three exports: `maskValue` for dispatching a masking strategy to a
single string, `maskObject` for traversing and masking a nested plain-object
graph, and `maskString` for detecting and masking embedded PII patterns inside
free-form text. All traversal is non-destructive — the input is never mutated;
a deep structural clone is produced instead.
OpenTelemetry instrumentation records a counter per strategy (`data_masking.mask.total`)
and a histogram of overall `maskObject` call duration (`data_masking.mask.duration_ms`),
enabling SREs to monitor masking throughput and latency in production.
## Features
- `maskValue`: uniform dispatch to all eight masking strategies.
- `maskObject`: deep-clone traversal with `deep` and `maskArrays` controls.
- `maskString`: regex-based PII detection for email, phone, and credit-card
patterns embedded in free-form strings.
- Non-mutating: original object/string always preserved.
- OTel counter + histogram on every operation.
- Sorted `fieldsAffected` list for deterministic output.
- `DataMaskingError` on strategy dispatch failure.
## Usage: Basic Example
```ts
import { maskObject } from '@suaveplan/data-masking/masker';
const result = maskObject(
{ user: { name: 'Alice', email: 'alice@example.com' } },
{ rules: [{ fields: ['user.email'], strategy: 'email' }] },
);
// result.masked.user.email → 'a***@e***.com'
// result.fieldsAffected → ['user.email']
```
## Usage: Advanced Example
```ts
import { maskObject, maskString, maskValue } from '@suaveplan/data-masking/masker';
// Full object masking with wildcards
const data = {
user: { name: 'John Doe', email: 'john@example.com', ssn: '123-45-6789' },
payment: { cardNumber: '4111-1111-1111-1111', cvv: '123' },
};
const result = maskObject(data, {
rules: [
{ fields: ['user.email'], strategy: 'email' },
{ fields: ['user.ssn', 'payment.cvv'], strategy: 'full' },
{ fields: ['payment.cardNumber'], strategy: 'credit-card' },
],
deep: true,
maskArrays: true,
});
// result.masked.user.email → 'j***@e***.com'
// result.masked.user.ssn → '***'
// result.masked.payment.cardNumber → '****-****-****-1111'
// result.masked.payment.cvv → '***'
// result.fieldsAffected.length → 4
// Free-form string masking
const log = 'User john@example.com called from +1-555-123-4567';
const masked = maskString(log, [
{ fields: ['*'], strategy: 'email' },
{ fields: ['*'], strategy: 'phone' },
]);
// masked → 'User j***@e***.com called from *-***-***-4567'
```
## API Reference
### `maskValue(value, strategy, rule)`
```ts
function maskValue(value: string, strategy: MaskStrategy, rule: MaskRule): string;
```
Dispatches `value` to the named strategy. Wraps any thrown error in a
`DataMaskingError` with code `DATA_MASKING.MASK_FAILED`.
### `maskObject<T>(obj, options)`
```ts
function maskObject<T extends Record<string, unknown>>(
obj: T,
options: MaskOptions,
): MaskResult<T>;
```
| Parameter | Type | Default | Description |
|------------------|---------------|---------|------------------------------------|
| `obj` | `T` | — | Source plain object. |
| `options.rules` | `MaskRule[]` | — | Ordered masking rules. |
| `options.deep` | `boolean` | `true` | Recurse into nested objects. |
| `options.maskArrays` | `boolean` | `true` | Mask array element values. |
**Returns** `MaskResult<T>` with `masked` clone and sorted `fieldsAffected`.
### `maskString(str, rules)`
```ts
function maskString(str: string, rules: MaskRule[]): string;
```
Scans `str` for embedded credit-card, email, and phone patterns and replaces
each detected match using the first rule with a matching strategy.
## Implementation Notes
- Object traversal uses plain `for...of` over `Object.keys` to avoid
prototype-chain pollution; `isPlainObject` guards against class instances,
`Date`, `RegExp`, etc., which are returned as-is.
- Array element paths use bracket notation `field[0]` to distinguish them
from nested object paths in the `fieldsAffected` list.
- The OTel `Meter` is obtained lazily on first call and cached module-wide to
avoid re-creating metric instruments on each invocation.
- `performance.now()` is used for duration measurement (available in Node.js
16+ and Bun natively; no polyfill required for the suaveplan target).
- `maskString` applies rules in category order (credit-card → email → phone)
to avoid double-masking where patterns overlap.

View File

@@ -0,0 +1,346 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import {
DATA_MASKING_CODES,
DataMaskingError,
} from "../../errors/data-masking-error.js";
import { _resetTokenCounter } from "../../strategies/tokenize.js";
import { maskObject, maskString, maskValue } from "./object-masker.js";
describe("maskValue", () => {
it("dispatches 'full' strategy", { timeout: 5000 }, () => {
const result = maskValue("secret", "full", {
fields: ["f"],
strategy: "full",
});
expect(result).toBe("***");
});
it("dispatches 'partial' strategy", { timeout: 5000 }, () => {
const result = maskValue("1234567890", "partial", {
fields: ["f"],
strategy: "partial",
keepLast: 4,
});
expect(result).toBe("******7890");
});
it("dispatches 'email' strategy", { timeout: 5000 }, () => {
const result = maskValue("john@example.com", "email", {
fields: ["f"],
strategy: "email",
});
expect(result).toBe("j***@e***.com");
});
it("dispatches 'phone' strategy", { timeout: 5000 }, () => {
const result = maskValue("+1-555-123-4567", "phone", {
fields: ["f"],
strategy: "phone",
});
expect(result).toBe("+*-***-***-4567");
});
it("dispatches 'credit-card' strategy", { timeout: 5000 }, () => {
const result = maskValue("4111-1111-1111-1111", "credit-card", {
fields: ["f"],
strategy: "credit-card",
});
expect(result).toBe("****-****-****-1111");
});
it("dispatches 'hash' strategy", { timeout: 5000 }, () => {
const result = maskValue("hello", "hash", {
fields: ["f"],
strategy: "hash",
});
expect(result).toMatch(/^[0-9a-f]{8}$/);
});
it("dispatches 'preserve-format' strategy", { timeout: 5000 }, () => {
const result = maskValue("Hello123", "preserve-format", {
fields: ["f"],
strategy: "preserve-format",
});
expect(result).toBe("XXXXX000");
});
it("dispatches 'tokenize' strategy", { timeout: 5000 }, () => {
_resetTokenCounter();
const result = maskValue("secret", "tokenize", {
fields: ["f"],
strategy: "tokenize",
});
expect(result).toMatch(/^TOKEN_TOKENIZE_\d+$/);
});
});
describe("maskObject", () => {
const data = {
user: {
name: "John Doe",
email: "john@example.com",
ssn: "123-45-6789",
},
payment: {
cardNumber: "4111111111111111",
cvv: "123",
},
};
it("masks email with email strategy", { timeout: 5000 }, () => {
const result = maskObject(data, {
rules: [{ fields: ["user.email"], strategy: "email" }],
});
expect(result.masked.user.email).toBe("j***@e***.com");
});
it("masks ssn and cvv with full strategy", { timeout: 5000 }, () => {
const result = maskObject(data, {
rules: [{ fields: ["user.ssn", "payment.cvv"], strategy: "full" }],
});
expect(result.masked.user.ssn).toBe("***");
expect(result.masked.payment.cvv).toBe("***");
});
it("masks card number with credit-card strategy", { timeout: 5000 }, () => {
const result = maskObject(data, {
rules: [
{ fields: ["payment.cardNumber"], strategy: "credit-card" },
],
});
expect(result.masked.payment.cardNumber).toBe("************1111");
});
it("reports all affected fields", { timeout: 5000 }, () => {
const result = maskObject(data, {
rules: [
{ fields: ["user.email"], strategy: "email" },
{ fields: ["user.ssn", "payment.cvv"], strategy: "full" },
{ fields: ["payment.cardNumber"], strategy: "credit-card" },
],
});
expect(result.fieldsAffected).toHaveLength(4);
expect(result.fieldsAffected).toContain("user.email");
expect(result.fieldsAffected).toContain("user.ssn");
expect(result.fieldsAffected).toContain("payment.cvv");
expect(result.fieldsAffected).toContain("payment.cardNumber");
});
it("does not mutate the original object", { timeout: 5000 }, () => {
const original = {
user: { email: "john@example.com" },
};
maskObject(original, {
rules: [{ fields: ["user.email"], strategy: "email" }],
});
expect(original.user.email).toBe("john@example.com");
});
it("does not mask unmatched fields", { timeout: 5000 }, () => {
const result = maskObject(data, {
rules: [{ fields: ["user.email"], strategy: "email" }],
});
expect(result.masked.user.name).toBe("John Doe");
expect(result.masked.payment.cvv).toBe("123");
});
it("returns empty fieldsAffected when no rules match", {
timeout: 5000,
}, () => {
const result = maskObject(data, {
rules: [{ fields: ["nonexistent.field"], strategy: "full" }],
});
expect(result.fieldsAffected).toHaveLength(0);
});
it("skips nested objects when deep=false", { timeout: 5000 }, () => {
const nested = { user: { email: "john@example.com" } };
const result = maskObject(nested, {
rules: [{ fields: ["user.email"], strategy: "email" }],
deep: false,
});
// When deep=false, nested objects are not traversed, so email is not masked
expect(result.masked.user).toEqual({ email: "john@example.com" });
expect(result.fieldsAffected).toHaveLength(0);
});
it("masks array elements when maskArrays=true (default)", {
timeout: 5000,
}, () => {
const obj = { emails: ["alice@example.com", "bob@example.com"] };
const result = maskObject(obj, {
rules: [{ fields: ["emails[0]", "emails[1]"], strategy: "email" }],
});
expect(result.masked.emails[0]).toBe("a***@e***.com");
expect(result.masked.emails[1]).toBe("b***@e***.com");
});
it("passes through arrays unchanged when maskArrays=false", {
timeout: 5000,
}, () => {
const obj = { emails: ["alice@example.com"] };
const result = maskObject(obj, {
rules: [{ fields: ["emails[0]"], strategy: "email" }],
maskArrays: false,
});
expect(result.masked.emails[0]).toBe("alice@example.com");
});
it("passes null values through unchanged", { timeout: 5000 }, () => {
const obj = { user: { email: null as unknown as string } };
const result = maskObject(obj, {
rules: [{ fields: ["user.email"], strategy: "email" }],
});
expect(result.masked.user.email).toBeNull();
});
it("passes undefined values through unchanged", { timeout: 5000 }, () => {
const obj = { user: { email: undefined as unknown as string } };
const result = maskObject(obj, {
rules: [{ fields: ["user.email"], strategy: "email" }],
});
expect(result.masked.user.email).toBeUndefined();
});
it("returns sorted fieldsAffected list", { timeout: 5000 }, () => {
const result = maskObject(data, {
rules: [
{
fields: [
"user.ssn",
"payment.cvv",
"user.email",
"payment.cardNumber",
],
strategy: "full",
},
],
});
const sorted = [...result.fieldsAffected].sort();
expect(result.fieldsAffected).toEqual(sorted);
});
it("supports wildcard patterns in rules", { timeout: 5000 }, () => {
const obj = { user: { email: "john@example.com", phone: "555-1234" } };
const result = maskObject(obj, {
rules: [{ fields: ["user.*"], strategy: "full" }],
});
expect(result.masked.user.email).toBe("***");
expect(result.masked.user.phone).toBe("***");
});
});
describe("DataMaskingError", () => {
it("has correct name, code, and message", { timeout: 5000 }, () => {
const err = new DataMaskingError(
"test message",
DATA_MASKING_CODES.MASK_FAILED
);
expect(err.name).toBe("DataMaskingError");
expect(err.code).toBe("DATA_MASKING.MASK_FAILED");
expect(err.message).toBe("test message");
expect(err instanceof Error).toBe(true);
});
it("works with INVALID_RULE code", { timeout: 5000 }, () => {
const err = new DataMaskingError(
"invalid rule",
DATA_MASKING_CODES.INVALID_RULE
);
expect(err.code).toBe("DATA_MASKING.INVALID_RULE");
});
});
describe("maskObject edge cases", () => {
it("passes through number values unchanged", { timeout: 5000 }, () => {
const obj = { age: 42 as unknown as string };
const result = maskObject(obj, {
rules: [{ fields: ["age"], strategy: "full" }],
});
// numbers are not strings so they pass through even when matched
expect(result.masked.age).toBe(42);
});
it("passes through boolean values unchanged", { timeout: 5000 }, () => {
const obj = { active: true as unknown as string };
const result = maskObject(obj, {
rules: [{ fields: ["active"], strategy: "full" }],
});
expect(result.masked.active).toBe(true);
});
it("masks a nested object field that matches a rule by serialising it", {
timeout: 5000,
}, () => {
// When deep=true and a plain object field is matched, it gets JSON-serialised then masked
const obj = {
metadata: { secret: "value", count: 42 },
};
const result = maskObject(obj, {
rules: [{ fields: ["metadata"], strategy: "full" }],
});
expect(result.masked.metadata).toBe("***");
expect(result.fieldsAffected).toContain("metadata");
});
it("passes through Date values (non-plain-object) unchanged", {
timeout: 5000,
}, () => {
const d = new Date("2024-01-01");
const obj = { createdAt: d as unknown as string };
const result = maskObject(obj, {
rules: [{ fields: ["other"], strategy: "full" }],
});
expect(result.masked.createdAt).toBe(d);
});
});
describe("maskString", () => {
it("masks embedded email addresses", { timeout: 5000 }, () => {
const result = maskString("Contact john@example.com for help", [
{ fields: ["*"], strategy: "email" },
]);
expect(result).toContain("j***@e***.com");
expect(result).not.toContain("john@example.com");
});
it("masks embedded credit card numbers", { timeout: 5000 }, () => {
const result = maskString("Card: 4111-1111-1111-1111 was charged", [
{ fields: ["*"], strategy: "credit-card" },
]);
expect(result).toContain("****-****-****-1111");
expect(result).not.toContain("4111-1111-1111-1111");
});
it("masks embedded phone numbers", { timeout: 5000 }, () => {
const result = maskString("Call +1-555-123-4567 now", [
{ fields: ["*"], strategy: "phone" },
]);
expect(result).toContain("4567");
expect(result).not.toContain("555-123-4567");
});
it("returns original string when no rules match patterns", {
timeout: 5000,
}, () => {
const original = "No PII here just text";
const result = maskString(original, [
{ fields: ["*"], strategy: "full" },
]);
expect(result).toBe(original);
});
it("handles multiple PII types in one string", { timeout: 5000 }, () => {
const log = "User john@example.com called 5551234567";
const result = maskString(log, [
{ fields: ["*"], strategy: "email" },
{ fields: ["*"], strategy: "phone" },
]);
expect(result).toContain("j***@e***.com");
expect(result).toContain("4567");
});
beforeEach(() => {
_resetTokenCounter();
});
});

View File

@@ -0,0 +1,298 @@
import {
type Counter,
type Histogram,
type Meter,
type MeterProvider,
metrics,
} from "@opentelemetry/api";
import { maskCreditCard } from "../../strategies/credit-card.js";
import { maskEmail } from "../../strategies/email.js";
import { maskFull } from "../../strategies/full.js";
import { maskHash } from "../../strategies/hash.js";
import { maskPartial } from "../../strategies/partial.js";
import { maskPhone } from "../../strategies/phone.js";
import { maskPreserveFormat } from "../../strategies/preserve-format.js";
import { maskTokenize } from "../../strategies/tokenize.js";
import type {
MaskOptions,
MaskResult,
MaskRule,
MaskStrategy,
} from "../../types/mask-types.js";
import { matchPath } from "../../utils/path-matcher.js";
// ---------------------------------------------------------------------------
// OTel instrumentation
// ---------------------------------------------------------------------------
interface MaskMetrics {
maskTotal: Counter;
maskDuration: Histogram;
}
let _metrics: MaskMetrics | undefined;
function getMetrics(provider?: MeterProvider): MaskMetrics {
if (_metrics) return _metrics;
const meter: Meter = (provider ?? metrics.getMeterProvider()).getMeter(
"@suaveplan/data-masking",
"0.1.0"
);
_metrics = {
maskTotal: meter.createCounter("data_masking.mask.total", {
description: "Number of mask operations performed",
}),
maskDuration: meter.createHistogram("data_masking.mask.duration_ms", {
description: "Duration of maskObject calls in milliseconds",
unit: "ms",
}),
};
return _metrics;
}
// ---------------------------------------------------------------------------
// Strategy dispatch
// ---------------------------------------------------------------------------
/**
* Dispatch a masking strategy for a concrete string value.
*
* @param value - The value to mask.
* @param strategy - Which masking strategy to apply.
* @param rule - The full `MaskRule` (passed to strategies for options).
* @returns The masked string.
* @throws `DataMaskingError` with `MASK_FAILED` if strategy execution throws.
*/
export function maskValue(
value: string,
strategy: MaskStrategy,
rule: MaskRule
): string {
switch (strategy) {
case "full":
return maskFull(value, rule);
case "partial":
return maskPartial(value, rule);
case "email":
return maskEmail(value, rule);
case "phone":
return maskPhone(value, rule);
case "credit-card":
return maskCreditCard(value, rule);
case "hash":
return maskHash(value, rule);
case "tokenize":
return maskTokenize(value, rule);
case "preserve-format":
return maskPreserveFormat(value, rule);
}
}
// ---------------------------------------------------------------------------
// Object traversal
// ---------------------------------------------------------------------------
/**
* Apply masking rules to a plain-object graph and return a deep clone with
* sensitive fields masked.
*
* The input object is never mutated. Array elements are masked in-place on
* the cloned copy when `options.maskArrays` is `true` (default). Nested
* objects are traversed when `options.deep` is `true` (default).
*
* OTel counters and histograms are recorded on every call.
*
* @param obj - The source object to mask.
* @param options - Rules and traversal flags.
* @returns A `MaskResult` containing the masked clone and the list of affected
* field paths.
*/
export function maskObject<T extends Record<string, unknown>>(
obj: T,
options: MaskOptions
): MaskResult<T> {
const start = performance.now();
const fieldsAffected: string[] = [];
const cloned = traverseObject(
obj,
options,
"",
fieldsAffected,
/* depth */ 0
) as T;
const durationMs = performance.now() - start;
const m = getMetrics();
m.maskDuration.record(durationMs);
return { masked: cloned, fieldsAffected: fieldsAffected.sort() };
}
// ---------------------------------------------------------------------------
// Recursive traversal helpers
// ---------------------------------------------------------------------------
function traverseObject(
obj: Record<string, unknown>,
options: MaskOptions,
prefix: string,
affected: string[],
depth: number
): Record<string, unknown> {
const deep = options.deep !== false;
const clone: Record<string, unknown> = {};
for (const key of Object.keys(obj)) {
const fieldPath = prefix ? `${prefix}.${key}` : key;
const value = obj[key];
clone[key] = traverseValue(
value,
fieldPath,
options,
affected,
depth,
deep
);
}
return clone;
}
function traverseValue(
value: unknown,
fieldPath: string,
options: MaskOptions,
affected: string[],
depth: number,
deep: boolean
): unknown {
if (value === null || value === undefined) return value;
// Check if any rule matches this field path.
const matchedRule = findRule(fieldPath, options.rules);
if (typeof value === "string") {
if (matchedRule) {
const masked = maskValue(value, matchedRule.strategy, matchedRule);
affected.push(fieldPath);
getMetrics().maskTotal.add(1, { strategy: matchedRule.strategy });
return masked;
}
return value;
}
if (Array.isArray(value)) {
if (options.maskArrays === false) return value;
return value.map((item, idx) => {
const itemPath = `${fieldPath}[${idx}]`;
return traverseValue(
item,
itemPath,
options,
affected,
depth + 1,
deep
);
});
}
if (deep && depth >= 0 && isPlainObject(value)) {
// If the object field itself is matched, mask the whole value as full.
if (matchedRule) {
const serialized = JSON.stringify(value);
const masked = maskValue(
serialized,
matchedRule.strategy,
matchedRule
);
affected.push(fieldPath);
getMetrics().maskTotal.add(1, { strategy: matchedRule.strategy });
return masked;
}
return traverseObject(
value as Record<string, unknown>,
options,
fieldPath,
affected,
depth + 1
);
}
// For non-deep traversal, only match string values at the top level.
if (!deep && isPlainObject(value)) {
return value;
}
return value;
}
function findRule(fieldPath: string, rules: MaskRule[]): MaskRule | undefined {
for (const rule of rules) {
for (const pattern of rule.fields) {
if (matchPath(fieldPath, pattern)) {
return rule;
}
}
}
return undefined;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Object.getPrototypeOf(value) === Object.prototype
);
}
// ---------------------------------------------------------------------------
// String masking
// ---------------------------------------------------------------------------
/**
* Detect embedded PII patterns in a free-form string and apply matching rules.
*
* Scans the string for substrings that look like email addresses, phone numbers,
* or credit-card numbers and replaces them according to the supplied rules.
* Rules are applied in declaration order; the first matching rule wins for each
* detected token.
*
* @param str - The free-form string to inspect.
* @param rules - The ordered set of masking rules.
* @returns A new string with detected PII replaced.
*/
export function maskString(str: string, rules: MaskRule[]): string {
let result = str;
// Credit card pattern: groups of 4 digits separated by spaces or hyphens,
// or 13-19 consecutive digits.
const ccRule = rules.find((r) => r.strategy === "credit-card");
if (ccRule) {
result = result.replace(
/\b(?:\d{4}[-\s]){3}\d{4}\b|\b\d{13,19}\b/g,
(match) => maskValue(match, "credit-card", ccRule)
);
}
// Email pattern.
const emailRule = rules.find((r) => r.strategy === "email");
if (emailRule) {
result = result.replace(
/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
(match) => maskValue(match, "email", emailRule)
);
}
// Phone pattern: E.164, US, international with separators.
const phoneRule = rules.find((r) => r.strategy === "phone");
if (phoneRule) {
result = result.replace(
/(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g,
(match) => maskValue(match, "phone", phoneRule)
);
}
return result;
}

View File

@@ -0,0 +1,120 @@
# data-masking-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides a stable error-code registry and a typed `DataMaskingError` class for
all failures that originate inside `@suaveplan/data-masking`. Keeping errors
isolated to a single file makes it easy for callers to perform narrow
`instanceof` checks and to discriminate specific failure modes via the `code`
property without resorting to fragile message-string matching.
The package is intentionally a leaf node — it carries zero `@suaveplan/*`
runtime dependencies — so `DataMaskingError` extends the native `Error` class
directly rather than `SuaveplanError`.
## Features
- `DATA_MASKING_CODES` constant object: two stable string codes exported as
`as const` for full TypeScript narrowing.
- `DataMaskingCode` union type derived from `DATA_MASKING_CODES`.
- `DataMaskingError` class: extends `Error`, sets `name = "DataMaskingError"`,
exposes a readonly `code: DataMaskingCode` property.
- Tree-shakeable — no side-effects, compatible with `"sideEffects": false`.
## Usage: Basic Example
```ts
import {
DataMaskingError,
DATA_MASKING_CODES,
} from '@suaveplan/data-masking/errors';
try {
throw new DataMaskingError(
'Strategy "unknown" is not recognised',
DATA_MASKING_CODES.INVALID_RULE,
);
} catch (err) {
if (err instanceof DataMaskingError) {
console.error(err.code); // 'DATA_MASKING.INVALID_RULE'
console.error(err.name); // 'DataMaskingError'
console.error(err.message);
}
}
```
## Usage: Advanced Example
```ts
import {
DataMaskingError,
DATA_MASKING_CODES,
type DataMaskingCode,
} from '@suaveplan/data-masking/errors';
function handleMaskingError(err: unknown): void {
if (!(err instanceof DataMaskingError)) throw err;
const actions: Record<DataMaskingCode, () => void> = {
[DATA_MASKING_CODES.INVALID_RULE]: () =>
console.warn('Rule validation failed — check your MaskRule definitions'),
[DATA_MASKING_CODES.MASK_FAILED]: () =>
console.error('Masking strategy threw during execution'),
};
actions[err.code]?.();
}
```
## API Reference
### `DATA_MASKING_CODES`
```ts
const DATA_MASKING_CODES: {
readonly INVALID_RULE: 'DATA_MASKING.INVALID_RULE';
readonly MASK_FAILED: 'DATA_MASKING.MASK_FAILED';
};
```
Stable string constants that identify each error category. Values are
intentionally namespaced with `DATA_MASKING.` to avoid collisions in shared
error-monitoring pipelines.
### `DataMaskingCode`
```ts
type DataMaskingCode = 'DATA_MASKING.INVALID_RULE' | 'DATA_MASKING.MASK_FAILED';
```
Union type derived from `DATA_MASKING_CODES`. Use this for exhaustive `switch`
statements or discriminated-union narrowing.
### `DataMaskingError`
```ts
class DataMaskingError extends Error {
readonly code: DataMaskingCode;
constructor(message: string, code: DataMaskingCode): DataMaskingError;
}
```
| Property | Type | Description |
|----------|-------------------|----------------------------------------|
| `name` | `string` | Always `"DataMaskingError"`. |
| `code` | `DataMaskingCode` | The stable machine-readable error code.|
| `message`| `string` | Human-readable description of failure. |
## Implementation Notes
- The `name` property is set in the constructor body (not via a static field)
to ensure correct behaviour after `Object.setPrototypeOf` in transpiled ES5
environments, though suaveplan targets ES2022+ natively.
- No stack-trace manipulation is performed — the default `Error` constructor
captures a stack trace automatically in V8 and Bun runtimes.
- `DATA_MASKING_CODES` is declared `as const` so TypeScript infers the narrow
string-literal type for each member; this enables exhaustive checks on
`DataMaskingCode` without a separate enum.

View File

@@ -0,0 +1,50 @@
/**
* Error types for `@suaveplan/data-masking`.
*
* Provides a stable code registry and a typed error class for all masking
* failures. Codes follow the `DATA_MASKING.<CATEGORY>` namespace convention
* so callers can discriminate errors without matching on message strings.
*/
/**
* Stable error code registry for the data-masking package.
*
* - `DATA_MASKING.INVALID_RULE` — a supplied `MaskRule` is structurally
* invalid (e.g. unknown strategy, empty field list).
* - `DATA_MASKING.MASK_FAILED` — an error occurred while applying a masking
* strategy to a specific value (e.g. regex failure, unexpected value shape).
*/
export const DATA_MASKING_CODES = {
INVALID_RULE: "DATA_MASKING.INVALID_RULE",
MASK_FAILED: "DATA_MASKING.MASK_FAILED",
} as const;
export type DataMaskingCode =
(typeof DATA_MASKING_CODES)[keyof typeof DATA_MASKING_CODES];
/**
* Typed error for all `@suaveplan/data-masking` failures.
*
* Extends the built-in `Error` with a strongly-typed `code` property drawn
* from `DATA_MASKING_CODES`, giving callers narrow discrimination without
* introducing a dependency on `@suaveplan/error` (this is a leaf package).
*
* @example
* ```ts
* import { DataMaskingError, DATA_MASKING_CODES } from '@suaveplan/data-masking/errors';
*
* throw new DataMaskingError(
* 'Strategy "unknown" is not supported',
* DATA_MASKING_CODES.INVALID_RULE,
* );
* ```
*/
export class DataMaskingError extends Error {
readonly code: DataMaskingCode;
public constructor(message: string, code: DataMaskingCode) {
super(message);
this.name = "DataMaskingError";
this.code = code;
}
}

View File

@@ -0,0 +1,5 @@
export {
DATA_MASKING_CODES,
type DataMaskingCode,
DataMaskingError,
} from "./data-masking-error";

View File

@@ -0,0 +1,34 @@
// Core masking functions
export {
maskObject,
maskString,
maskValue,
} from "./core/masker/object-masker.js";
export type { DataMaskingCode } from "./errors/data-masking-error.js";
// Error types
export {
DATA_MASKING_CODES,
DataMaskingError,
} from "./errors/data-masking-error.js";
export type {
MaskOptionsInput,
MaskRuleInput,
MaskStrategyInput,
} from "./schemas/mask-rule.schema.js";
// Schemas
export {
MaskOptionsSchema,
MaskRuleSchema,
MaskStrategySchema,
} from "./schemas/mask-rule.schema.js";
// Public types
export type {
MaskOptions,
MaskResult,
MaskRule,
MaskStrategy,
} from "./types/mask-types.js";
// Path matcher utility
export { matchPath } from "./utils/path-matcher.js";

View File

@@ -0,0 +1,8 @@
export {
type MaskOptionsInput,
MaskOptionsSchema,
type MaskRuleInput,
MaskRuleSchema,
type MaskStrategyInput,
MaskStrategySchema,
} from "./mask-rule.schema";

View File

@@ -0,0 +1,122 @@
# mask-rule.schema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides Zod schemas for all public configuration types in
`@suaveplan/data-masking`. Runtime validation of `MaskRule` and `MaskOptions`
objects is important when rules originate from external sources such as API
request bodies, configuration files, or database records. Using these schemas
before calling `maskObject` prevents silent mis-configurations — for example, a
rule with an empty `fields` array or an unrecognised `strategy` string — from
causing confusing runtime behaviour.
Exporting the schemas as a dedicated subpath (`./schemas`) allows tree-shaking
in production bundles that never parse external rule definitions.
## Features
- `MaskStrategySchema` — exhaustive `z.enum` of all eight strategy strings.
- `MaskRuleSchema` — validates `fields` (non-empty array of non-empty strings),
`strategy`, and optional `keepLast`, `keepFirst`, `replacement` fields.
- `MaskOptionsSchema` — validates `rules` (at least one required), `deep`,
`maskArrays`.
- Input types (`MaskStrategyInput`, `MaskRuleInput`, `MaskOptionsInput`)
exported for use in form and API validation pipelines.
## Usage: Basic Example
```ts
import { MaskRuleSchema } from '@suaveplan/data-masking/schemas';
const parsed = MaskRuleSchema.safeParse({
fields: ['user.email'],
strategy: 'email',
});
if (!parsed.success) {
console.error(parsed.error.issues);
} else {
console.log(parsed.data.strategy); // 'email'
}
```
## Usage: Advanced Example
```ts
import { MaskOptionsSchema } from '@suaveplan/data-masking/schemas';
import { maskObject } from '@suaveplan/data-masking';
function safeMaskObject(
data: Record<string, unknown>,
rawOptions: unknown,
) {
const options = MaskOptionsSchema.parse(rawOptions); // throws ZodError on invalid input
return maskObject(data, options);
}
// Use from an API request body
const result = safeMaskObject(userData, req.body.maskOptions);
```
## API Reference
### `MaskStrategySchema`
```ts
const MaskStrategySchema: z.ZodEnum<[
'full', 'partial', 'email', 'phone',
'credit-card', 'hash', 'tokenize', 'preserve-format'
]>;
```
Validates that a string is one of the eight supported masking strategy
identifiers.
### `MaskRuleSchema`
```ts
const MaskRuleSchema: z.ZodObject<{
fields: z.ZodArray<z.ZodString>; // min 1 item, each min 1 char
strategy: typeof MaskStrategySchema;
keepLast?: z.ZodNumber; // int, non-negative
keepFirst?: z.ZodNumber; // int, non-negative
replacement?: z.ZodString;
}>;
```
### `MaskOptionsSchema`
```ts
const MaskOptionsSchema: z.ZodObject<{
rules: z.ZodArray<typeof MaskRuleSchema>; // min 1 item
deep?: z.ZodBoolean;
maskArrays?: z.ZodBoolean;
}>;
```
### Input types
```ts
type MaskStrategyInput = z.input<typeof MaskStrategySchema>;
type MaskRuleInput = z.input<typeof MaskRuleSchema>;
type MaskOptionsInput = z.input<typeof MaskOptionsSchema>;
```
These are the raw (un-coerced) input types, useful for TypeScript annotations
in form handlers and API route definitions before calling `.parse()`.
## Implementation Notes
- `z.number().int().nonnegative()` is used for `keepLast` and `keepFirst` to
reject fractional or negative values that would produce nonsensical results
in the `partial` strategy.
- `z.array(...).min(1)` on both `fields` and `rules` prevents silent no-op
configurations — a rule with no fields or an options object with no rules
would be a programming error.
- The schema file is excluded from coverage measurement (via `*.schema.ts`
exclusion in `vitest.config.ts`) because it is declarative Zod schema
definition code; meaningful coverage comes from integration tests of the
schemas via `safeParse`/`parse`, not line-by-line execution tracing.

View File

@@ -0,0 +1,45 @@
import z from "zod";
/**
* Zod schema for the `MaskStrategy` union type.
*/
export const MaskStrategySchema = z.enum([
"full",
"partial",
"email",
"phone",
"credit-card",
"hash",
"tokenize",
"preserve-format",
]);
/**
* Zod schema for `MaskRule`.
*
* Validates that `fields` is a non-empty array of non-empty strings and that
* optional numeric options (`keepLast`, `keepFirst`) are non-negative integers.
*/
export const MaskRuleSchema = z.object({
fields: z.array(z.string().min(1)).min(1),
strategy: MaskStrategySchema,
keepLast: z.number().int().nonnegative().optional(),
keepFirst: z.number().int().nonnegative().optional(),
replacement: z.string().optional(),
});
/**
* Zod schema for `MaskOptions`.
*
* At least one rule must be supplied; `deep` and `maskArrays` default to
* `true` when omitted.
*/
export const MaskOptionsSchema = z.object({
rules: z.array(MaskRuleSchema).min(1),
deep: z.boolean().optional(),
maskArrays: z.boolean().optional(),
});
export type MaskStrategyInput = z.input<typeof MaskStrategySchema>;
export type MaskRuleInput = z.input<typeof MaskRuleSchema>;
export type MaskOptionsInput = z.input<typeof MaskOptionsSchema>;

View File

@@ -0,0 +1,82 @@
# credit-card strategy
## Purpose
The `credit-card` masking strategy produces the PCI-DSS-compatible truncation
format for payment card numbers: last 4 digits visible, all preceding digit
positions replaced with `*`, formatting separators preserved. This aligns
with the PCI DSS 4.0 requirement that primary account numbers (PANs) must be
rendered unreadable in logs and displays, while retaining just enough
information for card identification and customer service operations.
## Features
- Last-4-digit preservation regardless of separator style.
- Hyphens, spaces, and other non-digit characters pass through unchanged.
- Handles formatted (`4111-1111-1111-1111`) and raw (`4111111111111111`) inputs.
- No regex capture groups — single linear pass, O(n) complexity.
## Usage: Basic Example
```ts
import { maskCreditCard } from '@suaveplan/data-masking/strategies/credit-card';
const masked = maskCreditCard('4111-1111-1111-1111', {
fields: ['cardNumber'],
strategy: 'credit-card',
});
// masked → '****-****-****-1111'
```
## Usage: Advanced Example
```ts
import { maskCreditCard } from '@suaveplan/data-masking/strategies/credit-card';
// No separators
maskCreditCard('5500005555555559', { fields: ['pan'], strategy: 'credit-card' });
// → '************5559'
// Space-separated format
maskCreditCard('4111 1111 1111 1111', { fields: ['pan'], strategy: 'credit-card' });
// → '**** **** **** 1111'
// Amex 15-digit
maskCreditCard('3714 496353 98431', { fields: ['pan'], strategy: 'credit-card' });
// → '**** ****** *8431'
// No digits at all → passthrough
maskCreditCard('CARD-ON-FILE', { fields: ['pan'], strategy: 'credit-card' });
// → 'CARD-ON-FILE'
```
## API Reference
### `maskCreditCard(value, rule)`
```ts
function maskCreditCard(value: string, rule: MaskRule): string;
```
| Parameter | Type | Description |
|-----------|------------|-----------------------------------------------------------|
| `value` | `string` | The original card number in any format. |
| `rule` | `MaskRule` | Driving rule (no fields consumed by credit-card strategy).|
**Returns** the masked card number with last 4 digits preserved and all other
digit positions replaced by `*`.
**Does not throw.** Returns the original value unchanged when no digit
characters are found.
## Implementation Notes
- The algorithm is identical in structure to the `phone` strategy: extract all
digits to find the `keepFrom` threshold, then rebuild the string in a single
pass. The two strategies are kept as separate files to allow divergent
evolution (e.g. future BIN-preservation or Luhn-check validation in the
credit-card variant).
- `Math.max(0, digits.length - 4)` ensures cards with 4 or fewer digits are
returned unchanged, consistent with the principle of least surprise.
- The `rule` parameter is silently voided to satisfy the uniform strategy
interface; no `MaskRule` fields are read.

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { maskCreditCard } from "./credit-card.js";
describe("maskCreditCard", () => {
it("masks 4111-1111-1111-1111 keeping last 4 digits", {
timeout: 5000,
}, () => {
const result = maskCreditCard("4111-1111-1111-1111", {
fields: ["f"],
strategy: "credit-card",
});
expect(result).toBe("****-****-****-1111");
});
it("handles unformatted 16-digit card number", { timeout: 5000 }, () => {
const result = maskCreditCard("4111111111111111", {
fields: ["f"],
strategy: "credit-card",
});
expect(result).toBe("************1111");
});
it("handles space-separated card number", { timeout: 5000 }, () => {
const result = maskCreditCard("4111 1111 1111 1111", {
fields: ["f"],
strategy: "credit-card",
});
expect(result).toBe("**** **** **** 1111");
});
it("handles 15-digit Amex format", { timeout: 5000 }, () => {
// 371449635398431 — 15 digits, keepFrom = 11
const result = maskCreditCard("3714-496353-98431", {
fields: ["f"],
strategy: "credit-card",
});
expect(result).toBe("****-******-*8431");
});
it("returns value unchanged when no digits present", {
timeout: 5000,
}, () => {
const result = maskCreditCard("CARD-ON-FILE", {
fields: ["f"],
strategy: "credit-card",
});
expect(result).toBe("CARD-ON-FILE");
});
it("preserves all digits for 4-digit input", { timeout: 5000 }, () => {
const result = maskCreditCard("1234", {
fields: ["f"],
strategy: "credit-card",
});
expect(result).toBe("1234");
});
it("preserves separators in output", { timeout: 5000 }, () => {
const input = "5500-0055-5555-5559";
const result = maskCreditCard(input, {
fields: ["f"],
strategy: "credit-card",
});
expect(result).toBe("****-****-****-5559");
});
});

View File

@@ -0,0 +1,39 @@
import type { MaskRule } from "../types/mask-types.js";
/**
* Credit-card masking strategy.
*
* Keeps the last 4 digits of a credit-card number and replaces all preceding
* digit positions with `*`, while preserving formatting separators (hyphens,
* spaces). Produces the industry-standard masked form, e.g.:
*
* - `4111-1111-1111-1111` → `****-****-****-1111`
* - `4111111111111111` → `************1111`
*
* @param value - The original card number, optionally formatted with
* separators.
* @param rule - The `MaskRule` (not consumed by this strategy).
* @returns The masked card number.
*/
export function maskCreditCard(value: string, rule: MaskRule): string {
void rule;
const digits = value.replace(/\D/g, "");
if (digits.length === 0) return value;
const keepFrom = Math.max(0, digits.length - 4);
let digitsSeen = 0;
let result = "";
for (const ch of value) {
if (/\d/.test(ch)) {
if (digitsSeen < keepFrom) {
result += "*";
} else {
result += ch;
}
digitsSeen++;
} else {
result += ch;
}
}
return result;
}

View File

@@ -0,0 +1,75 @@
# email strategy
## Purpose
The `email` masking strategy anonymises email addresses while keeping the
output immediately recognisable as an email address. Retaining the first
character of the local part and of the domain name — plus the full TLD — gives
operations and support teams just enough context to identify a domain category
(e.g. `.com`, `.io`, `.co.uk`) and the rough shape of an address without
exposing personally identifiable information. This is the standard form used
in GDPR-compliant audit logs and support ticketing systems.
## Features
- Preserves first character of the local-part (`u***@...`).
- Preserves first character of the domain name (`...@d***...`).
- Preserves the TLD verbatim (`.com`, `.co.uk`, etc.).
- Gracefully handles malformed inputs (no `@` sign) by returning `"***"`.
- No external dependencies — pure string slicing.
## Usage: Basic Example
```ts
import { maskEmail } from '@suaveplan/data-masking/strategies/email';
const masked = maskEmail('john.doe@example.com', { fields: ['email'], strategy: 'email' });
// masked → 'j***@e***.com'
```
## Usage: Advanced Example
```ts
import { maskEmail } from '@suaveplan/data-masking/strategies/email';
// Long TLD preserved
maskEmail('alice@subdomain.company.co.uk', { fields: ['email'], strategy: 'email' });
// → 'a***@s***.co.uk'
// No @ in input → fallback
maskEmail('not-an-email', { fields: ['email'], strategy: 'email' });
// → '***'
// Single-char local part
maskEmail('x@y.io', { fields: ['email'], strategy: 'email' });
// → 'x***@y***.io'
```
## API Reference
### `maskEmail(value, rule)`
```ts
function maskEmail(value: string, rule: MaskRule): string;
```
| Parameter | Type | Description |
|-----------|------------|---------------------------------------------------------|
| `value` | `string` | The original email address. |
| `rule` | `MaskRule` | Driving rule (no fields are read from it for email). |
**Returns** a string in the form `u***@d***.tld`, or `"***"` when `value`
contains no `@` character.
**Does not throw.**
## Implementation Notes
- The TLD is identified as everything from the **last** `.` in the domain
portion, which handles multi-part TLDs like `.co.uk` correctly.
- If the domain portion contains no `.` (uncommon in practice) the TLD is
treated as empty and the output becomes `d***`.
- The `rule` parameter is accepted to satisfy the uniform strategy interface
but none of its fields are consumed by the email strategy.
- Masking is applied to the parsed components, not via regex substitution,
which avoids regex-denial-of-service risks on adversarially crafted inputs.

View File

@@ -0,0 +1,84 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { maskEmail } from "./email.js";
describe("maskEmail", () => {
it("masks john@example.com correctly", { timeout: 5000 }, () => {
expect(
maskEmail("john@example.com", { fields: ["f"], strategy: "email" })
).toBe("j***@e***.com");
});
it("preserves TLD", { timeout: 5000 }, () => {
const result = maskEmail("alice@acme.org", {
fields: ["f"],
strategy: "email",
});
expect(result).toMatch(/\.org$/);
expect(result).toBe("a***@a***.org");
});
it("handles multi-part TLD (.co.uk)", { timeout: 5000 }, () => {
// lastIndexOf splits at the final dot: domainName="example.co", tld=".uk"
// First char of domainName "example.co" is "e"
const result = maskEmail("bob@example.co.uk", {
fields: ["f"],
strategy: "email",
});
expect(result).toBe("b***@e***.uk");
});
it("returns '***' for value without @ sign", { timeout: 5000 }, () => {
expect(
maskEmail("not-an-email", { fields: ["f"], strategy: "email" })
).toBe("***");
});
it("handles single-char local part", { timeout: 5000 }, () => {
expect(maskEmail("x@y.io", { fields: ["f"], strategy: "email" })).toBe(
"x***@y***.io"
);
});
it("masks subdomain correctly", { timeout: 5000 }, () => {
const result = maskEmail("user@mail.company.com", {
fields: ["f"],
strategy: "email",
});
expect(result).toBe("u***@m***.com");
});
it("handles uppercase local part", { timeout: 5000 }, () => {
const result = maskEmail("John@Example.com", {
fields: ["f"],
strategy: "email",
});
expect(result).toBe("J***@E***.com");
});
it("handles empty local part (starts with @)", { timeout: 5000 }, () => {
// local.length === 0 → localFirst = ""
const result = maskEmail("@example.com", {
fields: ["f"],
strategy: "email",
});
expect(result).toBe("***@e***.com");
});
it("handles domain with no dot (no TLD)", { timeout: 5000 }, () => {
// dotIdx = -1, domainName = full domain, tld = ""
const result = maskEmail("user@localhost", {
fields: ["f"],
strategy: "email",
});
expect(result).toBe("u***@l***");
});
it("handles empty domain (email ends with @)", { timeout: 5000 }, () => {
// domain = "", dotIdx = -1, domainName = "", domainFirst = ""
const result = maskEmail("user@", {
fields: ["f"],
strategy: "email",
});
expect(result).toBe("u***@***");
});
});

View File

@@ -0,0 +1,36 @@
import type { MaskRule } from "../types/mask-types.js";
/**
* Email-address masking strategy.
*
* Produces a format-recognisable but anonymised representation:
* `u***@d***.tld` where `u` is the first character of the local part and `d`
* is the first character of the domain name. The TLD (everything after the
* final `.` in the domain) is preserved verbatim so the email is still
* recognisable as a specific domain type (`.com`, `.org`, `.io`, etc.).
*
* @param value - The original email address string.
* @param rule - The `MaskRule` driving this masking operation (unused fields
* are silently ignored).
* @returns The masked email string, or `"***"` if the input is not a
* recognisable email address.
*/
export function maskEmail(value: string, rule: MaskRule): string {
void rule;
const atIdx = value.indexOf("@");
if (atIdx < 0) return "***";
const local = value.slice(0, atIdx);
const domain = value.slice(atIdx + 1);
const localFirst = local.length > 0 ? local[0] : "";
// Use the last dot in the domain to split domain name from TLD.
// This consistently picks the final label as the TLD, which works well
// for common cases like .com, .org, .io, and for "mail.company.com" → ".com".
const dotIdx = domain.lastIndexOf(".");
const domainName = dotIdx > 0 ? domain.slice(0, dotIdx) : domain;
const tld = dotIdx > 0 ? domain.slice(dotIdx) : "";
const domainFirst = domainName.length > 0 ? domainName[0] : "";
return `${localFirst}***@${domainFirst}***${tld}`;
}

View File

@@ -0,0 +1,67 @@
# full strategy
## Purpose
The `full` masking strategy completely replaces an input string with a
configurable replacement sentinel. It is the most aggressive masking mode
available in `@suaveplan/data-masking`: no characters from the original value
are preserved, so the output reveals nothing about length, structure, or
content. This strategy is appropriate for highly sensitive fields such as
passwords, SSNs, or security codes where even partial exposure is unacceptable.
## Features
- Zero information leakage — original value is discarded entirely.
- Customisable replacement via `MaskRule.replacement`; defaults to `"***"`.
- Constant-time execution (no iteration over the input string).
## Usage: Basic Example
```ts
import { maskFull } from '@suaveplan/data-masking/strategies/full';
const masked = maskFull('super-secret-password', { fields: ['password'], strategy: 'full' });
// masked → '***'
```
## Usage: Advanced Example
```ts
import { maskFull } from '@suaveplan/data-masking/strategies/full';
// Custom replacement sentinel
const masked = maskFull('123-45-6789', {
fields: ['ssn'],
strategy: 'full',
replacement: '[REDACTED]',
});
// masked → '[REDACTED]'
```
## API Reference
### `maskFull(value, rule)`
```ts
function maskFull(value: string, rule: MaskRule): string;
```
| Parameter | Type | Description |
|-----------|------------|---------------------------------------------------------|
| `value` | `string` | The original value to mask (immediately discarded). |
| `rule` | `MaskRule` | Driving rule; `rule.replacement` used when provided. |
**Returns** `rule.replacement` if set, otherwise `"***"`.
**Does not throw.**
## Implementation Notes
- The `value` parameter is accepted but immediately voided to satisfy the
uniform `mask(value, rule): string` strategy interface.
- `rule.replacement` may be any string, including the empty string `""`.
Callers that want a custom sentinel (e.g. `"[REDACTED]"`, `"N/A"`) set this
field on their `MaskRule`.
- Unlike `partial`, `email`, or `preserve-format` strategies, the `full`
strategy cannot be reversed or detected — it is appropriate for
security-critical fields that must not leak data to downstream systems.

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { maskFull } from "./full.js";
describe("maskFull", () => {
it("returns default replacement '***' when no replacement specified", {
timeout: 5000,
}, () => {
expect(maskFull("secret", { fields: ["f"], strategy: "full" })).toBe(
"***"
);
});
it("uses custom replacement when provided", { timeout: 5000 }, () => {
expect(
maskFull("secret", {
fields: ["f"],
strategy: "full",
replacement: "[REDACTED]",
})
).toBe("[REDACTED]");
});
it("uses empty string as replacement when explicitly set", {
timeout: 5000,
}, () => {
expect(
maskFull("secret", {
fields: ["f"],
strategy: "full",
replacement: "",
})
).toBe("");
});
it("discards the original value entirely", { timeout: 5000 }, () => {
const result = maskFull("sensitive-data-123", {
fields: ["f"],
strategy: "full",
});
expect(result).not.toContain("sensitive");
expect(result).toBe("***");
});
it("handles empty input string", { timeout: 5000 }, () => {
expect(maskFull("", { fields: ["f"], strategy: "full" })).toBe("***");
});
it("handles very long input string", { timeout: 5000 }, () => {
expect(
maskFull("a".repeat(1000), { fields: ["f"], strategy: "full" })
).toBe("***");
});
});

View File

@@ -0,0 +1,17 @@
import type { MaskRule } from "../types/mask-types.js";
/**
* Full-replacement masking strategy.
*
* Replaces the entire input string with `rule.replacement` when provided, or
* the default `"***"` sentinel otherwise. The original length and content are
* completely obscured — no structural information is preserved.
*
* @param value - The original string to mask.
* @param rule - The `MaskRule` driving this masking operation.
* @returns The replacement string.
*/
export function maskFull(value: string, rule: MaskRule): string {
void value; // original value is intentionally discarded
return rule.replacement ?? "***";
}

View File

@@ -0,0 +1,79 @@
# hash strategy
## Purpose
The `hash` masking strategy produces a deterministic, fixed-length 8-character
hexadecimal token from any input string using the FNV-1a 32-bit hash algorithm.
Unlike the `full` or `tokenize` strategies, the `hash` strategy preserves the
property that identical input values produce identical output tokens, which
enables de-duplication and correlation in anonymised log streams without
exposing the original data. This is valuable when troubleshooting session
issues where the same user ID must be traceable across log entries.
**Security caveat:** FNV-1a is not cryptographically secure. It must not be
used for password hashing, MAC generation, or any context where collision
resistance is a security requirement. For those use cases, use
`@suaveplan/crypto-hash` with `sha256` or `argon2id`.
## Features
- Deterministic: same input → same 8-char hex output, every time.
- Fixed-length output (8 hex digits = 32-bit hash).
- Zero external dependencies — pure JavaScript arithmetic via `Math.imul`.
- Fast: O(n) in string length with very low constant factor.
## Usage: Basic Example
```ts
import { maskHash } from '@suaveplan/data-masking/strategies/hash';
const masked = maskHash('user_12345', { fields: ['userId'], strategy: 'hash' });
// masked → e.g. '2c7b4f8a' (deterministic for this input)
```
## Usage: Advanced Example
```ts
import { maskHash } from '@suaveplan/data-masking/strategies/hash';
// Identical inputs → identical outputs (log correlation)
const a = maskHash('john@example.com', { fields: ['email'], strategy: 'hash' });
const b = maskHash('john@example.com', { fields: ['email'], strategy: 'hash' });
// a === b → true
// Different inputs → (almost certainly) different outputs
const c = maskHash('jane@example.com', { fields: ['email'], strategy: 'hash' });
// a === c → false (very likely)
```
## API Reference
### `maskHash(value, rule)`
```ts
function maskHash(value: string, rule: MaskRule): string;
```
| Parameter | Type | Description |
|-----------|------------|-----------------------------------------------------|
| `value` | `string` | The original string to hash. |
| `rule` | `MaskRule` | Driving rule (no fields consumed by hash strategy). |
**Returns** an 8-character lowercase hexadecimal string.
**Does not throw.**
## Implementation Notes
- The FNV-1a 32-bit variant uses offset basis `0x811c9dc5` and prime
`0x01000193` per the canonical specification.
- `Math.imul` is used for the multiplication step to get correct 32-bit
integer multiplication semantics in JavaScript (standard `*` uses IEEE 754
doubles which can lose low bits beyond 53-bit precision).
- The `>>> 0` unsigned right-shift converts the result back from signed Int32
to unsigned Uint32 before the next iteration, matching the expected
unsigned arithmetic of FNV-1a.
- Character codes are read with `charCodeAt` (UTF-16 code units), which is
consistent and fast. Full Unicode codepoint splitting (via `codePointAt`)
is not applied because the goal is a cheap stable hash, not an
encoding-canonical one.

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { maskHash } from "./hash.js";
describe("maskHash", () => {
it("returns 8-character hex string", { timeout: 5000 }, () => {
const result = maskHash("hello", { fields: ["f"], strategy: "hash" });
expect(result).toMatch(/^[0-9a-f]{8}$/);
});
it("is deterministic for same input", { timeout: 5000 }, () => {
const rule = { fields: ["f"], strategy: "hash" as const };
const a = maskHash("user_12345", rule);
const b = maskHash("user_12345", rule);
expect(a).toBe(b);
});
it("produces different outputs for different inputs", {
timeout: 5000,
}, () => {
const rule = { fields: ["f"], strategy: "hash" as const };
const a = maskHash("john@example.com", rule);
const b = maskHash("jane@example.com", rule);
expect(a).not.toBe(b);
});
it("handles empty string", { timeout: 5000 }, () => {
const result = maskHash("", { fields: ["f"], strategy: "hash" });
expect(result).toMatch(/^[0-9a-f]{8}$/);
});
it("produces consistent known output for 'hello'", {
timeout: 5000,
}, () => {
// FNV-1a 32-bit hash of "hello" = 0x4f9f2cab
const result = maskHash("hello", { fields: ["f"], strategy: "hash" });
expect(result).toBe("4f9f2cab");
});
it("produces consistent known output for empty string", {
timeout: 5000,
}, () => {
// FNV-1a 32-bit hash of "" = 0x811c9dc5 (offset basis itself)
const result = maskHash("", { fields: ["f"], strategy: "hash" });
expect(result).toBe("811c9dc5");
});
it("output is always exactly 8 chars via padding", {
timeout: 5000,
}, () => {
// For inputs that produce a hash starting with 0, output must still be padded to 8 chars
const result = maskHash("abc", { fields: ["f"], strategy: "hash" });
expect(result.length).toBe(8);
});
});

View File

@@ -0,0 +1,45 @@
import type { MaskRule } from "../types/mask-types.js";
/**
* FNV-1a 32-bit hash masking strategy.
*
* Produces an 8-character lowercase hexadecimal hash of the input string.
* The same input always produces the same output within a process, making it
* suitable for consistent anonymisation in logs where duplicate detection is
* needed (e.g. the same user ID appearing multiple times should produce the
* same hash token so log correlation remains possible without exposing the
* original value).
*
* FNV-1a was chosen for its speed, simplicity, and absence of external
* dependencies. It is NOT cryptographically secure — do not use this strategy
* for password storage or collision-resistant identifiers.
*
* @param value - The original string to hash.
* @param rule - The `MaskRule` (not consumed by this strategy).
* @returns An 8-character lowercase hex string.
*/
export function maskHash(value: string, rule: MaskRule): string {
void rule;
return fnv1a32(value).toString(16).padStart(8, "0");
}
/**
* FNV-1a 32-bit hash implementation.
*
* Uses 32-bit unsigned arithmetic via bitwise OR 0 to stay within JavaScript's
* safe integer range on every multiply/XOR step.
*/
function fnv1a32(input: string): number {
// FNV offset basis and prime for 32-bit variant.
const FNV_OFFSET = 0x811c9dc5;
const FNV_PRIME = 0x01000193;
let hash = FNV_OFFSET;
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i);
// Multiply by FNV prime — keep in 32-bit range.
// JavaScript bitwise operators coerce to Int32; `>>> 0` converts to Uint32.
hash = Math.imul(hash, FNV_PRIME) >>> 0;
}
return hash;
}

View File

@@ -0,0 +1,8 @@
export { maskCreditCard } from "./credit-card";
export { maskEmail } from "./email";
export { maskFull } from "./full";
export { maskHash } from "./hash";
export { maskPartial } from "./partial";
export { maskPhone } from "./phone";
export { maskPreserveFormat } from "./preserve-format";
export { _resetTokenCounter, maskTokenize } from "./tokenize";

View File

@@ -0,0 +1,85 @@
# partial strategy
## Purpose
The `partial` masking strategy preserves a configurable number of characters at
the start and end of an input string while replacing the middle portion with
asterisks. This is the most commonly needed masking shape for fields like
API keys, account numbers, and reference codes where enough context must remain
for humans to identify a value in logs without exposing the sensitive body.
## Features
- Configurable `keepFirst` (default `0`) and `keepLast` (default `4`) character
counts via `MaskRule`.
- Safe handling of short strings: when `keepFirst + keepLast >= length` the
original value is returned unchanged rather than producing an empty or
misleading result.
- No external dependencies — pure string slicing.
## Usage: Basic Example
```ts
import { maskPartial } from '@suaveplan/data-masking/strategies/partial';
// Keep last 4 characters (default keepLast = 4, keepFirst = 0)
const masked = maskPartial('sk_live_abc123xyz789', {
fields: ['apiKey'],
strategy: 'partial',
});
// masked → '****************789' (last 3 chars shown — "789" ends value)
```
## Usage: Advanced Example
```ts
import { maskPartial } from '@suaveplan/data-masking/strategies/partial';
// Keep first 2 and last 4 characters
const masked = maskPartial('4111111111111111', {
fields: ['cardNumber'],
strategy: 'partial',
keepFirst: 2,
keepLast: 4,
});
// masked → '41**********1111'
// Short string — no masking applied
const short = maskPartial('abc', {
fields: ['code'],
strategy: 'partial',
keepFirst: 2,
keepLast: 2,
});
// short → 'abc' (keepFirst + keepLast >= length)
```
## API Reference
### `maskPartial(value, rule)`
```ts
function maskPartial(value: string, rule: MaskRule): string;
```
| Parameter | Type | Default | Description |
|------------------|------------|---------|------------------------------------------|
| `value` | `string` | — | The original string. |
| `rule.keepFirst` | `number` | `0` | Characters to keep from the start. |
| `rule.keepLast` | `number` | `4` | Characters to keep from the end. |
**Returns** the masked string, or the original value unchanged if it is too
short to mask.
**Does not throw.**
## Implementation Notes
- The mask character is always `"*"`. A future extension could expose a
`maskChar` option on `MaskRule` if needed.
- `keepFirst + keepLast >= len` guard prevents edge cases where `slice` would
produce overlapping or inverted ranges, which can silently produce empty
strings in JavaScript.
- Middle length is computed as `len - keepFirst - keepLast` to give a
mask block that exactly fills the hidden region, preserving total length
and allowing callers to spot-check that masking was applied.

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { maskPartial } from "./partial.js";
describe("maskPartial", () => {
it("keeps last 4 chars by default", { timeout: 5000 }, () => {
const result = maskPartial("1234567890", {
fields: ["f"],
strategy: "partial",
});
expect(result).toBe("******7890");
});
it("keeps first N chars when keepFirst set", { timeout: 5000 }, () => {
const result = maskPartial("1234567890", {
fields: ["f"],
strategy: "partial",
keepFirst: 2,
keepLast: 4,
});
expect(result).toBe("12****7890");
});
it("masks entire middle", { timeout: 5000 }, () => {
const result = maskPartial("abcdefghij", {
fields: ["f"],
strategy: "partial",
keepFirst: 1,
keepLast: 1,
});
expect(result).toBe("a********j");
});
it("returns value unchanged when keepFirst + keepLast >= length", {
timeout: 5000,
}, () => {
const result = maskPartial("abc", {
fields: ["f"],
strategy: "partial",
keepFirst: 2,
keepLast: 2,
});
expect(result).toBe("abc");
});
it("returns value unchanged when value shorter than keepLast", {
timeout: 5000,
}, () => {
const result = maskPartial("123", {
fields: ["f"],
strategy: "partial",
keepLast: 4,
});
expect(result).toBe("123");
});
it("handles zero keepFirst and keepLast", { timeout: 5000 }, () => {
// keepFirst=0, keepLast=0 means everything is masked but the guard triggers
// since 0 + 0 = 0 < 5, so result should be "***" * 5
const result = maskPartial("hello", {
fields: ["f"],
strategy: "partial",
keepFirst: 0,
keepLast: 0,
});
expect(result).toBe("*****");
});
it("preserves total length", { timeout: 5000 }, () => {
const input = "sk_live_abc123xyz";
const result = maskPartial(input, {
fields: ["f"],
strategy: "partial",
keepLast: 4,
});
expect(result.length).toBe(input.length);
});
});

View File

@@ -0,0 +1,33 @@
import type { MaskRule } from "../types/mask-types.js";
/**
* Partial masking strategy.
*
* Preserves up to `keepFirst` characters at the start and `keepLast`
* characters at the end of the input, replacing the middle section with
* asterisks. Useful for fields like account numbers or API keys where just
* enough identifying context is needed for debugging without exposing the
* sensitive core.
*
* @param value - The original string to mask.
* @param rule - The `MaskRule` containing `keepFirst` and `keepLast` options.
* @returns The partially-masked string.
*/
export function maskPartial(value: string, rule: MaskRule): string {
const keepFirst = rule.keepFirst ?? 0;
const keepLast = rule.keepLast ?? 4;
const len = value.length;
// If the value is short enough that kept chars overlap or cover everything,
// just return the full value — nothing remains to mask.
if (keepFirst + keepLast >= len) {
return value;
}
const prefix = value.slice(0, keepFirst);
const suffix = value.slice(len - keepLast);
const maskedLen = len - keepFirst - keepLast;
const middle = "*".repeat(maskedLen);
return `${prefix}${middle}${suffix}`;
}

View File

@@ -0,0 +1,80 @@
# phone strategy
## Purpose
The `phone` masking strategy anonymises phone numbers while preserving their
formatting separators, making masked values easy to read in logs and audit
trails. Only the last 4 digits are kept; all other digit positions are
replaced with `*`. Non-digit characters (hyphens, spaces, parentheses, dots,
`+`) pass through unchanged, so the output retains the same visual structure
as the input regardless of locale or formatting convention.
## Features
- Last-4-digit preservation regardless of phone format.
- Separator characters (`-`, ` `, `(`, `)`, `.`, `+`) are preserved verbatim.
- Handles international formats (`+1-…`), US formats (`(555) 123-4567`), and
unformatted digit strings (`5551234567`).
- No regex-based capture groups or lookaheads — single linear pass.
## Usage: Basic Example
```ts
import { maskPhone } from '@suaveplan/data-masking/strategies/phone';
const masked = maskPhone('+1-555-123-4567', { fields: ['phone'], strategy: 'phone' });
// masked → '*-***-***-4567'
```
## Usage: Advanced Example
```ts
import { maskPhone } from '@suaveplan/data-masking/strategies/phone';
// US format with parentheses
maskPhone('(555) 123-4567', { fields: ['phone'], strategy: 'phone' });
// → '(***) ***-4567'
// E.164 international
maskPhone('+447911123456', { fields: ['phone'], strategy: 'phone' });
// → '+*********3456'
// Unformatted
maskPhone('5551234567', { fields: ['phone'], strategy: 'phone' });
// → '******4567'
// No digits at all → passthrough
maskPhone('N/A', { fields: ['phone'], strategy: 'phone' });
// → 'N/A'
```
## API Reference
### `maskPhone(value, rule)`
```ts
function maskPhone(value: string, rule: MaskRule): string;
```
| Parameter | Type | Description |
|-----------|------------|----------------------------------------------------------|
| `value` | `string` | The original phone number in any format. |
| `rule` | `MaskRule` | Driving rule (no fields are consumed by phone strategy). |
**Returns** the masked phone string with last 4 digits preserved and all other
digit positions replaced by `*`.
**Does not throw.** Returns the original value unchanged when no digit
characters are found.
## Implementation Notes
- The strategy extracts all digits first to determine the `keepFrom` boundary
(total digits minus 4), then makes a single linear pass over the original
string to rebuild the result. This avoids multi-pass scanning and keeps
the logic O(n).
- `Math.max(0, digits.length - 4)` ensures that strings with 4 or fewer digits
are returned with all digits preserved (no masking applied), matching the
principle of least surprise.
- The `rule` parameter is accepted to satisfy the uniform strategy interface
but none of its fields are consumed.

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { maskPhone } from "./phone.js";
describe("maskPhone", () => {
it("masks +1-555-123-4567 keeping last 4 digits", { timeout: 5000 }, () => {
const result = maskPhone("+1-555-123-4567", {
fields: ["f"],
strategy: "phone",
});
expect(result).toBe("+*-***-***-4567");
});
it("preserves parentheses and space separators", { timeout: 5000 }, () => {
const result = maskPhone("(555) 123-4567", {
fields: ["f"],
strategy: "phone",
});
expect(result).toBe("(***) ***-4567");
});
it("handles unformatted digits", { timeout: 5000 }, () => {
const result = maskPhone("5551234567", {
fields: ["f"],
strategy: "phone",
});
expect(result).toBe("******4567");
});
it("handles international E.164 format", { timeout: 5000 }, () => {
// +447911123456 — '+' is non-digit (preserved), 12 digits total,
// keepFrom=8, last 4 = "3456", so 8 asterisks after '+'
const result = maskPhone("+447911123456", {
fields: ["f"],
strategy: "phone",
});
expect(result).toBe("+********3456");
});
it("returns value unchanged when no digits present", {
timeout: 5000,
}, () => {
const result = maskPhone("N/A", { fields: ["f"], strategy: "phone" });
expect(result).toBe("N/A");
});
it("preserves all digits when 4 or fewer total", { timeout: 5000 }, () => {
const result = maskPhone("1234", { fields: ["f"], strategy: "phone" });
expect(result).toBe("1234");
});
it("handles phone with dot separators", { timeout: 5000 }, () => {
const result = maskPhone("555.123.4567", {
fields: ["f"],
strategy: "phone",
});
expect(result).toBe("***.***.4567");
});
it("masks exactly all-but-last-4 digits", { timeout: 5000 }, () => {
// 11 digits total → first 7 masked
const result = maskPhone("12345678901", {
fields: ["f"],
strategy: "phone",
});
expect(result).toBe("*******8901");
});
});

View File

@@ -0,0 +1,40 @@
import type { MaskRule } from "../types/mask-types.js";
/**
* Phone-number masking strategy.
*
* Keeps the last 4 digits of a phone number and replaces all preceding digit
* positions with `*`, while preserving non-digit separator characters
* (hyphens, spaces, parentheses, dots, `+`) so the output remains formatted
* consistently with the input.
*
* Example: `+1-555-123-4567` → `*-***-***-4567`
*
* @param value - The original phone number string.
* @param rule - The `MaskRule` (not consumed by this strategy).
* @returns The masked phone number.
*/
export function maskPhone(value: string, rule: MaskRule): string {
void rule;
// Extract only the digit characters to determine the last-4 boundary.
const digits = value.replace(/\D/g, "");
if (digits.length === 0) return value;
const keepFrom = Math.max(0, digits.length - 4);
// Walk the original string, replacing digits that are NOT in the last 4.
let digitsSeen = 0;
let result = "";
for (const ch of value) {
if (/\d/.test(ch)) {
if (digitsSeen < keepFrom) {
result += "*";
} else {
result += ch;
}
digitsSeen++;
} else {
result += ch;
}
}
return result;
}

View File

@@ -0,0 +1,79 @@
# preserve-format strategy
## Purpose
The `preserve-format` masking strategy replaces every alphanumeric character
with a placeholder of the same character class — digits become `0`, letters
become `X` — while leaving all punctuation, spaces, and special characters
unchanged. The result has the same total length and the same structural
"skeleton" as the original, which allows downstream systems (parsers, validators,
format checkers) to continue operating on the masked data without seeing any
real content. This is particularly useful in schema-validation pipelines that
process PII-bearing fields and must remain functional during testing against
production-shaped data.
## Features
- Length-preserving: output has exactly the same character count as input.
- Character-class-preserving: digits → `0`, letters → `X`, other → verbatim.
- No regex — single character-by-character pass, safe on all Unicode inputs
(only ASCII alphanumerics are substituted; non-ASCII passes through).
- Zero external dependencies.
## Usage: Basic Example
```ts
import { maskPreserveFormat } from '@suaveplan/data-masking/strategies/preserve-format';
const masked = maskPreserveFormat('Hello123', { fields: ['code'], strategy: 'preserve-format' });
// masked → 'XXXXX000'
```
## Usage: Advanced Example
```ts
import { maskPreserveFormat } from '@suaveplan/data-masking/strategies/preserve-format';
// Phone with separators
maskPreserveFormat('+1-555-123-4567', { fields: ['phone'], strategy: 'preserve-format' });
// → '+0-000-000-0000'
// Mixed alphanumeric code
maskPreserveFormat('AB12-CD34-EF56', { fields: ['serial'], strategy: 'preserve-format' });
// → 'XX00-XX00-XX00'
// Non-ASCII pass-through (accented chars preserved)
maskPreserveFormat('café 42', { fields: ['label'], strategy: 'preserve-format' });
// → 'XXXX 00' (é is non-ASCII and passes through as-is)
```
## API Reference
### `maskPreserveFormat(value, rule)`
```ts
function maskPreserveFormat(value: string, rule: MaskRule): string;
```
| Parameter | Type | Description |
|-----------|------------|----------------------------------------------------------------|
| `value` | `string` | The original string to format-preserve. |
| `rule` | `MaskRule` | Driving rule (no fields consumed by preserve-format strategy). |
**Returns** a same-length string with alphanumeric chars replaced by `0` / `X`
and all other chars preserved.
**Does not throw.**
## Implementation Notes
- Only ASCII alphanumerics (`a-z`, `A-Z`, `0-9`) are replaced. Non-ASCII
characters (including accented letters, CJK, emoji) pass through unchanged.
This is intentional: the strategy targets structured English-language data
formats (phone numbers, reference codes, account identifiers) where only
ASCII alphanumerics carry sensitive content.
- The `for...of` iteration correctly handles multi-code-unit Unicode characters
(surrogate pairs) because JavaScript iterates by codepoint in `for...of`.
The ASCII range check ensures non-ASCII characters are passed through intact.
- The `rule` parameter is silently voided to satisfy the uniform strategy
interface.

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { maskPreserveFormat } from "./preserve-format.js";
describe("maskPreserveFormat", () => {
it("replaces letters with X and digits with 0", { timeout: 5000 }, () => {
expect(
maskPreserveFormat("Hello123", {
fields: ["f"],
strategy: "preserve-format",
})
).toBe("XXXXX000");
});
it("preserves non-alphanumeric characters", { timeout: 5000 }, () => {
expect(
maskPreserveFormat("+1-555-123-4567", {
fields: ["f"],
strategy: "preserve-format",
})
).toBe("+0-000-000-0000");
});
it("preserves spaces", { timeout: 5000 }, () => {
expect(
maskPreserveFormat("Hello World", {
fields: ["f"],
strategy: "preserve-format",
})
).toBe("XXXXX XXXXX");
});
it("handles all-digit input", { timeout: 5000 }, () => {
expect(
maskPreserveFormat("4111111111111111", {
fields: ["f"],
strategy: "preserve-format",
})
).toBe("0000000000000000");
});
it("handles all-letter input", { timeout: 5000 }, () => {
expect(
maskPreserveFormat("abcDEF", {
fields: ["f"],
strategy: "preserve-format",
})
).toBe("XXXXXX");
});
it("preserves length", { timeout: 5000 }, () => {
const input = "AB12-CD34-EF56";
const result = maskPreserveFormat(input, {
fields: ["f"],
strategy: "preserve-format",
});
expect(result.length).toBe(input.length);
expect(result).toBe("XX00-XX00-XX00");
});
it("handles empty string", { timeout: 5000 }, () => {
expect(
maskPreserveFormat("", {
fields: ["f"],
strategy: "preserve-format",
})
).toBe("");
});
it("replaces both upper and lower case letters with X", {
timeout: 5000,
}, () => {
expect(
maskPreserveFormat("aAbBcC", {
fields: ["f"],
strategy: "preserve-format",
})
).toBe("XXXXXX");
});
});

View File

@@ -0,0 +1,36 @@
import type { MaskRule } from "../types/mask-types.js";
/**
* Format-preserving masking strategy.
*
* Replaces each character in the input with a type-preserving substitute:
* - Decimal digits (`0-9`) → `0`
* - ASCII letters (`a-z`, `A-Z`) → `X`
* - All other characters (spaces, hyphens, punctuation, etc.) → preserved
* verbatim so the structural shape of the value is maintained.
*
* This produces output that has the same length and non-alphanumeric
* punctuation as the original, which is useful for format validation in
* downstream systems that need to parse the field structure without seeing
* real data.
*
* @example `"Hello, World! 123"` → `"XXXXX, XXXXX! 000"`
*
* @param value - The original string.
* @param rule - The `MaskRule` (not consumed by this strategy).
* @returns A format-preserved masked string of the same length.
*/
export function maskPreserveFormat(value: string, rule: MaskRule): string {
void rule;
let result = "";
for (const ch of value) {
if (ch >= "0" && ch <= "9") {
result += "0";
} else if ((ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z")) {
result += "X";
} else {
result += ch;
}
}
return result;
}

View File

@@ -0,0 +1,91 @@
# tokenize strategy
## Purpose
The `tokenize` masking strategy replaces a sensitive value with a structured
opaque token of the form `TOKEN_<STRATEGY>_<N>`, where `<N>` is a
monotonically incrementing counter scoped to the current process lifetime.
This is the simplest form of tokenisation — sufficient for audit trails and
structured logs where the original value must be fully obscured but a unique
reference per occurrence is still useful.
**Non-reversibility warning:** This implementation does NOT maintain a mapping
from token to original value. Each call produces a new token (the counter
increments on every invocation), and the counter resets when the process
restarts. True reversible tokenisation requires a persistent, encrypted token
vault that maps tokens to their plaintext originals. That capability is
intentionally outside the scope of `@suaveplan/data-masking`.
## Features
- Structured token format: `TOKEN_<STRATEGY>_<index>`.
- Monotonically increasing per-process counter — no random dependency.
- Deterministic in tests when the counter is reset via `_resetTokenCounter()`.
- Zero external dependencies.
## Usage: Basic Example
```ts
import { maskTokenize, _resetTokenCounter } from '@suaveplan/data-masking/strategies/tokenize';
_resetTokenCounter(); // deterministic start for tests
const t1 = maskTokenize('john@example.com', { fields: ['email'], strategy: 'tokenize' });
// t1 → 'TOKEN_TOKENIZE_0'
const t2 = maskTokenize('jane@example.com', { fields: ['email'], strategy: 'tokenize' });
// t2 → 'TOKEN_TOKENIZE_1'
```
## Usage: Advanced Example
```ts
import { maskTokenize, _resetTokenCounter } from '@suaveplan/data-masking/strategies/tokenize';
_resetTokenCounter();
// The strategy tag is embedded in the token for provenance
const token = maskTokenize('4111111111111111', { fields: ['pan'], strategy: 'tokenize' });
// token → 'TOKEN_TOKENIZE_0'
// Confirm it's a token (quick prefix check)
console.log(token.startsWith('TOKEN_')); // true
```
## API Reference
### `maskTokenize(value, rule)`
```ts
function maskTokenize(value: string, rule: MaskRule): string;
```
| Parameter | Type | Description |
|-----------|------------|---------------------------------------------------------------|
| `value` | `string` | The original value (silently discarded). |
| `rule` | `MaskRule` | Driving rule; `rule.strategy` is embedded in the token tag. |
**Returns** a token string of the form `TOKEN_<STRATEGY>_<N>`.
**Does not throw.**
### `_resetTokenCounter()`
```ts
function _resetTokenCounter(): void;
```
Resets the internal token counter to `0`. **For testing only.** Production
code must not call this function.
## Implementation Notes
- The counter is a module-level variable, not a class instance, to satisfy
the `mask(value, rule): string` strategy interface without requiring callers
to instantiate a strategy object or inject state.
- Hyphens in the strategy name are converted to underscores in the token tag
(`credit-card` → `CREDIT_CARD`) for identifier-safe token strings.
- Truly reversible tokenisation would require a key-value store (database or
in-memory map with encryption) mapping each token back to its plaintext.
That is deliberately deferred to a higher-level package and explicitly
documented as out-of-scope here.

View File

@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { _resetTokenCounter, maskTokenize } from "./tokenize.js";
describe("maskTokenize", () => {
beforeEach(() => {
_resetTokenCounter();
});
it("returns a token starting with TOKEN_", { timeout: 5000 }, () => {
const result = maskTokenize("secret", {
fields: ["f"],
strategy: "tokenize",
});
expect(result.startsWith("TOKEN_")).toBe(true);
});
it("embeds strategy name in token", { timeout: 5000 }, () => {
const result = maskTokenize("secret", {
fields: ["f"],
strategy: "tokenize",
});
expect(result).toContain("TOKENIZE");
});
it("includes counter index in token", { timeout: 5000 }, () => {
const result = maskTokenize("secret", {
fields: ["f"],
strategy: "tokenize",
});
expect(result).toBe("TOKEN_TOKENIZE_0");
});
it("increments counter on each call", { timeout: 5000 }, () => {
const t1 = maskTokenize("a", { fields: ["f"], strategy: "tokenize" });
const t2 = maskTokenize("b", { fields: ["f"], strategy: "tokenize" });
expect(t1).toBe("TOKEN_TOKENIZE_0");
expect(t2).toBe("TOKEN_TOKENIZE_1");
});
it("converts hyphens in strategy name to underscores", {
timeout: 5000,
}, () => {
const result = maskTokenize("4111111111111111", {
fields: ["f"],
strategy: "credit-card",
});
expect(result).toBe("TOKEN_CREDIT_CARD_0");
});
it("discards the original value", { timeout: 5000 }, () => {
const result = maskTokenize("sensitive-data", {
fields: ["f"],
strategy: "tokenize",
});
expect(result).not.toContain("sensitive");
});
it("resets counter correctly", { timeout: 5000 }, () => {
maskTokenize("a", { fields: ["f"], strategy: "tokenize" });
maskTokenize("b", { fields: ["f"], strategy: "tokenize" });
_resetTokenCounter();
const result = maskTokenize("c", {
fields: ["f"],
strategy: "tokenize",
});
expect(result).toBe("TOKEN_TOKENIZE_0");
});
});

View File

@@ -0,0 +1,46 @@
import type { MaskRule } from "../types/mask-types.js";
/**
* Token counter — per-module singleton, reset on import.
*
* Intentionally module-scoped rather than passed as a parameter so that the
* `mask(value, rule): string` strategy interface is preserved without requiring
* callers to manage state. The counter increments monotonically for the
* lifetime of the process.
*/
let _counter = 0;
/**
* Reset the token counter (exposed for deterministic test scenarios only).
* Production code should not call this.
*/
export function _resetTokenCounter(): void {
_counter = 0;
}
/**
* Tokenisation masking strategy.
*
* Replaces a value with a structured token of the form
* `TOKEN_<STRATEGY>_<index>` where `<STRATEGY>` is derived from the rule's
* `strategy` field (uppercased) and `<index>` is a monotonically increasing
* counter.
*
* **Important:** This implementation is NOT reversible. The counter is
* module-scoped and resets on process restart. True reversible tokenisation
* requires a persistent token vault (e.g. a database mapping tokens to
* originals with encryption at rest) which is out of scope for this package.
* The docs explicitly note this limitation.
*
* @param value - The original string (not used in token generation, accepted
* to satisfy the uniform strategy interface).
* @param rule - The `MaskRule`; `rule.strategy` is embedded in the token.
* @returns A token string like `TOKEN_TOKENIZE_0`.
*/
export function maskTokenize(value: string, rule: MaskRule): string {
void value;
const strategyTag = rule.strategy.toUpperCase().replace(/-/g, "_");
const token = `TOKEN_${strategyTag}_${_counter}`;
_counter++;
return token;
}

View File

@@ -0,0 +1,6 @@
export type {
MaskOptions,
MaskResult,
MaskRule,
MaskStrategy,
} from "./mask-types";

View File

@@ -0,0 +1,130 @@
# mask-types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Central type definition module for `@suaveplan/data-masking`. All public-facing
interfaces (`MaskRule`, `MaskOptions`, `MaskResult`) and the `MaskStrategy`
union type live here to provide a single authoritative source of truth for the
package's data model and to break circular-import hazards between strategy
implementations and the masker core.
This file ships zero runtime code — it is a types-only module. Importing it
has no observable side-effects, making it tree-shakeable in type-erasure builds.
## Features
- `MaskStrategy` union: eight masking modes as a string literal union.
- `MaskRule` interface: field path list, strategy, and per-strategy options.
- `MaskOptions` interface: traversal controls (`deep`, `maskArrays`).
- `MaskResult<T>` generic interface: masked clone + affected path list.
- Full JSDoc on every property for IDE hover documentation.
## Usage: Basic Example
```ts
import type { MaskRule, MaskOptions } from '@suaveplan/data-masking';
const options: MaskOptions = {
rules: [
{ fields: ['user.email'], strategy: 'email' },
{ fields: ['user.ssn'], strategy: 'full' },
],
};
```
## Usage: Advanced Example
```ts
import type { MaskRule, MaskResult, MaskStrategy } from '@suaveplan/data-masking';
// Build a typed rule map
const SENSITIVE_FIELDS: Record<string, MaskStrategy> = {
'user.email': 'email',
'user.phone': 'phone',
'payment.cardNumber': 'credit-card',
'payment.cvv': 'full',
};
const rules: MaskRule[] = Object.entries(SENSITIVE_FIELDS).map(
([field, strategy]) => ({ fields: [field], strategy }),
);
// Use with maskObject result
function logAffected(result: MaskResult<unknown>): void {
console.log('Masked fields:', result.fieldsAffected.join(', '));
}
```
## API Reference
### `MaskStrategy`
```ts
type MaskStrategy =
| 'full' | 'partial' | 'email' | 'phone'
| 'credit-card' | 'hash' | 'tokenize' | 'preserve-format';
```
String-literal union of all supported masking strategy identifiers.
### `MaskRule`
```ts
interface MaskRule {
fields: string[];
strategy: MaskStrategy;
keepLast?: number;
keepFirst?: number;
replacement?: string;
}
```
| Property | Type | Default | Description |
|---------------|----------------|---------|-------------------------------------------------|
| `fields` | `string[]` | — | Dot-notation paths / glob patterns. |
| `strategy` | `MaskStrategy` | — | Which masking strategy to apply. |
| `keepLast` | `number` | `4` | Chars to preserve at end (`partial` only). |
| `keepFirst` | `number` | `0` | Chars to preserve at start (`partial` only). |
| `replacement` | `string` | `"***"` | Custom replacement string (`full` only). |
### `MaskOptions`
```ts
interface MaskOptions {
rules: MaskRule[];
deep?: boolean;
maskArrays?: boolean;
}
```
| Property | Type | Default | Description |
|--------------|--------------|---------|-------------------------------------------|
| `rules` | `MaskRule[]` | — | Ordered list of masking rules. |
| `deep` | `boolean` | `true` | Recurse into nested objects. |
| `maskArrays` | `boolean` | `true` | Apply rules to array element values. |
### `MaskResult<T>`
```ts
interface MaskResult<T = unknown> {
masked: T;
fieldsAffected: string[];
}
```
| Property | Type | Description |
|------------------|------------|----------------------------------------------------|
| `masked` | `T` | Deep clone of input with masking applied. |
| `fieldsAffected` | `string[]` | Sorted dot-notation paths that were masked. |
## Implementation Notes
- This module is intentionally separated from strategy implementations to
avoid circular imports. Strategy files import `MaskRule` from here; the
masker core imports both strategies and types from here.
- `exactOptionalPropertyTypes` is enabled in `tsconfig.base.json`, so all
optional fields are typed as `T | undefined` rather than `T?` to avoid
accidental `undefined` assignment bypasses.

View File

@@ -0,0 +1,101 @@
/**
* Core type definitions for `@suaveplan/data-masking`.
*
* All public-facing interfaces and union types are declared here. Strategy
* implementation files import from this module to avoid circular references.
*/
/**
* The set of masking strategies available to a `MaskRule`.
*
* | Value | Behaviour |
* |--------------------|------------------------------------------------------------|
* | `'full'` | Replace entire value with `***` (or custom replacement). |
* | `'partial'` | Keep first/last N chars; mask the middle with `*`. |
* | `'email'` | Format: `u***@d***.tld` |
* | `'phone'` | Keep last 4 digits; mask others: `***-***-1234` |
* | `'credit-card'` | Keep last 4 digits: `****-****-****-1234` |
* | `'hash'` | FNV-1a 32-bit hash as 8-char hex string |
* | `'tokenize'` | Structured opaque token `TOKEN_<STRATEGY>_<N>` |
* | `'preserve-format'`| Replace alphanumerics with type placeholder (digit→0, letter→X) |
*/
export type MaskStrategy =
| "full"
| "partial"
| "email"
| "phone"
| "credit-card"
| "hash"
| "tokenize"
| "preserve-format";
/**
* A single masking rule that maps one or more field paths to a masking
* strategy.
*
* Field paths are dot-notation strings and support glob-style wildcards:
* - `*` matches any single path segment.
* - `**` matches any number of path segments (including zero).
*
* @example
* ```ts
* const rule: MaskRule = {
* fields: ['user.email', 'user.phone', 'payment.**'],
* strategy: 'partial',
* keepLast: 4,
* };
* ```
*/
export interface MaskRule {
/** Dot-notation field paths or glob patterns to apply this rule to. */
fields: string[];
/** The masking strategy to apply when a field path matches. */
strategy: MaskStrategy;
/**
* For `'partial'` strategy: number of characters to keep at the end of
* the value. Defaults to `4`.
*/
keepLast?: number | undefined;
/**
* For `'partial'` strategy: number of characters to keep at the start of
* the value. Defaults to `0`.
*/
keepFirst?: number | undefined;
/**
* For `'full'` strategy: custom replacement string. Defaults to `"***"`.
*/
replacement?: string | undefined;
}
/**
* Options controlling how an object traversal and masking pass is executed.
*/
export interface MaskOptions {
/** The ordered list of masking rules to evaluate. */
rules: MaskRule[];
/**
* When `true` (default), the masker recursively traverses nested plain
* objects. When `false`, only top-level fields are evaluated.
*/
deep?: boolean | undefined;
/**
* When `true` (default), the masker applies rules to elements inside
* arrays. When `false`, array values are passed through unmodified.
*/
maskArrays?: boolean | undefined;
}
/**
* The result returned by `maskObject`.
*
* @typeParam T - The type of the input object, preserved on `masked`.
*/
export interface MaskResult<T = unknown> {
/**
* A deep clone of the input with masking applied. The input object is
* never mutated.
*/
masked: T;
/** Sorted list of dot-notation paths that were masked during this call. */
fieldsAffected: string[];
}

View File

@@ -0,0 +1 @@
export { matchPath } from "./path-matcher";

View File

@@ -0,0 +1,88 @@
# path-matcher
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements a lightweight glob-style matcher for dot-notation object-path
strings used by `@suaveplan/data-masking`'s rule engine. When a `MaskRule`
declares `fields: ['user.*']` the path matcher is responsible for deciding
whether a path like `'user.email'` satisfies that pattern during object
traversal. Zero external dependencies are required — the entire implementation
is a recursive backtracking segment comparator that handles the three modes
callers need in practice.
## Features
- Exact matching: `"user.email"` matches `"user.email"`.
- Single-segment wildcard `*`: matches any one dot-separated segment.
- Multi-segment wildcard `**`: matches zero or more consecutive dot-separated
segments, enabling patterns like `"**.email"` or `"user.**"`.
- Correct handling of leading, trailing, and adjacent wildcards through
backtracking.
- Pure function — no state, no side-effects, zero allocations beyond the
initial `split`.
## Usage: Basic Example
```ts
import { matchPath } from '@suaveplan/data-masking/utils/path-matcher';
matchPath('user.email', 'user.email'); // true — exact
matchPath('user.name', 'user.email'); // false — no match
matchPath('user.email', 'user.*'); // true — single wildcard
```
## Usage: Advanced Example
```ts
import { matchPath } from '@suaveplan/data-masking/utils/path-matcher';
// Match any path ending in .email regardless of depth
matchPath('billing.contact.email', '**.email'); // true
matchPath('email', '**.email'); // true (** matches zero segs)
// Match everything under user.*
matchPath('user.address.city', 'user.**'); // true
matchPath('user.address.city', 'user.*'); // false — * is single-segment only
matchPath('user.address', 'user.*'); // true
// Combine wildcards
matchPath('a.b.c.d', '*.b.**.d'); // true
```
## API Reference
### `matchPath(fieldPath, pattern)`
```ts
function matchPath(fieldPath: string, pattern: string): boolean;
```
| Parameter | Type | Description |
|-------------|----------|--------------------------------------------------------|
| `fieldPath` | `string` | Concrete dot-notation path, e.g. `"user.address.zip"`. |
| `pattern` | `string` | Glob pattern with optional `*` / `**` wildcards. |
**Returns** `true` when `fieldPath` satisfies `pattern`, `false` otherwise.
**Does not throw.** Both empty strings are treated as single empty-segment
paths and will only match each other or a lone `*` / `**` pattern.
## Implementation Notes
- The implementation splits both inputs on `"."` and feeds the resulting
arrays into a recursive `matchSegments` helper. The recursion depth is
bounded by `Math.max(pathSegments, patternSegments)`, which in practice is
small (typically < 10 for real object paths).
- The `**` wildcard uses a greedy-with-backtrack strategy: it first attempts
to match zero path segments (advance only the pattern pointer), then
progressively tries consuming one more path segment per recursive call.
This guarantees correct results for patterns like `"**.email"` where `**`
must match zero leading segments.
- No regular expressions are used, avoiding edge-case escaping issues with
field names that contain regex metacharacters (dots, brackets, etc.).
- The function is intentionally kept free of any optional parameters or
configuration — the three modes (exact, `*`, `**`) cover every use case
encountered in PII field-path matching.

View File

@@ -0,0 +1,125 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { matchPath } from "./path-matcher.js";
describe("matchPath", () => {
describe("exact match", () => {
it("matches identical paths", { timeout: 5000 }, () => {
expect(matchPath("user.email", "user.email")).toBe(true);
});
it("does not match different paths", { timeout: 5000 }, () => {
expect(matchPath("user.name", "user.email")).toBe(false);
});
it("matches single-segment paths", { timeout: 5000 }, () => {
expect(matchPath("email", "email")).toBe(true);
});
it("does not match partially overlapping paths", {
timeout: 5000,
}, () => {
expect(matchPath("user.email.domain", "user.email")).toBe(false);
});
it("matches deep exact paths", { timeout: 5000 }, () => {
expect(matchPath("a.b.c.d", "a.b.c.d")).toBe(true);
});
});
describe("single-segment wildcard (*)", () => {
it("matches any single segment", { timeout: 5000 }, () => {
expect(matchPath("user.email", "user.*")).toBe(true);
});
it("matches other single segments", { timeout: 5000 }, () => {
expect(matchPath("user.name", "user.*")).toBe(true);
});
it("does not match multi-segment paths with single wildcard", {
timeout: 5000,
}, () => {
expect(matchPath("user.address.city", "user.*")).toBe(false);
});
it("matches at root level", { timeout: 5000 }, () => {
expect(matchPath("email", "*")).toBe(true);
});
it("handles multiple wildcards in pattern", { timeout: 5000 }, () => {
expect(matchPath("a.b.c", "*.b.*")).toBe(true);
});
it("does not match when wildcard count exceeds segments", {
timeout: 5000,
}, () => {
expect(matchPath("a.b", "*.*.*.c")).toBe(false);
});
});
describe("multi-segment wildcard (**)", () => {
it("matches zero segments at end", { timeout: 5000 }, () => {
expect(matchPath("user", "user.**")).toBe(true);
});
it("matches one segment", { timeout: 5000 }, () => {
expect(matchPath("user.email", "user.**")).toBe(true);
});
it("matches multiple segments", { timeout: 5000 }, () => {
expect(matchPath("user.address.city", "user.**")).toBe(true);
});
it("matches deep nesting", { timeout: 5000 }, () => {
expect(matchPath("user.a.b.c.d", "user.**")).toBe(true);
});
it("matches as only segment", { timeout: 5000 }, () => {
expect(matchPath("user.email", "**")).toBe(true);
});
it("matches single segment with leading **", { timeout: 5000 }, () => {
expect(matchPath("email", "**.email")).toBe(true);
});
it("matches nested path ending in email", { timeout: 5000 }, () => {
expect(matchPath("user.email", "**.email")).toBe(true);
});
it("matches deep nested path ending in email", {
timeout: 5000,
}, () => {
expect(matchPath("a.b.c.email", "**.email")).toBe(true);
});
it("does not match wrong suffix with **", { timeout: 5000 }, () => {
expect(matchPath("user.name", "**.email")).toBe(false);
});
it("handles ** in the middle", { timeout: 5000 }, () => {
expect(matchPath("a.b.c.d", "a.**.d")).toBe(true);
});
it("handles ** matching zero middle segments", {
timeout: 5000,
}, () => {
expect(matchPath("a.d", "a.**.d")).toBe(true);
});
});
describe("edge cases", () => {
it("handles empty-like patterns gracefully", { timeout: 5000 }, () => {
// Single dot segment path vs single wildcard
expect(matchPath("x", "*")).toBe(true);
});
it("combines * and ** wildcards correctly", { timeout: 5000 }, () => {
expect(matchPath("a.b.c.d", "*.b.**.d")).toBe(true);
});
it("does not falsely match with mismatched prefix", {
timeout: 5000,
}, () => {
expect(matchPath("billing.email", "user.*")).toBe(false);
});
});
});

View File

@@ -0,0 +1,84 @@
/**
* Glob-style field-path matcher for masking rule evaluation.
*
* Supports three matching modes that cover the common cases for nested-object
* traversal:
* - Exact match (`"user.email"` → `"user.email"`)
* - Single-segment wildcard (`*`) — matches any one dot-separated segment
* - Multi-segment wildcard (`**`) — matches any number of dot-separated
* segments (including zero when at the end of a pattern)
*/
/**
* Test whether a concrete `fieldPath` matches the supplied glob `pattern`.
*
* Rules:
* - `*` matches exactly one path segment (characters between dots).
* - `**` matches zero or more path segments.
* - All other characters must match literally (case-sensitive).
* - Leading/trailing dots are treated as empty segments and behave
* consistently with the wildcard rules above.
*
* @param fieldPath - The concrete dot-notation path, e.g. `"user.address.city"`.
* @param pattern - The glob pattern to test against, e.g. `"user.*"` or `"**.email"`.
* @returns `true` when `fieldPath` satisfies `pattern`, `false` otherwise.
*
* @example
* ```ts
* matchPath('user.email', 'user.email'); // true — exact
* matchPath('user.email', 'user.*'); // true — single-segment wildcard
* matchPath('user.address.city', 'user.**'); // true — multi-segment wildcard
* matchPath('billing.email', '**.email'); // true — leading multi-segment
* matchPath('user.name', 'user.email'); // false — no match
* ```
*/
export function matchPath(fieldPath: string, pattern: string): boolean {
const pathSegs = fieldPath.split(".");
const patSegs = pattern.split(".");
return matchSegments(pathSegs, 0, patSegs, 0);
}
/**
* Recursive backtracking matcher that aligns path segments with pattern
* segments, handling `**` greedily with fall-back.
*/
function matchSegments(
path: readonly string[],
pi: number,
pat: readonly string[],
si: number
): boolean {
// Both exhausted → match.
if (pi === path.length && si === pat.length) return true;
// Pattern exhausted but path has remaining segments → no match.
if (si === pat.length) return false;
const seg = pat[si];
if (seg === "**") {
// `**` can match zero or more path segments.
// Try consuming zero segments (advance only pattern).
if (matchSegments(path, pi, pat, si + 1)) return true;
// Try consuming one path segment at a time.
for (let consumed = pi; consumed < path.length; consumed++) {
if (matchSegments(path, consumed + 1, pat, si + 1)) return true;
}
return false;
}
// Path is exhausted but pattern still has non-`**` segments → no match.
if (pi === path.length) return false;
// `*` matches any single segment.
if (seg === "*") {
return matchSegments(path, pi + 1, pat, si + 1);
}
// Literal segment — must match exactly.
if (path[pi] === seg) {
return matchSegments(path, pi + 1, pat, si + 1);
}
return false;
}

View File

@@ -0,0 +1,53 @@
/**
* Verify that the runtime dependencies declared in package.json match those
* actually required by the source tree — no undeclared deps, no phantom deps.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const PKG_ROOT = join(import.meta.dirname, "..");
const pkg = JSON.parse(
readFileSync(join(PKG_ROOT, "package.json"), "utf-8")
) as {
dependencies: Record<string, string>;
devDependencies: Record<string, string>;
};
describe("dep-graph", () => {
it("has zod as a runtime dep", { timeout: 5000 }, () => {
expect(pkg.dependencies["zod"]).toBeDefined();
});
it("has tslib as a runtime dep", { timeout: 5000 }, () => {
expect(pkg.dependencies["tslib"]).toBeDefined();
});
it("has @opentelemetry/api as a runtime dep", { timeout: 5000 }, () => {
expect(pkg.dependencies["@opentelemetry/api"]).toBeDefined();
});
it("does NOT have @suaveplan/error as a runtime dep (leaf package)", {
timeout: 5000,
}, () => {
expect(pkg.dependencies["@suaveplan/error"]).toBeUndefined();
});
it("does NOT have @suaveplan/types as a runtime dep (leaf package)", {
timeout: 5000,
}, () => {
expect(pkg.dependencies["@suaveplan/types"]).toBeUndefined();
});
it("has vitest as a devDep only", { timeout: 5000 }, () => {
expect(pkg.devDependencies["vitest"]).toBeDefined();
expect(pkg.dependencies["vitest"]).toBeUndefined();
});
it("has @vitest/coverage-istanbul as a devDep only", {
timeout: 5000,
}, () => {
expect(pkg.devDependencies["@vitest/coverage-istanbul"]).toBeDefined();
expect(pkg.dependencies["@vitest/coverage-istanbul"]).toBeUndefined();
});
});

View File

@@ -0,0 +1,63 @@
/**
* Enforce that production source files do not use disallowed globals
* (Math.random, Date.now, crypto.randomUUID) that would make the library
* non-deterministic or platform-dependent.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const SRC_ROOT = join(import.meta.dirname, "../src");
const DISALLOWED = [
/\bMath\.random\b/,
/\bDate\.now\b/,
/\bcrypto\.randomUUID\b/,
];
// The tokenize strategy uses a module-level counter (no randomness).
// No files need to be approved for random usage.
const APPROVED_FILES: string[] = [];
function walkTs(dir: string): string[] {
const result: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
result.push(...walkTs(full));
} else if (entry.endsWith(".ts")) {
result.push(full);
}
}
return result;
}
function getSourceFiles(): string[] {
return walkTs(SRC_ROOT).filter(
(f) => !f.includes(".test.") && !f.includes(".bench.")
);
}
describe("no-disallowed globals", () => {
const sourceFiles = getSourceFiles();
it("found at least one source file to check", { timeout: 5000 }, () => {
expect(sourceFiles.length).toBeGreaterThan(0);
});
for (const pattern of DISALLOWED) {
it(`no source file uses ${pattern.source}`, { timeout: 5000 }, () => {
const violations: string[] = [];
for (const file of sourceFiles) {
const relative = file.replace(`${SRC_ROOT}/`, "");
if (APPROVED_FILES.some((ap) => file.endsWith(ap))) continue;
const content = readFileSync(file, "utf-8");
if (pattern.test(content)) {
violations.push(relative);
}
}
expect(violations).toEqual([]);
});
}
});

View File

@@ -0,0 +1,8 @@
// Ensure `globalThis.crypto` is available in bare-Node vitest runs.
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,51 @@
/**
* Verify tree-shaking readiness:
* - sideEffects: false is set in package.json
* - At least 5 named subpath exports are declared
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const PKG_ROOT = join(import.meta.dirname, "..");
const pkg = JSON.parse(
readFileSync(join(PKG_ROOT, "package.json"), "utf-8")
) as {
sideEffects: boolean | string[];
exports: Record<string, unknown>;
};
describe("tree-shake", () => {
it("sideEffects is false", { timeout: 5000 }, () => {
expect(pkg.sideEffects).toBe(false);
});
it("has at least 4 subpath exports (excluding package.json)", {
timeout: 5000,
}, () => {
const subpaths = Object.keys(pkg.exports).filter(
(k) => k !== "./package.json"
);
expect(subpaths.length).toBeGreaterThanOrEqual(4);
});
it("root export '.' is declared", { timeout: 5000 }, () => {
expect(pkg.exports["."]).toBeDefined();
});
it("'./masker' subpath export is declared", { timeout: 5000 }, () => {
expect(pkg.exports["./masker"]).toBeDefined();
});
it("'./strategies' subpath export is declared", { timeout: 5000 }, () => {
expect(pkg.exports["./strategies"]).toBeDefined();
});
it("'./errors' subpath export is declared", { timeout: 5000 }, () => {
expect(pkg.exports["./errors"]).toBeDefined();
});
it("'./schemas' subpath export is declared", { timeout: 5000 }, () => {
expect(pkg.exports["./schemas"]).toBeDefined();
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/data-masking",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: ["zod", "tslib", "@opentelemetry/api", /^node:/],
},
},
});

View File

@@ -0,0 +1,31 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/*.schema.ts",
"src/**/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});