@asaidimu/utils-sanitize 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Saidimu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,238 @@
1
+ # `@asaidimu/utils-sanitize`
2
+
3
+ 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).
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @asaidimu/utils-sanitize
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```typescript
14
+ import { Sanitizer, DocumentSanitizer, newSecureDefaultConfig } from "@asaidimu/utils-sanitize";
15
+
16
+ // Create a sanitizer with common security patterns
17
+ const config = newSecureDefaultConfig();
18
+ const sanitizer = new DocumentSanitizer(config, "my-scope");
19
+
20
+ const doc = {
21
+ username: "alice",
22
+ password: "supersecret",
23
+ email: "alice@example.com",
24
+ ssn: "123-45-6789",
25
+ notes: "hello world",
26
+ };
27
+
28
+ const result = await sanitizer.sanitizeDocumentDeep(doc);
29
+ // {
30
+ // username: "alice",
31
+ // password: "***",
32
+ // email: "al*********om",
33
+ // ssn: "***",
34
+ // notes: "hello world",
35
+ // }
36
+ ```
37
+
38
+ ## Policies
39
+
40
+ | Policy | Constant | Effect |
41
+ |--------|----------|--------|
42
+ | `"redact"` | `MaskRedact` | Replaces value with `"***"` |
43
+ | `"hash"` | `MaskHash` | Replaces value with `[HASH:XXXXXXXX]` (first 8 hex chars of HMAC-SHA256) |
44
+ | `"obscure"` | `MaskObscure` | Shows prefix/suffix characters, replaces middle with a character (default: `*`) |
45
+ | `"preserve"` | `MaskPreserve` | Leaves the value unchanged (default) |
46
+
47
+ ### Policy Resolution Order
48
+
49
+ When resolving which policy applies to a field:
50
+
51
+ 1. **Explicit field mapping** (`config.fields`) — highest priority
52
+ 2. **Pattern rules** (`config.patterns`) — evaluated in declaration order, first match wins
53
+ 3. **Default policy** (`config.defaultPolicy`) — fallback when no rule matches
54
+
55
+ ### Restrictiveness Ordering
56
+
57
+ redact > hash > obscure > preserve
58
+
59
+ Use `mostRestrictivePolicy(a, b)` to get the more restrictive of two policies.
60
+
61
+ ## API
62
+
63
+ ### Core Types
64
+
65
+ ```typescript
66
+ type MaskedFieldPolicy = "redact" | "hash" | "preserve" | "obscure";
67
+ ```
68
+
69
+ ### `Sanitizer`
70
+
71
+ Flat-document sanitizer. Applies policies to top-level fields only.
72
+
73
+ ```typescript
74
+ const s = new Sanitizer(config, logger?);
75
+
76
+ // Sanitize a single value
77
+ await s.sanitizeValue("fieldName", value);
78
+
79
+ // Sanitize a flat document
80
+ await s.sanitizeDocument({ field: value, ... });
81
+ ```
82
+
83
+ ### `DocumentSanitizer`
84
+
85
+ Extends `Sanitizer` with recursive deep sanitization and metadata handling.
86
+
87
+ ```typescript
88
+ const ds = new DocumentSanitizer(config, scopeID, logger?);
89
+
90
+ // Deep sanitization (nested objects, arrays)
91
+ await ds.sanitizeDocumentDeep(doc);
92
+
93
+ // Sanitize only metadata (preserves reserved system fields)
94
+ await ds.sanitizeMetadata(metadata);
95
+ ```
96
+
97
+ **Deep sanitization behaviour:**
98
+
99
+ - **Plain objects** — recursed, each key sanitized independently (policies are not inherited from parent field names)
100
+ - **Arrays** — recursed element-by-element
101
+ - Objects inside arrays have each key sanitized independently
102
+ - Scalars inside arrays inherit the parent field's policy via `<fieldName>[]` lookup
103
+ - **Dates** and **Uint8Arrays** — treated as scalars (not recursed)
104
+ - **`_metadata_`** — special handling: system fields (`_version_`, `_created_`, `_updated_`, `_checksum_`, `_signature_`) are preserved; user-defined fields are sanitized
105
+
106
+ ### `FieldMaskConfig`
107
+
108
+ ```typescript
109
+ interface FieldMaskConfig {
110
+ version?: string;
111
+ scope?: string;
112
+ defaultPolicy?: MaskedFieldPolicy; // default: "preserve"
113
+ fields?: Record<string, MaskedFieldPolicy>;
114
+ patterns?: PatternRule[];
115
+ obscureConfig?: ObscureConfig;
116
+ hashSecret?: string; // hex-encoded, ≥ 32 hex chars
117
+ description?: string;
118
+ }
119
+ ```
120
+
121
+ ### `ObscureConfig`
122
+
123
+ ```typescript
124
+ interface ObscureConfig {
125
+ prefixLength: number; // chars to show at start (default: 2)
126
+ suffixLength: number; // chars to show at end (default: 2)
127
+ replacement: string; // middle replacement char (default: "*")
128
+ maxLength: number; // normalize output length (0 = no limit, default: 0)
129
+ }
130
+ ```
131
+
132
+ When `maxLength` is set, the output is normalised to exactly `maxLength`, hiding the original value's length:
133
+
134
+ | Original | `maxLength=12` | Notes |
135
+ |----------|----------------|-------|
136
+ | `"abc123"` | `"ab******23"` | Padded to 12 |
137
+ | `"1ea82440-9c3e"` | `"1ea8****9c3e"` | Exact fit |
138
+ | `"1ea82440-…-2651"` | `"1ea8****2651"` | Truncated to 12 |
139
+
140
+ Values shorter than `prefixLength + suffixLength + 1` are rendered as `"[OBSCURED]"`.
141
+
142
+ ### `PatternRule`
143
+
144
+ ```typescript
145
+ interface PatternRule {
146
+ pattern: string; // regex source string
147
+ policy: MaskedFieldPolicy;
148
+ comment?: string; // human-readable description
149
+ readonly _regex?: RegExp; // compiled regex (non-enumerable, internal)
150
+ }
151
+ ```
152
+
153
+ ### Config Validation
154
+
155
+ `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.
156
+
157
+ ### Configuration Merging
158
+
159
+ ```typescript
160
+ mergeConfigs(globalConfig, scopedConfig): FieldMaskConfig
161
+ ```
162
+
163
+ Merges a global config with a scoped config for multi-scope setups:
164
+
165
+ - **Fields** — global baseline, scoped overrides (scoped wins on collision)
166
+ - **Patterns** — scoped patterns evaluated first (higher priority); scoped patterns shadow global patterns with the same regex source
167
+ - **Default policy** — scoped wins, then global, then `"preserve"`
168
+ - **Obscure config** — scoped wins when it has a `replacement` char
169
+ - **Hash secret** — scoped wins
170
+
171
+ Does not mutate either input.
172
+
173
+ ### Helpers
174
+
175
+ ```typescript
176
+ // Field policy parsing
177
+ parseMaskedFieldPolicy(s: string): MaskedFieldPolicy; // throws SystemError on invalid
178
+
179
+ // Policy comparison
180
+ mostRestrictivePolicy(a, b): MaskedFieldPolicy;
181
+
182
+ // Pattern compilation
183
+ compilePattern(pattern, policy, comment?): PatternRule; // throws SystemError on invalid regex
184
+ mustCompilePattern(pattern, policy, comment?): PatternRule; // alias (for static init)
185
+
186
+ // Pre-built patterns
187
+ commonSecurityPatterns(): PatternRule[]; // 11 common patterns (password, email, SSN, etc.)
188
+ newSecureDefaultConfig(): FieldMaskConfig; // config with common patterns + defaults
189
+
190
+ // Batch sanitization
191
+ sanitizeDocumentArray(sanitizer, docs[]): Promise<Record<string, unknown>[]>;
192
+ sanitizeAnyValue(sanitizer, value): Promise<unknown>; // auto-detect object/array/scalar
193
+ ```
194
+
195
+ ### `commonSecurityPatterns()` — included patterns
196
+
197
+ | Pattern | Policy | Field examples |
198
+ |---------|--------|----------------|
199
+ | `password` | redact | `password`, `userPassword` |
200
+ | `secret` | redact | `secret`, `my_secret` |
201
+ | `token` | redact | `token`, `auth_token` |
202
+ | `api[_-]?key` | redact | `api_key`, `apikey`, `api-key` |
203
+ | `private[_-]?key` | redact | `private_key` |
204
+ | `credential` | redact | `credential`, `credentials` |
205
+ | `ssn\|social[_-]?security` | redact | `ssn`, `social_security` |
206
+ | `credit[_-]?card\|cvv` | redact | `credit_card`, `cvv` |
207
+ | `email` | obscure | `email`, `userEmail` |
208
+ | `phone\|mobile` | obscure | `phone`, `mobile` |
209
+ | `auth` | hash | `auth`, `authToken` |
210
+
211
+ ## Hash Details
212
+
213
+ - Uses **HMAC-SHA256** via the Web Crypto API (`crypto.subtle.sign`)
214
+ - Output format: `[HASH:XXXXXXXX]` (first 8 hex chars)
215
+ - Each `Sanitizer` instance uses a per-instance random 32-byte secret when `hashSecret` is not provided, preventing rainbow-table attacks
216
+ - Consistent secret across instances = consistent hashes for the same input values
217
+
218
+ ## Obscure Details
219
+
220
+ - Uses `TextDecoder` for `Uint8Array` values
221
+ - Falls back to `String()` for all other types
222
+ - With `maxLength > 0`, the output is normalised to exactly `maxLength` characters, hiding the original length
223
+
224
+ ## Error Handling
225
+
226
+ The module throws `SystemError` (from `@core/error`) with structured error codes:
227
+
228
+ | Code | Scenario |
229
+ |------|----------|
230
+ | `ERR_SANITIZATION_CONFIG_INVALID` | Config validation failed |
231
+ | `ERR_INVALID_POLICY` | Unknown policy string |
232
+ | `ERR_EMPTY_PATTERN` | Pattern string is empty |
233
+ | `ERR_INVALID_REGEX` | Pattern regex failed to compile |
234
+ | `ERR_INVALID_CONFIG` | Invalid obscure config or hash secret |
235
+ | `ERR_SANITIZATION_PATTERN_INVALID` | `compilePattern` received invalid regex |
236
+ | `ERR_SANITIZATION_FAILED` | Document sanitization failed (batch operations) |
237
+
238
+ On invalid config, `Sanitizer` and `DocumentSanitizer` constructors fall back to a **preserve-all** default and log the error — they never throw from the constructor.
package/index.d.cts ADDED
@@ -0,0 +1,246 @@
1
+ //#region src/logger/logger.d.ts
2
+ /**
3
+ * Represents the severity level of a system log entry.
4
+ * Levels are ordered by increasing severity: trace, debug, info, warn, error.
5
+ */
6
+ type LogLevel = "trace" | "debug" | "info" | "warn" | "error";
7
+ /**
8
+ * Structure representing a fully contextualized system log entry ready for sinking.
9
+ */
10
+ interface SystemLog {
11
+ /** The severity level of the log. */
12
+ level: LogLevel;
13
+ /** A clear, human-readable string identifying the distinct event (e.g., "user_login_failed"). */
14
+ event: string;
15
+ /** Additional structured data specific to this log instantiation. */
16
+ data?: object;
17
+ /** Contextual data shared across the logger instance (e.g., traceIds, environment). */
18
+ context?: object;
19
+ /** Epoch timestamp in milliseconds denoting when the log event occurred. */
20
+ timestamp: number;
21
+ }
22
+ /**
23
+ * Primary logger interface providing level-specific log dispatching and context-chaining.
24
+ */
25
+ interface SystemLogger {
26
+ /** Logs high-volume, extremely fine-grained diagnostic details. */
27
+ trace(event: string, data?: object): void;
28
+ /** Logs diagnostic information useful during local development or debugging. */
29
+ debug(event: string, data?: object): void;
30
+ /** Logs standard operational events that track the healthy flow of the system. */
31
+ info(event: string, data?: object): void;
32
+ /** Alias for the `info` logging method. */
33
+ log(event: string, data?: object): void;
34
+ /** Logs non-fatal operational anomalies or conditions that deserve attention. */
35
+ warn(event: string, data?: object): void;
36
+ /** Logs critical errors, failures, or exceptions preventing a transaction/process. */
37
+ error(event: string, data?: unknown): void;
38
+ /**
39
+ * Directly forwards a pre-constructed `SystemLog` record through the logger's pipeline.
40
+ * Combines instance context before outputting.
41
+ */
42
+ write(record: SystemLog): void;
43
+ /**
44
+ * Generates a new `SystemLogger` instance that shares the existing sinks and parent
45
+ * context, deeply merging the newly provided context on top.
46
+ *
47
+ * @param context - The metadata to append to all subsequent logs emitted by the child logger.
48
+ */
49
+ child(context: object): SystemLogger;
50
+ }
51
+ //#endregion
52
+ //#region src/sanitize/index.d.ts
53
+ /** Defines how a field should be treated during sanitization. */
54
+ type MaskedFieldPolicy = "redact" | "hash" | "preserve" | "obscure";
55
+ declare const MaskRedact: MaskedFieldPolicy;
56
+ declare const MaskHash: MaskedFieldPolicy;
57
+ declare const MaskPreserve: MaskedFieldPolicy;
58
+ declare const MaskObscure: MaskedFieldPolicy;
59
+ /**
60
+ * Parses a string into a MaskedFieldPolicy.
61
+ * Throws a SystemError if the string is not a valid policy.
62
+ */
63
+ declare function parseMaskedFieldPolicy(s: string): MaskedFieldPolicy;
64
+ /** Returns the more restrictive of two policies. */
65
+ declare function mostRestrictivePolicy(a: MaskedFieldPolicy, b: MaskedFieldPolicy): MaskedFieldPolicy;
66
+ /** Allows regex-based matching on field names. */
67
+ interface PatternRule {
68
+ /** Regex source string. */
69
+ pattern: string;
70
+ /** Masking policy applied when pattern matches. */
71
+ policy: MaskedFieldPolicy;
72
+ /** Human-readable description. */
73
+ comment?: string;
74
+ /**
75
+ * Compiled regex — populated during validateConfig(). Internal use only.
76
+ * Non-enumerable so it does not appear in JSON.stringify output.
77
+ */
78
+ readonly _regex?: RegExp;
79
+ }
80
+ /** Controls how MaskObscure renders its output. */
81
+ interface ObscureConfig {
82
+ /** Number of characters to reveal at the start. */
83
+ prefixLength: number;
84
+ /** Number of characters to reveal at the end. */
85
+ suffixLength: number;
86
+ /** Replacement character for the obscured portion (default: `"*"`). */
87
+ replacement: string;
88
+ /**
89
+ * Maximum total length of the obscured output (0 = no limit).
90
+ * When set, the output is normalised to exactly this length,
91
+ * hiding the original value's length.
92
+ *
93
+ * Example with maxLength=12:
94
+ * Short: "abc123" → "ab******23" (padded to 12)
95
+ * Medium: "1ea82440-9c3e" → "1ea8****9c3e" (exact fit)
96
+ * Long: "1ea82440-…-2651" → "1ea8****2651" (truncated to 12)
97
+ *
98
+ * Values shorter than prefixLength + suffixLength + 1 are shown as "[OBSCURED]".
99
+ */
100
+ maxLength: number;
101
+ }
102
+ declare function defaultObscureConfig(): ObscureConfig;
103
+ /** Configuration for a field-masking sanitizer. */
104
+ interface FieldMaskConfig {
105
+ /** Semantic version for forward compatibility. */
106
+ version?: string;
107
+ /** Scope identifier — must be non-empty when used in multi-scope setups. */
108
+ scope?: string;
109
+ /** Policy applied when no explicit rule matches a field. Defaults to `"preserve"`. */
110
+ defaultPolicy?: MaskedFieldPolicy;
111
+ /** Explicit field-name → policy mapping (highest priority). */
112
+ fields?: Record<string, MaskedFieldPolicy>;
113
+ /** Regex-based rules evaluated after `fields`. */
114
+ patterns?: PatternRule[];
115
+ /** Controls MaskObscure rendering. */
116
+ obscureConfig?: ObscureConfig;
117
+ /**
118
+ * Hex-encoded HMAC secret (≥ 32 hex chars / 16 bytes).
119
+ * If omitted, a cryptographically random secret is generated per instance.
120
+ */
121
+ hashSecret?: string;
122
+ /** Human-readable context. */
123
+ description?: string;
124
+ }
125
+ interface ValidationIssue {
126
+ code: string;
127
+ message: string;
128
+ path?: string;
129
+ index?: number;
130
+ severity: "error" | "warning";
131
+ }
132
+ /**
133
+ * Validates a FieldMaskConfig, mutating it to set defaults where appropriate.
134
+ * Throws a SystemError aggregating all validation errors if any are found.
135
+ *
136
+ * Safe to call in any environment — no Node.js APIs used.
137
+ */
138
+ declare function validateConfig(config: FieldMaskConfig): void;
139
+ /** Handles field masking based on a FieldMaskConfig. */
140
+ declare class Sanitizer {
141
+ protected readonly config: FieldMaskConfig;
142
+ protected readonly logger: SystemLogger;
143
+ /** Raw secret bytes — kept for reference; actual signing uses cryptoKeyPromise. */
144
+ protected readonly hashSecretBytes: Uint8Array;
145
+ /**
146
+ * Lazily-imported CryptoKey for HMAC-SHA256.
147
+ * Imported once and reused across all hashValue() calls on this instance.
148
+ */
149
+ protected readonly cryptoKeyPromise: Promise<CryptoKey>;
150
+ /**
151
+ * Creates a new Sanitizer.
152
+ *
153
+ * Validates the config and applies defaults. If the config is invalid, falls
154
+ * back to a preserve-all default and logs the error.
155
+ *
156
+ * If `config.hashSecret` is not provided, a cryptographically random 32-byte
157
+ * secret is generated per instance via `crypto.getRandomValues`, preventing
158
+ * rainbow-table attacks on hashed values.
159
+ */
160
+ constructor(config: FieldMaskConfig, logger?: SystemLogger);
161
+ /**
162
+ * Applies masking rules to a flat document.
163
+ * Returns a new object — the original is never mutated.
164
+ */
165
+ sanitizeDocument(doc: Record<string, unknown>): Promise<Record<string, unknown>>;
166
+ /** Applies masking to a single named value. */
167
+ sanitizeValue(fieldName: string, value: unknown): Promise<unknown>;
168
+ /** Resolves which policy applies to a given field name. */
169
+ protected getPolicyForField(fieldName: string): MaskedFieldPolicy;
170
+ /** Applies the given masking policy to a value. */
171
+ protected applyPolicy(fieldName: string, value: unknown, policy: MaskedFieldPolicy): Promise<unknown>;
172
+ /**
173
+ * Creates a short HMAC-SHA256 hash of the value using the Web Crypto API.
174
+ * Uses a per-sanitizer secret to prevent rainbow-table attacks.
175
+ * Returns `[HASH:<8 hex chars>]`.
176
+ */
177
+ protected hashValue(value: unknown): Promise<string>;
178
+ /**
179
+ * Shows first/last characters with the middle replaced by `obscureConfig.replacement`.
180
+ * When `maxLength` is set, the output is normalised to exactly that length.
181
+ * Synchronous — no crypto involved.
182
+ */
183
+ protected obscureValue(value: unknown): string;
184
+ }
185
+ /** Extends Sanitizer with recursive document and metadata handling. */
186
+ declare class DocumentSanitizer extends Sanitizer {
187
+ private readonly scopeID;
188
+ constructor(config: FieldMaskConfig, scopeID: string, logger?: SystemLogger);
189
+ /**
190
+ * Performs deep sanitization of a document, recursing into nested objects and arrays.
191
+ * Returns a new object — the original is never mutated.
192
+ */
193
+ sanitizeDocumentDeep(doc: Record<string, unknown>): Promise<Record<string, unknown>>;
194
+ /** Sanitizes a metadata map: system fields are preserved, user-defined fields are sanitized. */
195
+ sanitizeMetadata(metadata: Record<string, unknown>): Promise<Record<string, unknown>>;
196
+ /** Recursively sanitizes a value, descending into nested maps and arrays. */
197
+ private sanitizeValueDeep;
198
+ protected applyPolicy(fieldName: string, value: unknown, policy: MaskedFieldPolicy): Promise<unknown>;
199
+ }
200
+ /**
201
+ * Merges a global config with a scoped config.
202
+ *
203
+ * Priority rules:
204
+ * - Scoped field mappings override global field mappings.
205
+ * - Scoped patterns shadow global patterns with the same regex source string,
206
+ * and are evaluated first (higher priority) in the merged result.
207
+ * - Scoped default policy, obscure config, and hash secret each override
208
+ * global when present.
209
+ *
210
+ * Does not mutate either input — returns a fresh FieldMaskConfig.
211
+ */
212
+ declare function mergeConfigs(globalConfig: FieldMaskConfig | null, scopedConfig: FieldMaskConfig): FieldMaskConfig;
213
+ /**
214
+ * Compiles a regex pattern and returns a PatternRule with the compiled regex attached.
215
+ * Throws a SystemError if the regex is invalid.
216
+ */
217
+ declare function compilePattern(pattern: string, policy: MaskedFieldPolicy, comment?: string): PatternRule;
218
+ /**
219
+ * Compiles a pattern and throws on error.
220
+ * Use only for static initialization at module load time.
221
+ */
222
+ declare function mustCompilePattern(pattern: string, policy: MaskedFieldPolicy, comment?: string): PatternRule;
223
+ /**
224
+ * Returns a set of commonly used security-oriented masking patterns.
225
+ *
226
+ * Note: Go's inline `(?i)` flag is replaced with the JS `i` regex flag,
227
+ * which is semantically equivalent for ASCII field names.
228
+ */
229
+ declare function commonSecurityPatterns(): PatternRule[];
230
+ /** Creates a FieldMaskConfig pre-loaded with common security patterns. */
231
+ declare function newSecureDefaultConfig(): FieldMaskConfig;
232
+ /**
233
+ * Sanitizes an array of documents using the same DocumentSanitizer instance.
234
+ * Returns a new array — originals are not mutated.
235
+ * Throws a SystemError if any document fails sanitization.
236
+ */
237
+ declare function sanitizeDocumentArray(sanitizer: DocumentSanitizer, docs: Record<string, unknown>[]): Promise<Record<string, unknown>[]>;
238
+ /**
239
+ * Sanitizes any value that might be or contain documents.
240
+ * - Plain objects are treated as documents and sanitized deeply.
241
+ * - Arrays are recursed element-by-element.
242
+ * - Scalars are returned as-is (no field-name context at the top level).
243
+ */
244
+ declare function sanitizeValue(sanitizer: DocumentSanitizer, value: unknown): Promise<unknown>;
245
+ //#endregion
246
+ export { DocumentSanitizer, FieldMaskConfig, MaskHash, MaskObscure, MaskPreserve, MaskRedact, MaskedFieldPolicy, ObscureConfig, PatternRule, Sanitizer, ValidationIssue, commonSecurityPatterns, compilePattern, defaultObscureConfig, mergeConfigs, mostRestrictivePolicy, mustCompilePattern, newSecureDefaultConfig, parseMaskedFieldPolicy, sanitizeDocumentArray, sanitizeValue, validateConfig };
package/index.d.ts ADDED
@@ -0,0 +1,197 @@
1
+ import { SystemLogger } from "@asaidimu/utils-logger";
2
+
3
+ //#region src/sanitize/index.d.ts
4
+ /** Defines how a field should be treated during sanitization. */
5
+ type MaskedFieldPolicy = "redact" | "hash" | "preserve" | "obscure";
6
+ declare const MaskRedact: MaskedFieldPolicy;
7
+ declare const MaskHash: MaskedFieldPolicy;
8
+ declare const MaskPreserve: MaskedFieldPolicy;
9
+ declare const MaskObscure: MaskedFieldPolicy;
10
+ /**
11
+ * Parses a string into a MaskedFieldPolicy.
12
+ * Throws a SystemError if the string is not a valid policy.
13
+ */
14
+ declare function parseMaskedFieldPolicy(s: string): MaskedFieldPolicy;
15
+ /** Returns the more restrictive of two policies. */
16
+ declare function mostRestrictivePolicy(a: MaskedFieldPolicy, b: MaskedFieldPolicy): MaskedFieldPolicy;
17
+ /** Allows regex-based matching on field names. */
18
+ interface PatternRule {
19
+ /** Regex source string. */
20
+ pattern: string;
21
+ /** Masking policy applied when pattern matches. */
22
+ policy: MaskedFieldPolicy;
23
+ /** Human-readable description. */
24
+ comment?: string;
25
+ /**
26
+ * Compiled regex — populated during validateConfig(). Internal use only.
27
+ * Non-enumerable so it does not appear in JSON.stringify output.
28
+ */
29
+ readonly _regex?: RegExp;
30
+ }
31
+ /** Controls how MaskObscure renders its output. */
32
+ interface ObscureConfig {
33
+ /** Number of characters to reveal at the start. */
34
+ prefixLength: number;
35
+ /** Number of characters to reveal at the end. */
36
+ suffixLength: number;
37
+ /** Replacement character for the obscured portion (default: `"*"`). */
38
+ replacement: string;
39
+ /**
40
+ * Maximum total length of the obscured output (0 = no limit).
41
+ * When set, the output is normalised to exactly this length,
42
+ * hiding the original value's length.
43
+ *
44
+ * Example with maxLength=12:
45
+ * Short: "abc123" → "ab******23" (padded to 12)
46
+ * Medium: "1ea82440-9c3e" → "1ea8****9c3e" (exact fit)
47
+ * Long: "1ea82440-…-2651" → "1ea8****2651" (truncated to 12)
48
+ *
49
+ * Values shorter than prefixLength + suffixLength + 1 are shown as "[OBSCURED]".
50
+ */
51
+ maxLength: number;
52
+ }
53
+ declare function defaultObscureConfig(): ObscureConfig;
54
+ /** Configuration for a field-masking sanitizer. */
55
+ interface FieldMaskConfig {
56
+ /** Semantic version for forward compatibility. */
57
+ version?: string;
58
+ /** Scope identifier — must be non-empty when used in multi-scope setups. */
59
+ scope?: string;
60
+ /** Policy applied when no explicit rule matches a field. Defaults to `"preserve"`. */
61
+ defaultPolicy?: MaskedFieldPolicy;
62
+ /** Explicit field-name → policy mapping (highest priority). */
63
+ fields?: Record<string, MaskedFieldPolicy>;
64
+ /** Regex-based rules evaluated after `fields`. */
65
+ patterns?: PatternRule[];
66
+ /** Controls MaskObscure rendering. */
67
+ obscureConfig?: ObscureConfig;
68
+ /**
69
+ * Hex-encoded HMAC secret (≥ 32 hex chars / 16 bytes).
70
+ * If omitted, a cryptographically random secret is generated per instance.
71
+ */
72
+ hashSecret?: string;
73
+ /** Human-readable context. */
74
+ description?: string;
75
+ }
76
+ interface ValidationIssue {
77
+ code: string;
78
+ message: string;
79
+ path?: string;
80
+ index?: number;
81
+ severity: "error" | "warning";
82
+ }
83
+ /**
84
+ * Validates a FieldMaskConfig, mutating it to set defaults where appropriate.
85
+ * Throws a SystemError aggregating all validation errors if any are found.
86
+ *
87
+ * Safe to call in any environment — no Node.js APIs used.
88
+ */
89
+ declare function validateConfig(config: FieldMaskConfig): void;
90
+ /** Handles field masking based on a FieldMaskConfig. */
91
+ declare class Sanitizer {
92
+ protected readonly config: FieldMaskConfig;
93
+ protected readonly logger: SystemLogger;
94
+ /** Raw secret bytes — kept for reference; actual signing uses cryptoKeyPromise. */
95
+ protected readonly hashSecretBytes: Uint8Array;
96
+ /**
97
+ * Lazily-imported CryptoKey for HMAC-SHA256.
98
+ * Imported once and reused across all hashValue() calls on this instance.
99
+ */
100
+ protected readonly cryptoKeyPromise: Promise<CryptoKey>;
101
+ /**
102
+ * Creates a new Sanitizer.
103
+ *
104
+ * Validates the config and applies defaults. If the config is invalid, falls
105
+ * back to a preserve-all default and logs the error.
106
+ *
107
+ * If `config.hashSecret` is not provided, a cryptographically random 32-byte
108
+ * secret is generated per instance via `crypto.getRandomValues`, preventing
109
+ * rainbow-table attacks on hashed values.
110
+ */
111
+ constructor(config: FieldMaskConfig, logger?: SystemLogger);
112
+ /**
113
+ * Applies masking rules to a flat document.
114
+ * Returns a new object — the original is never mutated.
115
+ */
116
+ sanitizeDocument(doc: Record<string, unknown>): Promise<Record<string, unknown>>;
117
+ /** Applies masking to a single named value. */
118
+ sanitizeValue(fieldName: string, value: unknown): Promise<unknown>;
119
+ /** Resolves which policy applies to a given field name. */
120
+ protected getPolicyForField(fieldName: string): MaskedFieldPolicy;
121
+ /** Applies the given masking policy to a value. */
122
+ protected applyPolicy(fieldName: string, value: unknown, policy: MaskedFieldPolicy): Promise<unknown>;
123
+ /**
124
+ * Creates a short HMAC-SHA256 hash of the value using the Web Crypto API.
125
+ * Uses a per-sanitizer secret to prevent rainbow-table attacks.
126
+ * Returns `[HASH:<8 hex chars>]`.
127
+ */
128
+ protected hashValue(value: unknown): Promise<string>;
129
+ /**
130
+ * Shows first/last characters with the middle replaced by `obscureConfig.replacement`.
131
+ * When `maxLength` is set, the output is normalised to exactly that length.
132
+ * Synchronous — no crypto involved.
133
+ */
134
+ protected obscureValue(value: unknown): string;
135
+ }
136
+ /** Extends Sanitizer with recursive document and metadata handling. */
137
+ declare class DocumentSanitizer extends Sanitizer {
138
+ private readonly scopeID;
139
+ constructor(config: FieldMaskConfig, scopeID: string, logger?: SystemLogger);
140
+ /**
141
+ * Performs deep sanitization of a document, recursing into nested objects and arrays.
142
+ * Returns a new object — the original is never mutated.
143
+ */
144
+ sanitizeDocumentDeep(doc: Record<string, unknown>): Promise<Record<string, unknown>>;
145
+ /** Sanitizes a metadata map: system fields are preserved, user-defined fields are sanitized. */
146
+ sanitizeMetadata(metadata: Record<string, unknown>): Promise<Record<string, unknown>>;
147
+ /** Recursively sanitizes a value, descending into nested maps and arrays. */
148
+ private sanitizeValueDeep;
149
+ protected applyPolicy(fieldName: string, value: unknown, policy: MaskedFieldPolicy): Promise<unknown>;
150
+ }
151
+ /**
152
+ * Merges a global config with a scoped config.
153
+ *
154
+ * Priority rules:
155
+ * - Scoped field mappings override global field mappings.
156
+ * - Scoped patterns shadow global patterns with the same regex source string,
157
+ * and are evaluated first (higher priority) in the merged result.
158
+ * - Scoped default policy, obscure config, and hash secret each override
159
+ * global when present.
160
+ *
161
+ * Does not mutate either input — returns a fresh FieldMaskConfig.
162
+ */
163
+ declare function mergeConfigs(globalConfig: FieldMaskConfig | null, scopedConfig: FieldMaskConfig): FieldMaskConfig;
164
+ /**
165
+ * Compiles a regex pattern and returns a PatternRule with the compiled regex attached.
166
+ * Throws a SystemError if the regex is invalid.
167
+ */
168
+ declare function compilePattern(pattern: string, policy: MaskedFieldPolicy, comment?: string): PatternRule;
169
+ /**
170
+ * Compiles a pattern and throws on error.
171
+ * Use only for static initialization at module load time.
172
+ */
173
+ declare function mustCompilePattern(pattern: string, policy: MaskedFieldPolicy, comment?: string): PatternRule;
174
+ /**
175
+ * Returns a set of commonly used security-oriented masking patterns.
176
+ *
177
+ * Note: Go's inline `(?i)` flag is replaced with the JS `i` regex flag,
178
+ * which is semantically equivalent for ASCII field names.
179
+ */
180
+ declare function commonSecurityPatterns(): PatternRule[];
181
+ /** Creates a FieldMaskConfig pre-loaded with common security patterns. */
182
+ declare function newSecureDefaultConfig(): FieldMaskConfig;
183
+ /**
184
+ * Sanitizes an array of documents using the same DocumentSanitizer instance.
185
+ * Returns a new array — originals are not mutated.
186
+ * Throws a SystemError if any document fails sanitization.
187
+ */
188
+ declare function sanitizeDocumentArray(sanitizer: DocumentSanitizer, docs: Record<string, unknown>[]): Promise<Record<string, unknown>[]>;
189
+ /**
190
+ * Sanitizes any value that might be or contain documents.
191
+ * - Plain objects are treated as documents and sanitized deeply.
192
+ * - Arrays are recursed element-by-element.
193
+ * - Scalars are returned as-is (no field-name context at the top level).
194
+ */
195
+ declare function sanitizeValue(sanitizer: DocumentSanitizer, value: unknown): Promise<unknown>;
196
+ //#endregion
197
+ export { DocumentSanitizer, FieldMaskConfig, MaskHash, MaskObscure, MaskPreserve, MaskRedact, MaskedFieldPolicy, ObscureConfig, PatternRule, Sanitizer, ValidationIssue, commonSecurityPatterns, compilePattern, defaultObscureConfig, mergeConfigs, mostRestrictivePolicy, mustCompilePattern, newSecureDefaultConfig, parseMaskedFieldPolicy, sanitizeDocumentArray, sanitizeValue, validateConfig };
package/index.js ADDED
@@ -0,0 +1 @@
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});let e=require("@asaidimu/utils-error"),t=require("@asaidimu/utils-logger");const n=`redact`,r=`hash`,i=`preserve`,a=`obscure`;function o(t){switch(t.toLowerCase()){case`redact`:return n;case`hash`:return r;case`obscure`:return a;case`preserve`:return i;default:throw new e.SystemError({code:`ERR_SANITIZATION_CONFIG_INVALID`,message:`unknown policy: ${t} (valid: redact, hash, obscure, preserve)`})}}const s={redact:4,hash:3,obscure:2,preserve:1};function c(e,t){return s[e]>=s[t]?e:t}function l(){return{prefixLength:2,suffixLength:2,replacement:`*`,maxLength:0}}function u(t){let n=[];for(let[e,r]of Object.entries(t.fields??{}))try{o(r)}catch{n.push({code:`ERR_INVALID_POLICY`,message:`invalid policy "${r}" for field "${e}"`,path:`fields.${e}`,severity:`error`})}let r=[];for(let e=0;e<(t.patterns??[]).length;e++){let i=t.patterns[e];if(!i.pattern){n.push({code:`ERR_EMPTY_PATTERN`,message:`pattern string is empty`,path:`patterns`,index:e,severity:`error`}),r.push(i);continue}let a;try{a=new RegExp(i.pattern)}catch(t){n.push({code:`ERR_INVALID_REGEX`,message:`invalid regex: ${String(t)}`,path:`patterns`,index:e,severity:`error`}),r.push(i);continue}try{o(i.policy)}catch{n.push({code:`ERR_INVALID_POLICY`,message:`invalid policy "${i.policy}"`,path:`patterns`,index:e,severity:`error`})}let s={...i};Object.defineProperty(s,"_regex",{value:a,enumerable:!1,writable:!1,configurable:!1}),r.push(s)}if(t.patterns=r,t.defaultPolicy!==void 0)try{o(t.defaultPolicy)}catch{n.push({code:`ERR_INVALID_POLICY`,message:`invalid default policy "${t.defaultPolicy}"`,path:`default_policy`,severity:`error`})}else t.defaultPolicy=i;if(!t.obscureConfig||!t.obscureConfig.replacement)t.obscureConfig=l();else{let e=t.obscureConfig;if(e.prefixLength<0&&n.push({code:`ERR_INVALID_CONFIG`,message:`prefixLength must be >= 0`,path:`obscure.prefixLength`,severity:`error`}),e.suffixLength<0&&n.push({code:`ERR_INVALID_CONFIG`,message:`suffixLength must be >= 0`,path:`obscure.suffixLength`,severity:`error`}),e.maxLength<0&&n.push({code:`ERR_INVALID_CONFIG`,message:`maxLength must be >= 0`,path:`obscure.maxLength`,severity:`error`}),e.maxLength>0){let t=e.prefixLength+e.suffixLength+1;e.maxLength<t&&n.push({code:`ERR_INVALID_CONFIG`,message:`maxLength (${e.maxLength}) is too small for prefix (${e.prefixLength}) + suffix (${e.suffixLength}) + 1`,path:`obscure.maxLength`,severity:`warning`})}}if(t.hashSecret!==void 0&&t.hashSecret!==``){let e=t.hashSecret;e.length%2==0&&/^[0-9a-fA-F]+$/.test(e)?e.length<32&&n.push({code:`ERR_INVALID_CONFIG`,message:`hashSecret too short: must be at least 16 bytes (32 hex chars)`,path:`hash_secret`,severity:`error`}):n.push({code:`ERR_INVALID_CONFIG`,message:`hashSecret must be a valid hex-encoded string`,path:`hash_secret`,severity:`error`})}let a=n.filter(e=>e.severity===`error`);if(a.length>0)throw new e.SystemError({code:`ERR_SANITIZATION_CONFIG_INVALID`,message:`sanitization config validation failed`,issues:a.map(e=>({code:e.code,message:e.message,path:e.path,index:e.index,severity:`error`}))})}function d(e){let t=new Uint8Array(e.length/2);for(let n=0;n<t.length;n++)t[n]=parseInt(e.slice(n*2,n*2+2),16);return t}function f(e){return Array.from(new Uint8Array(e)).map(e=>e.toString(16).padStart(2,`0`)).join(``)}function p(){let e=new Uint8Array(32);return crypto.getRandomValues(e),e}function m(e){return crypto.subtle.importKey(`raw`,e,{name:`HMAC`,hash:`SHA-256`},!1,[`sign`])}var h=class{config;logger;hashSecretBytes;cryptoKeyPromise;constructor(e,n){this.logger=n??new t.Logger([]);let r={...e};try{u(r)}catch(e){this.logger.error(`sanitizer_config_invalid`,{error:String(e),fallback:`preserve-all`}),r={defaultPolicy:i,obscureConfig:l()},u(r)}this.config=r;let a;if(r.hashSecret){let e=d(r.hashSecret);e.length>=16?(a=e,this.logger.debug(`sanitizer_hash_secret_loaded`)):(this.logger.warn(`sanitizer_hash_secret_invalid`,{reason:`too short, generating random secret`}),a=p())}else a=p();this.hashSecretBytes=a,this.cryptoKeyPromise=m(a)}async sanitizeDocument(e){let t=await Promise.all(Object.entries(e).map(async([e,t])=>{let n=this.getPolicyForField(e);return[e,await this.applyPolicy(e,t,n)]}));return Object.fromEntries(t)}async sanitizeValue(e,t){let n=this.getPolicyForField(e);return this.applyPolicy(e,t,n)}getPolicyForField(e){let t=this.config.fields?.[e];if(t!==void 0)return t;for(let t of this.config.patterns??[])if(t._regex?.test(e))return t.policy;return this.config.defaultPolicy??`preserve`}async applyPolicy(e,t,n){if(t==null)return t;switch(n){case`redact`:return`***`;case`hash`:return this.hashValue(t);case`obscure`:return this.obscureValue(t);case`preserve`:return t;default:return this.logger.warn(`sanitizer_unknown_policy`,{field:e,policy:n}),t}}async hashValue(e){let t=E(e),n=await this.cryptoKeyPromise,r=new TextEncoder().encode(t);return`[HASH:${f(await crypto.subtle.sign(`HMAC`,n,r)).slice(0,8)}]`}obscureValue(e){let t=E(e),{prefixLength:n,suffixLength:r,replacement:i,maxLength:a}=this.config.obscureConfig??l(),o=t.length;if(o<=n+r+1)return`[OBSCURED]`;let s=t.slice(0,n),c=t.slice(o-r),u;if(a>0){let e=a-n-r;if(e<1)return`[OBSCURED]`;u=e}else u=o-n-r;return`${s}${i.repeat(u)}${c}`}};const g=new Set([`_version_`,`_created_`,`_updated_`,`_checksum_`,`_signature_`]);function _(e){return g.has(e)}var v=class extends h{scopeID;constructor(e,t,n){super(e,n),this.scopeID=t}async sanitizeDocumentDeep(e){let t=await Promise.all(Object.entries(e).map(async([e,t])=>e===`_metadata_`&&D(t)?[e,await this.sanitizeMetadata(t)]:[e,await this.sanitizeValueDeep(e,t)]));return Object.fromEntries(t)}async sanitizeMetadata(e){let t=await Promise.all(Object.entries(e).map(async([e,t])=>_(e)?[e,t]:[e,await this.sanitizeValueDeep(e,t)]));return Object.fromEntries(t)}async sanitizeValueDeep(e,t){if(t==null)return t;if(D(t)){let e=await Promise.all(Object.entries(t).map(async([e,t])=>[e,await this.sanitizeValueDeep(e,t)]));return Object.fromEntries(e)}if(Array.isArray(t))return Promise.all(t.map(async t=>{if(D(t)){let e=await Promise.all(Object.entries(t).map(async([e,t])=>[e,await this.sanitizeValueDeep(e,t)]));return Object.fromEntries(e)}return this.sanitizeValueDeep(`${e}[]`,t)}));let n=this.getPolicyForField(e);return this.applyPolicy(e,t,n)}async applyPolicy(e,t,n){if(t==null)return t;switch(n){case`redact`:case`hash`:case`obscure`:case`preserve`:return super.applyPolicy(e,t,n);default:return this.logger.warn(`sanitizer_unknown_policy`,{scope:this.scopeID,field:e,policy:n}),t}}};function y(e,t){let n={fields:{},patterns:[]};Object.assign(n.fields,e?.fields??{}),Object.assign(n.fields,t.fields??{});let r=new Map;for(let t of e?.patterns??[])r.set(t.pattern,t);for(let e of t.patterns??[])r.set(e.pattern,e);let i=new Set((t.patterns??[]).map(e=>e.pattern)),a=(t.patterns??[]).map(e=>r.get(e.pattern)),o=(e?.patterns??[]).filter(e=>!i.has(e.pattern)).map(e=>r.get(e.pattern));return n.patterns=[...a,...o],n.defaultPolicy=t.defaultPolicy??e?.defaultPolicy??`preserve`,t.obscureConfig?.replacement?n.obscureConfig=t.obscureConfig:e?.obscureConfig&&(n.obscureConfig=e.obscureConfig),n.hashSecret=t.hashSecret??e?.hashSecret,n}function b(t,n,r){let i;try{i=new RegExp(t)}catch(n){throw new e.SystemError({code:`ERR_SANITIZATION_PATTERN_INVALID`,message:`failed to compile pattern "${t}"`,cause:n})}let a={pattern:t,policy:n,comment:r};return Object.defineProperty(a,"_regex",{value:i,enumerable:!1,writable:!1,configurable:!1}),a}function x(e,t,n){return b(e,t,n)}function S(){return[b(`password`,n,`Passwords`),b(`secret`,n,`Secrets`),b(`token`,n,`Tokens`),b(`api[_-]?key`,n,`API keys`),b(`private[_-]?key`,n,`Private keys`),b(`credential`,n,`Credentials`),b(`auth`,r,`Auth fields`),b(`ssn|social[_-]?security`,n,`SSN`),b(`credit[_-]?card|cvv`,n,`Payment card data`),b(`email`,a,`Email addresses`),b(`phone|mobile`,a,`Phone numbers`)].map(({pattern:e,policy:t,comment:n})=>b(`(?i)${e}`.replace(/^\(\?i\)/,``),t,n))}function C(){return{version:`v1`,fields:{},patterns:S(),defaultPolicy:i,obscureConfig:l()}}async function w(t,n){return Promise.all(n.map(async(n,r)=>{try{return await t.sanitizeDocumentDeep(n)}catch(t){throw new e.SystemError({code:`ERR_SANITIZATION_FAILED`,message:`failed to sanitize document at index ${r}`,cause:t,issues:[{code:`ERR_SANITIZATION_FAILED`,message:String(t),index:r,severity:`error`}]})}}))}async function T(e,t){return t==null?t:D(t)?e.sanitizeDocumentDeep(t):Array.isArray(t)?Promise.all(t.map(t=>T(e,t))):t}function E(e){return typeof e==`string`?e:e instanceof Uint8Array?new TextDecoder().decode(e):String(e)}function D(e){return typeof e==`object`&&!!e&&!Array.isArray(e)&&!(e instanceof Uint8Array)&&!(e instanceof Date)}exports.DocumentSanitizer=v,exports.MaskHash=r,exports.MaskObscure=a,exports.MaskPreserve=i,exports.MaskRedact=n,exports.Sanitizer=h,exports.commonSecurityPatterns=S,exports.compilePattern=b,exports.defaultObscureConfig=l,exports.mergeConfigs=y,exports.mostRestrictivePolicy=c,exports.mustCompilePattern=x,exports.newSecureDefaultConfig=C,exports.parseMaskedFieldPolicy=o,exports.sanitizeDocumentArray=w,exports.sanitizeValue=T,exports.validateConfig=u;
package/index.mjs ADDED
@@ -0,0 +1 @@
1
+ import{SystemError as e}from"@asaidimu/utils-error";import{Logger as t}from"@asaidimu/utils-logger";const n=`redact`,r=`hash`,i=`preserve`,a=`obscure`;function o(t){switch(t.toLowerCase()){case`redact`:return n;case`hash`:return r;case`obscure`:return a;case`preserve`:return i;default:throw new e({code:`ERR_SANITIZATION_CONFIG_INVALID`,message:`unknown policy: ${t} (valid: redact, hash, obscure, preserve)`})}}const s={redact:4,hash:3,obscure:2,preserve:1};function c(e,t){return s[e]>=s[t]?e:t}function l(){return{prefixLength:2,suffixLength:2,replacement:`*`,maxLength:0}}function u(t){let n=[];for(let[e,r]of Object.entries(t.fields??{}))try{o(r)}catch{n.push({code:`ERR_INVALID_POLICY`,message:`invalid policy "${r}" for field "${e}"`,path:`fields.${e}`,severity:`error`})}let r=[];for(let e=0;e<(t.patterns??[]).length;e++){let i=t.patterns[e];if(!i.pattern){n.push({code:`ERR_EMPTY_PATTERN`,message:`pattern string is empty`,path:`patterns`,index:e,severity:`error`}),r.push(i);continue}let a;try{a=new RegExp(i.pattern)}catch(t){n.push({code:`ERR_INVALID_REGEX`,message:`invalid regex: ${String(t)}`,path:`patterns`,index:e,severity:`error`}),r.push(i);continue}try{o(i.policy)}catch{n.push({code:`ERR_INVALID_POLICY`,message:`invalid policy "${i.policy}"`,path:`patterns`,index:e,severity:`error`})}let s={...i};Object.defineProperty(s,"_regex",{value:a,enumerable:!1,writable:!1,configurable:!1}),r.push(s)}if(t.patterns=r,t.defaultPolicy!==void 0)try{o(t.defaultPolicy)}catch{n.push({code:`ERR_INVALID_POLICY`,message:`invalid default policy "${t.defaultPolicy}"`,path:`default_policy`,severity:`error`})}else t.defaultPolicy=i;if(!t.obscureConfig||!t.obscureConfig.replacement)t.obscureConfig=l();else{let e=t.obscureConfig;if(e.prefixLength<0&&n.push({code:`ERR_INVALID_CONFIG`,message:`prefixLength must be >= 0`,path:`obscure.prefixLength`,severity:`error`}),e.suffixLength<0&&n.push({code:`ERR_INVALID_CONFIG`,message:`suffixLength must be >= 0`,path:`obscure.suffixLength`,severity:`error`}),e.maxLength<0&&n.push({code:`ERR_INVALID_CONFIG`,message:`maxLength must be >= 0`,path:`obscure.maxLength`,severity:`error`}),e.maxLength>0){let t=e.prefixLength+e.suffixLength+1;e.maxLength<t&&n.push({code:`ERR_INVALID_CONFIG`,message:`maxLength (${e.maxLength}) is too small for prefix (${e.prefixLength}) + suffix (${e.suffixLength}) + 1`,path:`obscure.maxLength`,severity:`warning`})}}if(t.hashSecret!==void 0&&t.hashSecret!==``){let e=t.hashSecret;e.length%2==0&&/^[0-9a-fA-F]+$/.test(e)?e.length<32&&n.push({code:`ERR_INVALID_CONFIG`,message:`hashSecret too short: must be at least 16 bytes (32 hex chars)`,path:`hash_secret`,severity:`error`}):n.push({code:`ERR_INVALID_CONFIG`,message:`hashSecret must be a valid hex-encoded string`,path:`hash_secret`,severity:`error`})}let a=n.filter(e=>e.severity===`error`);if(a.length>0)throw new e({code:`ERR_SANITIZATION_CONFIG_INVALID`,message:`sanitization config validation failed`,issues:a.map(e=>({code:e.code,message:e.message,path:e.path,index:e.index,severity:`error`}))})}function d(e){let t=new Uint8Array(e.length/2);for(let n=0;n<t.length;n++)t[n]=parseInt(e.slice(n*2,n*2+2),16);return t}function f(e){return Array.from(new Uint8Array(e)).map(e=>e.toString(16).padStart(2,`0`)).join(``)}function p(){let e=new Uint8Array(32);return crypto.getRandomValues(e),e}function m(e){return crypto.subtle.importKey(`raw`,e,{name:`HMAC`,hash:`SHA-256`},!1,[`sign`])}var h=class{config;logger;hashSecretBytes;cryptoKeyPromise;constructor(e,n){this.logger=n??new t([]);let r={...e};try{u(r)}catch(e){this.logger.error(`sanitizer_config_invalid`,{error:String(e),fallback:`preserve-all`}),r={defaultPolicy:i,obscureConfig:l()},u(r)}this.config=r;let a;if(r.hashSecret){let e=d(r.hashSecret);e.length>=16?(a=e,this.logger.debug(`sanitizer_hash_secret_loaded`)):(this.logger.warn(`sanitizer_hash_secret_invalid`,{reason:`too short, generating random secret`}),a=p())}else a=p();this.hashSecretBytes=a,this.cryptoKeyPromise=m(a)}async sanitizeDocument(e){let t=await Promise.all(Object.entries(e).map(async([e,t])=>{let n=this.getPolicyForField(e);return[e,await this.applyPolicy(e,t,n)]}));return Object.fromEntries(t)}async sanitizeValue(e,t){let n=this.getPolicyForField(e);return this.applyPolicy(e,t,n)}getPolicyForField(e){let t=this.config.fields?.[e];if(t!==void 0)return t;for(let t of this.config.patterns??[])if(t._regex?.test(e))return t.policy;return this.config.defaultPolicy??`preserve`}async applyPolicy(e,t,n){if(t==null)return t;switch(n){case`redact`:return`***`;case`hash`:return this.hashValue(t);case`obscure`:return this.obscureValue(t);case`preserve`:return t;default:return this.logger.warn(`sanitizer_unknown_policy`,{field:e,policy:n}),t}}async hashValue(e){let t=E(e),n=await this.cryptoKeyPromise,r=new TextEncoder().encode(t);return`[HASH:${f(await crypto.subtle.sign(`HMAC`,n,r)).slice(0,8)}]`}obscureValue(e){let t=E(e),{prefixLength:n,suffixLength:r,replacement:i,maxLength:a}=this.config.obscureConfig??l(),o=t.length;if(o<=n+r+1)return`[OBSCURED]`;let s=t.slice(0,n),c=t.slice(o-r),u;if(a>0){let e=a-n-r;if(e<1)return`[OBSCURED]`;u=e}else u=o-n-r;return`${s}${i.repeat(u)}${c}`}};const g=new Set([`_version_`,`_created_`,`_updated_`,`_checksum_`,`_signature_`]);function _(e){return g.has(e)}var v=class extends h{scopeID;constructor(e,t,n){super(e,n),this.scopeID=t}async sanitizeDocumentDeep(e){let t=await Promise.all(Object.entries(e).map(async([e,t])=>e===`_metadata_`&&D(t)?[e,await this.sanitizeMetadata(t)]:[e,await this.sanitizeValueDeep(e,t)]));return Object.fromEntries(t)}async sanitizeMetadata(e){let t=await Promise.all(Object.entries(e).map(async([e,t])=>_(e)?[e,t]:[e,await this.sanitizeValueDeep(e,t)]));return Object.fromEntries(t)}async sanitizeValueDeep(e,t){if(t==null)return t;if(D(t)){let e=await Promise.all(Object.entries(t).map(async([e,t])=>[e,await this.sanitizeValueDeep(e,t)]));return Object.fromEntries(e)}if(Array.isArray(t))return Promise.all(t.map(async t=>{if(D(t)){let e=await Promise.all(Object.entries(t).map(async([e,t])=>[e,await this.sanitizeValueDeep(e,t)]));return Object.fromEntries(e)}return this.sanitizeValueDeep(`${e}[]`,t)}));let n=this.getPolicyForField(e);return this.applyPolicy(e,t,n)}async applyPolicy(e,t,n){if(t==null)return t;switch(n){case`redact`:case`hash`:case`obscure`:case`preserve`:return super.applyPolicy(e,t,n);default:return this.logger.warn(`sanitizer_unknown_policy`,{scope:this.scopeID,field:e,policy:n}),t}}};function y(e,t){let n={fields:{},patterns:[]};Object.assign(n.fields,e?.fields??{}),Object.assign(n.fields,t.fields??{});let r=new Map;for(let t of e?.patterns??[])r.set(t.pattern,t);for(let e of t.patterns??[])r.set(e.pattern,e);let i=new Set((t.patterns??[]).map(e=>e.pattern)),a=(t.patterns??[]).map(e=>r.get(e.pattern)),o=(e?.patterns??[]).filter(e=>!i.has(e.pattern)).map(e=>r.get(e.pattern));return n.patterns=[...a,...o],n.defaultPolicy=t.defaultPolicy??e?.defaultPolicy??`preserve`,t.obscureConfig?.replacement?n.obscureConfig=t.obscureConfig:e?.obscureConfig&&(n.obscureConfig=e.obscureConfig),n.hashSecret=t.hashSecret??e?.hashSecret,n}function b(t,n,r){let i;try{i=new RegExp(t)}catch(n){throw new e({code:`ERR_SANITIZATION_PATTERN_INVALID`,message:`failed to compile pattern "${t}"`,cause:n})}let a={pattern:t,policy:n,comment:r};return Object.defineProperty(a,"_regex",{value:i,enumerable:!1,writable:!1,configurable:!1}),a}function x(e,t,n){return b(e,t,n)}function S(){return[b(`password`,n,`Passwords`),b(`secret`,n,`Secrets`),b(`token`,n,`Tokens`),b(`api[_-]?key`,n,`API keys`),b(`private[_-]?key`,n,`Private keys`),b(`credential`,n,`Credentials`),b(`auth`,r,`Auth fields`),b(`ssn|social[_-]?security`,n,`SSN`),b(`credit[_-]?card|cvv`,n,`Payment card data`),b(`email`,a,`Email addresses`),b(`phone|mobile`,a,`Phone numbers`)].map(({pattern:e,policy:t,comment:n})=>b(`(?i)${e}`.replace(/^\(\?i\)/,``),t,n))}function C(){return{version:`v1`,fields:{},patterns:S(),defaultPolicy:i,obscureConfig:l()}}async function w(t,n){return Promise.all(n.map(async(n,r)=>{try{return await t.sanitizeDocumentDeep(n)}catch(t){throw new e({code:`ERR_SANITIZATION_FAILED`,message:`failed to sanitize document at index ${r}`,cause:t,issues:[{code:`ERR_SANITIZATION_FAILED`,message:String(t),index:r,severity:`error`}]})}}))}async function T(e,t){return t==null?t:D(t)?e.sanitizeDocumentDeep(t):Array.isArray(t)?Promise.all(t.map(t=>T(e,t))):t}function E(e){return typeof e==`string`?e:e instanceof Uint8Array?new TextDecoder().decode(e):String(e)}function D(e){return typeof e==`object`&&!!e&&!Array.isArray(e)&&!(e instanceof Uint8Array)&&!(e instanceof Date)}export{v as DocumentSanitizer,r as MaskHash,a as MaskObscure,i as MaskPreserve,n as MaskRedact,h as Sanitizer,S as commonSecurityPatterns,b as compilePattern,l as defaultObscureConfig,y as mergeConfigs,c as mostRestrictivePolicy,x as mustCompilePattern,C as newSecureDefaultConfig,o as parseMaskedFieldPolicy,w as sanitizeDocumentArray,T as sanitizeValue,u as validateConfig};
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@asaidimu/utils-sanitize",
3
+ "version": "1.0.0",
4
+ "description": "A collection of sanitize utilities.",
5
+ "main": "index.js",
6
+ "module": "index.mjs",
7
+ "types": "index.d.ts",
8
+ "keywords": [
9
+ "typescript",
10
+ "utility"
11
+ ],
12
+ "author": "Saidimu <47994458+asaidimu@users.noreply.github.com>",
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/asaidimu/erp-utils.git"
17
+ },
18
+ "bugs": {
19
+ "url": "https://github.com/asaidimu/erp-utils/issues"
20
+ },
21
+ "homepage": "https://github.com/asaidimu/erp-utils/tree/main/src/sanitize#readme",
22
+ "files": [
23
+ "./*"
24
+ ],
25
+ "exports": {
26
+ ".": {
27
+ "import": {
28
+ "types": "./index.d.ts",
29
+ "default": "./index.mjs"
30
+ },
31
+ "require": {
32
+ "types": "./index.d.ts",
33
+ "default": "./index.js"
34
+ }
35
+ }
36
+ },
37
+ "dependencies": {},
38
+ "publishConfig": {
39
+ "registry": "https://registry.npmjs.org/",
40
+ "tag": "latest",
41
+ "access": "public"
42
+ },
43
+ "release": {
44
+ "plugins": [
45
+ [
46
+ "@semantic-release/npm",
47
+ {
48
+ "pkgRoot": "./dist"
49
+ }
50
+ ],
51
+ [
52
+ "@semantic-release/git",
53
+ {
54
+ "assets": [
55
+ "CHANGELOG.md",
56
+ "package.json"
57
+ ],
58
+ "message": "chore(release): Release @asaidimu/utils-sanitize v${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
59
+ }
60
+ ]
61
+ ]
62
+ }
63
+ }