@asaidimu/utils-sanitize
v1.0.12
Published
A collection of sanitize utilities.
Readme
@asaidimu/utils-sanitize
Field-level sanitization for documents, with support for configurable masking policies, recursive deep sanitization, multi-scope configuration merging, and HMAC-based hashing. Works in browsers and Node.js 18+ (Web Crypto API).
Installation
npm install @asaidimu/utils-sanitizeQuick Start
import {
DocumentSanitizer,
newSecureDefaultConfig,
commonEnvPatterns,
} from "@asaidimu/utils-sanitize";
// Create a sanitizer with common security patterns + env var value masking
const config = newSecureDefaultConfig();
config.patterns = [...config.patterns!, ...commonEnvPatterns(["DATABASE_URL"])];
const sanitizer = new DocumentSanitizer(config, "my-scope");
const doc = {
username: "alice",
password: "supersecret",
email: "[email protected]",
ssn: "123-45-6789",
k: process.env.API_KEY, // caught by value-pattern even though key is "k"
notes: "hello world",
};
const result = await sanitizer.sanitizeDocumentDeep(doc);
// {
// username: "alice",
// password: "***",
// email: "al*********om",
// ssn: "***",
// k: "***",
// notes: "hello world",
// }Policies
| Policy | Constant | Effect |
| ------------ | -------------- | ------------------------------------------------------------------------------- |
| "redact" | MaskRedact | Replaces value with "***" |
| "hash" | MaskHash | Replaces value with [HASH:XXXXXXXX] (first 8 hex chars of HMAC-SHA256) |
| "obscure" | MaskObscure | Shows prefix/suffix characters, replaces middle with a character (default: *) |
| "preserve" | MaskPreserve | Leaves the value unchanged (default) |
Policy Resolution
Every field goes through resolvePolicy(fieldName, value), which walks all matching rules and returns the most restrictive result — it never short-circuits on the first match.
Resolution collects candidates from three sources:
- Explicit field mapping (
config.fields) — keyed by exact field name - Key-patterns (
matchOn: "key") — tested against the field name - Value-patterns (
matchOn: "value") — tested against the string representation of the field value, regardless of key name
The most restrictive policy across all matches wins. For example, if a key-pattern resolves to "obscure" but a value-pattern resolves to "redact", the field is redacted.
Restrictiveness Ordering
redact > hash > obscure > preserve
Use mostRestrictivePolicy(a, b) to get the more restrictive of two policies.
Input Normalization
Before any policy is applied, all document entry points normalize the input through toPojo. This ensures the sanitizer output is always a serialization-safe plain object, since the output is assumed to be serialized (e.g. via JSON.stringify).
Normalization rules (applied recursively):
- Own string keys only — inherited properties and symbol-keyed properties are discarded
- Functions are dropped — function-valued keys are removed entirely; function elements inside arrays are compacted out
- Symbols are dropped — same treatment as functions
null/undefined— preserved as-isDate/Uint8Array— treated as leaf scalars, returned unchanged- Arrays — recursed element-by-element; non-serializable elements (functions, symbols) are removed, compacting the array
- All other objects — own string keys only, recursed into, producing a plain
Record - Primitives (
string,number,boolean,bigint) — pass through unchanged
API
Core Types
type MaskedFieldPolicy = "redact" | "hash" | "preserve" | "obscure";Sanitizer
Flat-document sanitizer. Applies policies to top-level fields only.
const s = new Sanitizer(config, logger?);
// Sanitize a single value
await s.sanitizeValue("fieldName", value);
// Sanitize a flat document (input is normalized via toPojo)
await s.sanitizeDocument({ field: value, ... });DocumentSanitizer
Extends Sanitizer with recursive deep sanitization and metadata handling.
const ds = new DocumentSanitizer(config, scopeID, logger?);
// Deep sanitization (nested objects, arrays; input normalized via toPojo)
await ds.sanitizeDocumentDeep(doc);
// Sanitize only metadata (preserves reserved system fields)
await ds.sanitizeMetadata(metadata);Deep sanitization behaviour:
- Plain objects — recursed, each key sanitized independently (policies are not inherited from parent field names)
- Arrays — recursed element-by-element
- Objects inside arrays have each key sanitized independently
- Scalars inside arrays inherit the parent field's policy via
<fieldName>[]lookup
- Dates and Uint8Arrays — treated as scalars (not recursed)
_metadata_— special handling: system fields (_version_,_created_,_updated_,_checksum_,_signature_) are preserved; user-defined fields are sanitized
FieldMaskConfig
interface FieldMaskConfig {
version?: string;
scope?: string;
defaultPolicy?: MaskedFieldPolicy; // default: "preserve"
fields?: Record<string, MaskedFieldPolicy>;
patterns?: PatternRule[];
obscureConfig?: ObscureConfig;
hashSecret?: string; // hex-encoded, ≥ 32 hex chars
description?: string;
}ObscureConfig
interface ObscureConfig {
prefixLength: number; // chars to show at start (default: 2)
suffixLength: number; // chars to show at end (default: 2)
replacement: string; // middle replacement char (default: "*")
maxLength: number; // normalize output length (0 = no limit, default: 0)
}When maxLength is set, the output is normalised to exactly maxLength, hiding the original value's length:
| Original | maxLength=12 | Notes |
| ------------------- | ---------------- | --------------- |
| "abc123" | "ab******23" | Padded to 12 |
| "1ea82440-9c3e" | "1ea8****9c3e" | Exact fit |
| "1ea82440-…-2651" | "1ea8****2651" | Truncated to 12 |
Values shorter than prefixLength + suffixLength + 1 are rendered as "[OBSCURED]".
PatternRule
interface PatternRule {
pattern: string; // regex source string
policy: MaskedFieldPolicy;
matchOn?: "key" | "value"; // default: "key"
comment?: string; // human-readable description
readonly _regex?: RegExp; // compiled regex (non-enumerable, internal)
}matchOn controls what the pattern is tested against:
"key"— pattern is tested against the field name. Used bycommonSecurityPatterns()."value"— pattern is tested against the string representation of the field value, regardless of key name. Used bycommonEnvPatterns()to catch cases like{ k: process.env.API_KEY }where the key carries no signal.
Both kinds of rules participate in the same resolvePolicy pass, with the most restrictive match across all rules winning.
Config Validation
validateConfig(config) validates a FieldMaskConfig, mutating it to apply defaults. Throws SystemError aggregating all validation errors when any are found (severity: "error"). Warnings (e.g., maxLength too small) are collected but do not throw. Also validates matchOn when present.
Configuration Merging
mergeConfigs(globalConfig, scopedConfig): FieldMaskConfigMerges a global config with a scoped config for multi-scope setups:
- Fields — global baseline, scoped overrides (scoped wins on collision)
- Patterns — scoped patterns evaluated first (higher priority); a scoped pattern shadows a global pattern only when both have the same
matchOnand the same regex source. A"key"pattern and a"value"pattern with the same regex are treated as distinct rules and never collapse. - Default policy — scoped wins, then global, then
"preserve" - Obscure config — scoped wins when it has a
replacementchar - Hash secret — scoped wins
Does not mutate either input.
Helpers
// Field policy parsing
parseMaskedFieldPolicy(s: string): MaskedFieldPolicy; // throws SystemError on invalid
// Policy comparison
mostRestrictivePolicy(a, b): MaskedFieldPolicy;
// Input normalization
toPojo(value: unknown): unknown; // recursively normalize to serialization-safe POJO
// Pattern compilation
compilePattern(pattern, policy, comment?, matchOn?): PatternRule; // throws on invalid regex
mustCompilePattern(pattern, policy, comment?, matchOn?): PatternRule; // alias (for static init)
// Pre-built key-name patterns
commonSecurityPatterns(): PatternRule[]; // 11 patterns matched against field names
newSecureDefaultConfig(): FieldMaskConfig; // config with common patterns + defaults
// Pre-built value patterns from environment variables
commonEnvPatterns(extraKeys?: string[], env?: Record<string, string | undefined>): PatternRule[];
// Batch sanitization
sanitizeDocumentArray(sanitizer, docs[]): Promise<Record<string, unknown>[]>;
sanitizeValue(sanitizer, value): Promise<unknown>; // auto-detect object/array/scalarcommonSecurityPatterns() — included patterns
All rules use matchOn: "key" — they match against field names.
| Pattern | Policy | Field examples |
| -------------------------- | --------- | ------------------------------ |
| password | redact | password, userPassword |
| secret | redact | secret, my_secret |
| token | redact | token, auth_token |
| api[_-]?key | redact | api_key, apikey, api-key |
| private[_-]?key | redact | private_key |
| credential | redact | credential, credentials |
| ssn\|social[_-]?security | redact | ssn, social_security |
| credit[_-]?card\|cvv | redact | credit_card, cvv |
| email | obscure | email, userEmail |
| phone\|mobile | obscure | phone, mobile |
| auth | hash | auth, authToken |
commonEnvPatterns(extraKeys?, env?) — value patterns from env
Builds matchOn: "value" rules from environment variables so that any field whose value equals a known secret is redacted, regardless of key name. This catches patterns like { k: process.env.API_KEY } where the key carries no signal.
const config = newSecureDefaultConfig();
config.patterns = [
...config.patterns!,
// heuristic: all env vars whose name contains key, secret, token, etc.
// + any explicitly named vars
...commonEnvPatterns(["DATABASE_URL"]),
];How rules are built:
- Collects env var names whose name matches the heuristic (
key,secret,token,password,passwd,credential,auth,api,private,cert,signing— case-insensitive) - Merges in
extraKeys; deduplicates by name - Skips values shorter than 8 characters (too short — high false-positive risk)
- Deduplicates by resolved value — two vars holding the same secret produce one rule
- Escapes each value as a literal regex so special characters do not alter matching semantics
- Returns
PatternRule[]withmatchOn: "value"andpolicy: "redact"
The optional env parameter (default: process.env) accepts any Record<string, string | undefined>, making the function fully testable without real environment state:
const rules = commonEnvPatterns(["MY_KEY"], { MY_KEY: "super-secret-value" });
// → one PatternRule, matchOn "value", literal pattern "super\-secret\-value"Hash Details
- Uses HMAC-SHA256 via the Web Crypto API (
crypto.subtle.sign) - Output format:
[HASH:XXXXXXXX](first 8 hex chars) - Each
Sanitizerinstance uses a per-instance random 32-byte secret whenhashSecretis not provided, preventing rainbow-table attacks - Consistent secret across instances = consistent hashes for the same input values
Obscure Details
- Uses
TextDecoderforUint8Arrayvalues - Falls back to
String()for all other non-string types - With
maxLength > 0, the output is normalised to exactlymaxLengthcharacters, hiding the original length
Error Handling
The module throws SystemError (from @core/error) with structured error codes:
| Code | Scenario |
| ---------------------------------- | ----------------------------------------------- |
| ERR_SANITIZATION_CONFIG_INVALID | Config validation failed |
| ERR_INVALID_POLICY | Unknown policy string |
| ERR_INVALID_MATCH_ON | matchOn value is not "key" or "value" |
| ERR_EMPTY_PATTERN | Pattern string is empty |
| ERR_INVALID_REGEX | Pattern regex failed to compile |
| ERR_INVALID_CONFIG | Invalid obscure config or hash secret |
| ERR_SANITIZATION_PATTERN_INVALID | compilePattern received invalid regex |
| ERR_SANITIZATION_INVALID_INPUT | Top-level document is not a plain object |
| ERR_SANITIZATION_FAILED | Document sanitization failed (batch operations) |
On invalid config, Sanitizer and DocumentSanitizer constructors fall back to a preserve-all default and log the error — they never throw from the constructor.
