@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 +21 -0
- package/README.md +238 -0
- package/index.d.cts +246 -0
- package/index.d.ts +197 -0
- package/index.js +1 -0
- package/index.mjs +1 -0
- package/package.json +63 -0
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
|
+
}
|