Initial Commit
This commit is contained in:
128
packages/foundation/data/data-masking/README.md
Normal file
128
packages/foundation/data/data-masking/README.md
Normal 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
|
||||
100
packages/foundation/data/data-masking/package.json
Normal file
100
packages/foundation/data/data-masking/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
1
packages/foundation/data/data-masking/src/core/index.ts
Normal file
1
packages/foundation/data/data-masking/src/core/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { maskObject, maskString, maskValue } from "./masker";
|
||||
@@ -0,0 +1 @@
|
||||
export { maskObject, maskString, maskValue } from "./object-masker";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
DATA_MASKING_CODES,
|
||||
type DataMaskingCode,
|
||||
DataMaskingError,
|
||||
} from "./data-masking-error";
|
||||
34
packages/foundation/data/data-masking/src/index.ts
Normal file
34
packages/foundation/data/data-masking/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
type MaskOptionsInput,
|
||||
MaskOptionsSchema,
|
||||
type MaskRuleInput,
|
||||
MaskRuleSchema,
|
||||
type MaskStrategyInput,
|
||||
MaskStrategySchema,
|
||||
} from "./mask-rule.schema";
|
||||
@@ -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.
|
||||
@@ -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>;
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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***@***");
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
67
packages/foundation/data/data-masking/src/strategies/full.md
Normal file
67
packages/foundation/data/data-masking/src/strategies/full.md
Normal 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.
|
||||
@@ -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("***");
|
||||
});
|
||||
});
|
||||
17
packages/foundation/data/data-masking/src/strategies/full.ts
Normal file
17
packages/foundation/data/data-masking/src/strategies/full.ts
Normal 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 ?? "***";
|
||||
}
|
||||
79
packages/foundation/data/data-masking/src/strategies/hash.md
Normal file
79
packages/foundation/data/data-masking/src/strategies/hash.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
45
packages/foundation/data/data-masking/src/strategies/hash.ts
Normal file
45
packages/foundation/data/data-masking/src/strategies/hash.ts
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
6
packages/foundation/data/data-masking/src/types/index.ts
Normal file
6
packages/foundation/data/data-masking/src/types/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
MaskOptions,
|
||||
MaskResult,
|
||||
MaskRule,
|
||||
MaskStrategy,
|
||||
} from "./mask-types";
|
||||
130
packages/foundation/data/data-masking/src/types/mask-types.md
Normal file
130
packages/foundation/data/data-masking/src/types/mask-types.md
Normal 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.
|
||||
101
packages/foundation/data/data-masking/src/types/mask-types.ts
Normal file
101
packages/foundation/data/data-masking/src/types/mask-types.ts
Normal 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[];
|
||||
}
|
||||
1
packages/foundation/data/data-masking/src/utils/index.ts
Normal file
1
packages/foundation/data/data-masking/src/utils/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { matchPath } from "./path-matcher";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
12
packages/foundation/data/data-masking/tsconfig.lib.json
Normal file
12
packages/foundation/data/data-masking/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
30
packages/foundation/data/data-masking/vite.config.ts
Normal file
30
packages/foundation/data/data-masking/vite.config.ts
Normal 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:/],
|
||||
},
|
||||
},
|
||||
});
|
||||
31
packages/foundation/data/data-masking/vitest.config.ts
Normal file
31
packages/foundation/data/data-masking/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user