first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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.