@resq-systems/security 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/lib/crypto.mjs ADDED
@@ -0,0 +1,294 @@
1
+ import { brandRefiner, toPositiveInt, unsafeBrand } from "@resq-systems/types";
2
+ import { createCipheriv, createDecipheriv, createHash, randomBytes, scrypt } from "node:crypto";
3
+ import { promisify } from "node:util";
4
+ //#region src/crypto.ts
5
+ /**
6
+ * Copyright 2026 ResQ Systems, Inc.
7
+ *
8
+ * Licensed under the Apache License, Version 2.0 (the "License");
9
+ * you may not use this file except in compliance with the License.
10
+ * You may obtain a copy of the License at
11
+ *
12
+ * http://www.apache.org/licenses/LICENSE-2.0
13
+ *
14
+ * Unless required by applicable law or agreed to in writing, software
15
+ * distributed under the License is distributed on an "AS IS" BASIS,
16
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17
+ * See the License for the specific language governing permissions and
18
+ * limitations under the License.
19
+ */
20
+ /**
21
+ * @file Security Utilities
22
+ * @module utils/security
23
+ * @author ResQ
24
+ * @description Provides encryption, hashing, and security functions
25
+ * for compliance with SOC2, ISO 27001, NIST 800-53.
26
+ * Includes AES-256-GCM encryption, secure token generation,
27
+ * and PII masking utilities.
28
+ * @compliance NIST 800-53 SC-28 (Protection of Information at Rest)
29
+ * @compliance NIST 800-53 SC-13 (Cryptographic Protection)
30
+ */
31
+ const scryptAsync = promisify(scrypt);
32
+ /** AES-256-GCM encryption algorithm */
33
+ const ALGORITHM = "aes-256-gcm";
34
+ /** Initialization vector length in bytes */
35
+ const IV_LENGTH = 16;
36
+ /** Authentication tag length in bytes */
37
+ const AUTH_TAG_LENGTH = 16;
38
+ /** Salt length for key derivation */
39
+ const SALT_LENGTH = 32;
40
+ /** Derived key length (256 bits for AES-256) */
41
+ const KEY_LENGTH = 32;
42
+ /** Minimum decoded byte length of a well-formed {@link Ciphertext} envelope. */
43
+ const CIPHERTEXT_MIN_BYTES = SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH;
44
+ /**
45
+ * Smart constructors for {@link EncryptionKey}. The runtime check is a
46
+ * non-empty string — scrypt stretches any non-empty secret into a 256-bit
47
+ * key, so entropy is the caller's responsibility, but an empty key is
48
+ * always a bug.
49
+ */
50
+ const EncryptionKeyBrand = brandRefiner((value) => value.length > 0, "encryption key");
51
+ /** Type guard: `true` when `value` is a usable {@link EncryptionKey}. */
52
+ const isEncryptionKey = EncryptionKeyBrand.is;
53
+ /** Assert `value` is a non-empty secret and brand it, throwing otherwise. */
54
+ const toEncryptionKey = EncryptionKeyBrand.from;
55
+ /** Return `value` branded as an {@link EncryptionKey}, or `null` when empty. */
56
+ const coerceEncryptionKey = EncryptionKeyBrand.coerce;
57
+ /** Brand `value` as an {@link EncryptionKey} without checking. */
58
+ const unsafeEncryptionKey = EncryptionKeyBrand.unsafe;
59
+ /**
60
+ * Smart constructors for {@link Ciphertext}. The runtime check verifies the
61
+ * value base64-decodes to at least the fixed envelope header size — enough
62
+ * to reject truncated or non-base64 input before it reaches
63
+ * {@link decryptData}.
64
+ */
65
+ const CiphertextBrand = brandRefiner((value) => value.length > 0 && Buffer.from(value, "base64").length >= CIPHERTEXT_MIN_BYTES, "ciphertext");
66
+ /** Type guard: `true` when `value` is a well-formed {@link Ciphertext} envelope. */
67
+ const isCiphertext = CiphertextBrand.is;
68
+ /** Assert `value` is a well-formed envelope and brand it, throwing otherwise. */
69
+ const toCiphertext = CiphertextBrand.from;
70
+ /** Return `value` branded as a {@link Ciphertext}, or `null` when malformed. */
71
+ const coerceCiphertext = CiphertextBrand.coerce;
72
+ /** Brand `value` as a {@link Ciphertext} without checking. */
73
+ const unsafeCiphertext = CiphertextBrand.unsafe;
74
+ /**
75
+ * Derive a 32-byte (AES-256) key from a password and per-record salt
76
+ * using scrypt with Node's default cost parameters.
77
+ *
78
+ * Internal helper — used inside the encrypt/decrypt round-trip because
79
+ * the salt must travel alongside the ciphertext for decryption to
80
+ * succeed.
81
+ *
82
+ * @internal
83
+ */
84
+ async function deriveKey(password, salt) {
85
+ return await scryptAsync(password, salt, KEY_LENGTH);
86
+ }
87
+ /**
88
+ * Encrypt a UTF-8 string with AES-256-GCM authenticated encryption.
89
+ *
90
+ * Each call generates a fresh random salt and IV — the same plaintext
91
+ * encrypted twice with the same `encryptionKey` produces different
92
+ * ciphertexts, which is the property you want for at-rest encryption.
93
+ *
94
+ * Output layout (base64-encoded): `salt(32) | iv(16) | authTag(16) | ciphertext(*)`.
95
+ * The companion {@link decryptData} understands this layout.
96
+ *
97
+ * @param plaintext - UTF-8 string to encrypt.
98
+ * @param encryptionKey - Caller-supplied secret. Treated as a password
99
+ * and stretched into a 256-bit AES key via scrypt; can be any length,
100
+ * though a high-entropy secret (≥ 32 bytes) is strongly preferred.
101
+ *
102
+ * @returns A self-contained base64 string. Store or transmit verbatim;
103
+ * the salt/IV are recovered on decryption.
104
+ *
105
+ * @throws From the underlying Node crypto primitives if `encryptionKey`
106
+ * is empty or scrypt fails.
107
+ *
108
+ * @compliance NIST 800-53 SC-28 (Protection of Information at Rest),
109
+ * SC-13 (Cryptographic Protection).
110
+ *
111
+ * @example
112
+ * ```ts
113
+ * const ct = await encryptData("user@example.com", process.env.PII_KEY!);
114
+ * await db.users.update(id, { email: ct });
115
+ * ```
116
+ */
117
+ async function encryptData(plaintext, encryptionKey) {
118
+ const salt = randomBytes(SALT_LENGTH);
119
+ const key = await deriveKey(encryptionKey, salt);
120
+ const iv = randomBytes(IV_LENGTH);
121
+ const cipher = createCipheriv(ALGORITHM, key, iv);
122
+ const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
123
+ const authTag = cipher.getAuthTag();
124
+ const combined = Buffer.concat([
125
+ salt,
126
+ iv,
127
+ authTag,
128
+ encrypted
129
+ ]);
130
+ return CiphertextBrand.unsafe(combined.toString("base64"));
131
+ }
132
+ /**
133
+ * Reverse {@link encryptData}. Verifies the GCM authentication tag
134
+ * before returning plaintext — tampered ciphertexts throw.
135
+ *
136
+ * @param encryptedData - Base64 string produced by {@link encryptData}.
137
+ * @param encryptionKey - Same key/password used to encrypt. Wrong keys
138
+ * throw an "Unsupported state or unable to authenticate data" error
139
+ * from Node — the authenticated tag failure is indistinguishable from
140
+ * tampering, by design.
141
+ *
142
+ * @returns The original UTF-8 plaintext.
143
+ *
144
+ * @throws Error if the tag does not verify (wrong key, modified
145
+ * ciphertext, truncated payload). Catch this and treat it as a
146
+ * security event, not a recoverable error.
147
+ *
148
+ * @example
149
+ * ```ts
150
+ * const plaintext = await decryptData(stored, process.env.PII_KEY!);
151
+ * ```
152
+ */
153
+ async function decryptData(encryptedData, encryptionKey) {
154
+ const combined = Buffer.from(encryptedData, "base64");
155
+ const salt = combined.subarray(0, SALT_LENGTH);
156
+ const iv = combined.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);
157
+ const authTag = combined.subarray(SALT_LENGTH + IV_LENGTH, SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH);
158
+ const ciphertext = combined.subarray(SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH);
159
+ const decipher = createDecipheriv(ALGORITHM, await deriveKey(encryptionKey, salt), iv);
160
+ decipher.setAuthTag(authTag);
161
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
162
+ }
163
+ /**
164
+ * Compute a SHA-256 digest of a UTF-8 string and return it as lowercase
165
+ * hex.
166
+ *
167
+ * **Not for password storage.** SHA-256 is fast by design — use a
168
+ * deliberately slow KDF (`bcrypt`, `argon2`, or `scrypt`) for
169
+ * password-equivalent material. This helper is intended for
170
+ * non-reversible identifiers, content hashes, and idempotency keys.
171
+ *
172
+ * @param data - UTF-8 input.
173
+ * @returns 64-character lowercase hex digest.
174
+ *
175
+ * @example
176
+ * ```ts
177
+ * hashData("hello"); // → "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
178
+ * ```
179
+ */
180
+ function hashData(data) {
181
+ return unsafeBrand(createHash("sha256").update(data).digest("hex"));
182
+ }
183
+ /**
184
+ * Generate a cryptographically random hex token suitable for session
185
+ * IDs, password-reset tokens, CSRF tokens, and similar single-use
186
+ * secrets.
187
+ *
188
+ * @param length - Number of random *bytes* to draw as a {@link PositiveInt}
189
+ * (the returned hex string is twice as long). Default `32` ⇒ 64-char hex
190
+ * / 256 bits of entropy. Construct non-default lengths with `toPositiveInt`
191
+ * so zero-byte and negative lengths are unrepresentable.
192
+ * @returns A {@link SecureToken}: lowercase hex string of length `length * 2`.
193
+ *
194
+ * @example
195
+ * ```ts
196
+ * import { toPositiveInt } from "@resq-systems/types";
197
+ * generateSecureToken(); // 64-char hex (256-bit entropy)
198
+ * generateSecureToken(toPositiveInt(16)); // 32-char hex (128-bit entropy)
199
+ * ```
200
+ */
201
+ function generateSecureToken(length = toPositiveInt(32)) {
202
+ return unsafeBrand(randomBytes(length).toString("hex"));
203
+ }
204
+ /**
205
+ * Mask an arbitrary PII string for safe logging — keeps the first two
206
+ * and last two characters and replaces everything in between with
207
+ * asterisks. Strings of length ≤ 4 are fully masked as `"****"`.
208
+ *
209
+ * @param data - Raw PII string.
210
+ * @returns Masked representation safe for logs.
211
+ *
212
+ * @example
213
+ * ```ts
214
+ * maskPII("4242424242424242"); // → "42************42"
215
+ * maskPII("AB12"); // → "****"
216
+ * ```
217
+ */
218
+ function maskPII(data) {
219
+ if (data.length <= 4) return unsafeBrand("****");
220
+ return unsafeBrand(`${data.slice(0, 2)}${"*".repeat(data.length - 4)}${data.slice(-2)}`);
221
+ }
222
+ /**
223
+ * Mask an email address while preserving the domain — useful for
224
+ * deduplication and support workflows where the domain is non-PII but
225
+ * the local part identifies the user.
226
+ *
227
+ * @param email - Full email. Falls back to {@link maskPII} if the input
228
+ * does not contain a valid `local@domain` shape.
229
+ * @returns Masked email; e.g. `"j*****e@example.com"`.
230
+ *
231
+ * @example
232
+ * ```ts
233
+ * maskEmail("jane@example.com"); // → "j**e@example.com"
234
+ * maskEmail("ab@example.com"); // → "**@example.com"
235
+ * maskEmail("not-an-email"); // → "no********il" (maskPII fallback)
236
+ * ```
237
+ */
238
+ function maskEmail(email) {
239
+ const parts = email.split("@");
240
+ const local = parts[0];
241
+ const domain = parts[1];
242
+ if (!domain || !local) return maskPII(email);
243
+ return unsafeBrand(`${local.length > 2 ? `${local[0]}${"*".repeat(local.length - 2)}${local[local.length - 1]}` : "**"}@${domain}`);
244
+ }
245
+ /**
246
+ * Recursively shallow-copy an object, replacing any field whose key
247
+ * contains a sensitive substring (case-insensitive) with `[REDACTED]`,
248
+ * and masking string fields whose key contains `"email"` via
249
+ * {@link maskEmail}.
250
+ *
251
+ * Designed for log structures — preserves shape so log queries continue
252
+ * to work, but ensures secrets and identifiers don't leak. Use as a
253
+ * defensive layer **before** writing structured log lines.
254
+ *
255
+ * @param obj - Object to sanitize. Original is not mutated.
256
+ * @param sensitiveFields - Substring allow-list. Defaults to
257
+ * `["password", "passwordHash", "token", "secret",
258
+ * "twoFactorSecret", "apiKey"]`. Substrings match anywhere in the
259
+ * key, e.g. `"token"` matches `"refreshToken"` and `"id_token"`.
260
+ *
261
+ * @returns A new object with sensitive fields redacted and emails
262
+ * masked. Nested objects are recursed; arrays and primitives pass
263
+ * through unchanged.
264
+ *
265
+ * @example
266
+ * ```ts
267
+ * sanitizeForLogging({
268
+ * id: 1,
269
+ * email: "u@x.com",
270
+ * apiKey: "sk-...",
271
+ * nested: { token: "..." },
272
+ * });
273
+ * // → { id: 1, email: "u@x.com" (masked), apiKey: "[REDACTED]", nested: { token: "[REDACTED]" } }
274
+ * ```
275
+ */
276
+ function sanitizeForLogging(obj, sensitiveFields = [
277
+ "password",
278
+ "passwordHash",
279
+ "token",
280
+ "secret",
281
+ "twoFactorSecret",
282
+ "apiKey"
283
+ ]) {
284
+ const sanitized = {};
285
+ for (const [key, value] of Object.entries(obj)) if (sensitiveFields.some((field) => key.toLowerCase().includes(field.toLowerCase()))) sanitized[key] = "[REDACTED]";
286
+ else if (key.toLowerCase().includes("email") && typeof value === "string") sanitized[key] = maskEmail(value);
287
+ else if (typeof value === "object" && value !== null) sanitized[key] = sanitizeForLogging(value, sensitiveFields);
288
+ else sanitized[key] = value;
289
+ return sanitized;
290
+ }
291
+ //#endregion
292
+ export { coerceCiphertext, coerceEncryptionKey, decryptData, encryptData, generateSecureToken, hashData, isCiphertext, isEncryptionKey, maskEmail, maskPII, sanitizeForLogging, toCiphertext, toEncryptionKey, unsafeCiphertext, unsafeEncryptionKey };
293
+
294
+ //# sourceMappingURL=crypto.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"crypto.mjs","names":[],"sources":["../src/crypto.ts"],"sourcesContent":["/**\n * Copyright 2026 ResQ Systems, Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * @file Security Utilities\n * @module utils/security\n * @author ResQ\n * @description Provides encryption, hashing, and security functions\n * for compliance with SOC2, ISO 27001, NIST 800-53.\n * Includes AES-256-GCM encryption, secure token generation,\n * and PII masking utilities.\n * @compliance NIST 800-53 SC-28 (Protection of Information at Rest)\n * @compliance NIST 800-53 SC-13 (Cryptographic Protection)\n */\n\nimport {\n\ttype Brand,\n\tbrandRefiner,\n\ttype PositiveInt,\n\ttoPositiveInt,\n\tunsafeBrand,\n} from \"@resq-systems/types\";\nimport { createCipheriv, createDecipheriv, createHash, randomBytes, scrypt } from \"node:crypto\";\nimport { promisify } from \"node:util\";\n\nconst scryptAsync = promisify(scrypt);\n\n/** AES-256-GCM encryption algorithm */\nconst ALGORITHM = \"aes-256-gcm\";\n/** Initialization vector length in bytes */\nconst IV_LENGTH = 16;\n/** Authentication tag length in bytes */\nconst AUTH_TAG_LENGTH = 16;\n/** Salt length for key derivation */\nconst SALT_LENGTH = 32;\n/** Derived key length (256 bits for AES-256) */\nconst KEY_LENGTH = 32;\n\n// ============================================\n// Nominal (branded) types\n// ============================================\n\n/**\n * Base64 AES-256-GCM payload produced by {@link encryptData} — the\n * `salt | iv | authTag | ciphertext` envelope. Only {@link decryptData}\n * should consume a value of this type; read one back from storage through\n * {@link toCiphertext}.\n */\nexport type Ciphertext = Brand<string, \"Ciphertext\">;\n\n/**\n * A secret accepted by {@link encryptData}/{@link decryptData} as the\n * scrypt password. Mint one at the boundary where the secret enters the\n * process (typically from `process.env`) via {@link toEncryptionKey}.\n */\nexport type EncryptionKey = Brand<string, \"EncryptionKey\">;\n\n/** Cryptographically random hex token minted by {@link generateSecureToken}. */\nexport type SecureToken = Brand<string, \"SecureToken\">;\n\n/** Lowercase 64-char SHA-256 hex digest produced by {@link hashData}. */\nexport type Sha256Hex = Brand<string, \"Sha256Hex\">;\n\n/** A PII string masked for safe logging by {@link maskPII}/{@link maskEmail}. */\nexport type Masked = Brand<string, \"Masked\">;\n\n/** Minimum decoded byte length of a well-formed {@link Ciphertext} envelope. */\nconst CIPHERTEXT_MIN_BYTES = SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH;\n\n/**\n * Smart constructors for {@link EncryptionKey}. The runtime check is a\n * non-empty string — scrypt stretches any non-empty secret into a 256-bit\n * key, so entropy is the caller's responsibility, but an empty key is\n * always a bug.\n */\nconst EncryptionKeyBrand = brandRefiner<string, \"EncryptionKey\">(\n\t(value) => value.length > 0,\n\t\"encryption key\",\n);\n\n/** Type guard: `true` when `value` is a usable {@link EncryptionKey}. */\nexport const isEncryptionKey = EncryptionKeyBrand.is;\n/** Assert `value` is a non-empty secret and brand it, throwing otherwise. */\nexport const toEncryptionKey = EncryptionKeyBrand.from;\n/** Return `value` branded as an {@link EncryptionKey}, or `null` when empty. */\nexport const coerceEncryptionKey = EncryptionKeyBrand.coerce;\n/** Brand `value` as an {@link EncryptionKey} without checking. */\nexport const unsafeEncryptionKey = EncryptionKeyBrand.unsafe;\n\n/**\n * Smart constructors for {@link Ciphertext}. The runtime check verifies the\n * value base64-decodes to at least the fixed envelope header size — enough\n * to reject truncated or non-base64 input before it reaches\n * {@link decryptData}.\n */\nconst CiphertextBrand = brandRefiner<string, \"Ciphertext\">(\n\t(value) => value.length > 0 && Buffer.from(value, \"base64\").length >= CIPHERTEXT_MIN_BYTES,\n\t\"ciphertext\",\n);\n\n/** Type guard: `true` when `value` is a well-formed {@link Ciphertext} envelope. */\nexport const isCiphertext = CiphertextBrand.is;\n/** Assert `value` is a well-formed envelope and brand it, throwing otherwise. */\nexport const toCiphertext = CiphertextBrand.from;\n/** Return `value` branded as a {@link Ciphertext}, or `null` when malformed. */\nexport const coerceCiphertext = CiphertextBrand.coerce;\n/** Brand `value` as a {@link Ciphertext} without checking. */\nexport const unsafeCiphertext = CiphertextBrand.unsafe;\n\n/**\n * Derive a 32-byte (AES-256) key from a password and per-record salt\n * using scrypt with Node's default cost parameters.\n *\n * Internal helper — used inside the encrypt/decrypt round-trip because\n * the salt must travel alongside the ciphertext for decryption to\n * succeed.\n *\n * @internal\n */\nasync function deriveKey(password: string, salt: Buffer): Promise<Buffer> {\n\treturn (await scryptAsync(password, salt, KEY_LENGTH)) as Buffer;\n}\n\n/**\n * Encrypt a UTF-8 string with AES-256-GCM authenticated encryption.\n *\n * Each call generates a fresh random salt and IV — the same plaintext\n * encrypted twice with the same `encryptionKey` produces different\n * ciphertexts, which is the property you want for at-rest encryption.\n *\n * Output layout (base64-encoded): `salt(32) | iv(16) | authTag(16) | ciphertext(*)`.\n * The companion {@link decryptData} understands this layout.\n *\n * @param plaintext - UTF-8 string to encrypt.\n * @param encryptionKey - Caller-supplied secret. Treated as a password\n * and stretched into a 256-bit AES key via scrypt; can be any length,\n * though a high-entropy secret (≥ 32 bytes) is strongly preferred.\n *\n * @returns A self-contained base64 string. Store or transmit verbatim;\n * the salt/IV are recovered on decryption.\n *\n * @throws From the underlying Node crypto primitives if `encryptionKey`\n * is empty or scrypt fails.\n *\n * @compliance NIST 800-53 SC-28 (Protection of Information at Rest),\n * SC-13 (Cryptographic Protection).\n *\n * @example\n * ```ts\n * const ct = await encryptData(\"user@example.com\", process.env.PII_KEY!);\n * await db.users.update(id, { email: ct });\n * ```\n */\nexport async function encryptData(\n\tplaintext: string,\n\tencryptionKey: EncryptionKey,\n): Promise<Ciphertext> {\n\tconst salt = randomBytes(SALT_LENGTH);\n\tconst key = await deriveKey(encryptionKey, salt);\n\tconst iv = randomBytes(IV_LENGTH);\n\n\tconst cipher = createCipheriv(ALGORITHM, key, iv);\n\tconst encrypted = Buffer.concat([cipher.update(plaintext, \"utf8\"), cipher.final()]);\n\tconst authTag = cipher.getAuthTag();\n\n\tconst combined = Buffer.concat([salt, iv, authTag, encrypted]);\n\treturn CiphertextBrand.unsafe(combined.toString(\"base64\"));\n}\n\n/**\n * Reverse {@link encryptData}. Verifies the GCM authentication tag\n * before returning plaintext — tampered ciphertexts throw.\n *\n * @param encryptedData - Base64 string produced by {@link encryptData}.\n * @param encryptionKey - Same key/password used to encrypt. Wrong keys\n * throw an \"Unsupported state or unable to authenticate data\" error\n * from Node — the authenticated tag failure is indistinguishable from\n * tampering, by design.\n *\n * @returns The original UTF-8 plaintext.\n *\n * @throws Error if the tag does not verify (wrong key, modified\n * ciphertext, truncated payload). Catch this and treat it as a\n * security event, not a recoverable error.\n *\n * @example\n * ```ts\n * const plaintext = await decryptData(stored, process.env.PII_KEY!);\n * ```\n */\nexport async function decryptData(\n\tencryptedData: Ciphertext,\n\tencryptionKey: EncryptionKey,\n): Promise<string> {\n\tconst combined = Buffer.from(encryptedData, \"base64\");\n\n\tconst salt = combined.subarray(0, SALT_LENGTH);\n\tconst iv = combined.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);\n\tconst authTag = combined.subarray(\n\t\tSALT_LENGTH + IV_LENGTH,\n\t\tSALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH,\n\t);\n\tconst ciphertext = combined.subarray(SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH);\n\n\tconst key = await deriveKey(encryptionKey, salt);\n\n\tconst decipher = createDecipheriv(ALGORITHM, key, iv);\n\tdecipher.setAuthTag(authTag);\n\n\tconst decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);\n\treturn decrypted.toString(\"utf8\");\n}\n\n/**\n * Compute a SHA-256 digest of a UTF-8 string and return it as lowercase\n * hex.\n *\n * **Not for password storage.** SHA-256 is fast by design — use a\n * deliberately slow KDF (`bcrypt`, `argon2`, or `scrypt`) for\n * password-equivalent material. This helper is intended for\n * non-reversible identifiers, content hashes, and idempotency keys.\n *\n * @param data - UTF-8 input.\n * @returns 64-character lowercase hex digest.\n *\n * @example\n * ```ts\n * hashData(\"hello\"); // → \"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\"\n * ```\n */\nexport function hashData(data: string): Sha256Hex {\n\treturn unsafeBrand<\"Sha256Hex\", string>(createHash(\"sha256\").update(data).digest(\"hex\"));\n}\n\n/**\n * Generate a cryptographically random hex token suitable for session\n * IDs, password-reset tokens, CSRF tokens, and similar single-use\n * secrets.\n *\n * @param length - Number of random *bytes* to draw as a {@link PositiveInt}\n * (the returned hex string is twice as long). Default `32` ⇒ 64-char hex\n * / 256 bits of entropy. Construct non-default lengths with `toPositiveInt`\n * so zero-byte and negative lengths are unrepresentable.\n * @returns A {@link SecureToken}: lowercase hex string of length `length * 2`.\n *\n * @example\n * ```ts\n * import { toPositiveInt } from \"@resq-systems/types\";\n * generateSecureToken(); // 64-char hex (256-bit entropy)\n * generateSecureToken(toPositiveInt(16)); // 32-char hex (128-bit entropy)\n * ```\n */\nexport function generateSecureToken(length: PositiveInt = toPositiveInt(32)): SecureToken {\n\treturn unsafeBrand<\"SecureToken\", string>(randomBytes(length).toString(\"hex\"));\n}\n\n/**\n * Mask an arbitrary PII string for safe logging — keeps the first two\n * and last two characters and replaces everything in between with\n * asterisks. Strings of length ≤ 4 are fully masked as `\"****\"`.\n *\n * @param data - Raw PII string.\n * @returns Masked representation safe for logs.\n *\n * @example\n * ```ts\n * maskPII(\"4242424242424242\"); // → \"42************42\"\n * maskPII(\"AB12\"); // → \"****\"\n * ```\n */\nexport function maskPII(data: string): Masked {\n\tif (data.length <= 4) {\n\t\treturn unsafeBrand<\"Masked\", string>(\"****\");\n\t}\n\treturn unsafeBrand<\"Masked\", string>(\n\t\t`${data.slice(0, 2)}${\"*\".repeat(data.length - 4)}${data.slice(-2)}`,\n\t);\n}\n\n/**\n * Mask an email address while preserving the domain — useful for\n * deduplication and support workflows where the domain is non-PII but\n * the local part identifies the user.\n *\n * @param email - Full email. Falls back to {@link maskPII} if the input\n * does not contain a valid `local@domain` shape.\n * @returns Masked email; e.g. `\"j*****e@example.com\"`.\n *\n * @example\n * ```ts\n * maskEmail(\"jane@example.com\"); // → \"j**e@example.com\"\n * maskEmail(\"ab@example.com\"); // → \"**@example.com\"\n * maskEmail(\"not-an-email\"); // → \"no********il\" (maskPII fallback)\n * ```\n */\nexport function maskEmail(email: string): Masked {\n\tconst parts = email.split(\"@\");\n\tconst local = parts[0];\n\tconst domain = parts[1];\n\tif (!domain || !local) return maskPII(email);\n\tconst maskedLocal =\n\t\tlocal.length > 2\n\t\t\t? `${local[0]}${\"*\".repeat(local.length - 2)}${local[local.length - 1]}`\n\t\t\t: \"**\";\n\treturn unsafeBrand<\"Masked\", string>(`${maskedLocal}@${domain}`);\n}\n\n/**\n * Recursively shallow-copy an object, replacing any field whose key\n * contains a sensitive substring (case-insensitive) with `[REDACTED]`,\n * and masking string fields whose key contains `\"email\"` via\n * {@link maskEmail}.\n *\n * Designed for log structures — preserves shape so log queries continue\n * to work, but ensures secrets and identifiers don't leak. Use as a\n * defensive layer **before** writing structured log lines.\n *\n * @param obj - Object to sanitize. Original is not mutated.\n * @param sensitiveFields - Substring allow-list. Defaults to\n * `[\"password\", \"passwordHash\", \"token\", \"secret\",\n * \"twoFactorSecret\", \"apiKey\"]`. Substrings match anywhere in the\n * key, e.g. `\"token\"` matches `\"refreshToken\"` and `\"id_token\"`.\n *\n * @returns A new object with sensitive fields redacted and emails\n * masked. Nested objects are recursed; arrays and primitives pass\n * through unchanged.\n *\n * @example\n * ```ts\n * sanitizeForLogging({\n * id: 1,\n * email: \"u@x.com\",\n * apiKey: \"sk-...\",\n * nested: { token: \"...\" },\n * });\n * // → { id: 1, email: \"u@x.com\" (masked), apiKey: \"[REDACTED]\", nested: { token: \"[REDACTED]\" } }\n * ```\n */\nexport function sanitizeForLogging(\n\tobj: Record<string, unknown>,\n\tsensitiveFields: string[] = [\n\t\t\"password\",\n\t\t\"passwordHash\",\n\t\t\"token\",\n\t\t\"secret\",\n\t\t\"twoFactorSecret\",\n\t\t\"apiKey\",\n\t],\n): Record<string, unknown> {\n\tconst sanitized: Record<string, unknown> = {};\n\n\tfor (const [key, value] of Object.entries(obj)) {\n\t\tif (sensitiveFields.some((field) => key.toLowerCase().includes(field.toLowerCase()))) {\n\t\t\tsanitized[key] = \"[REDACTED]\";\n\t\t} else if (key.toLowerCase().includes(\"email\") && typeof value === \"string\") {\n\t\t\tsanitized[key] = maskEmail(value);\n\t\t} else if (typeof value === \"object\" && value !== null) {\n\t\t\tsanitized[key] = sanitizeForLogging(value as Record<string, unknown>, sensitiveFields);\n\t\t} else {\n\t\t\tsanitized[key] = value;\n\t\t}\n\t}\n\n\treturn sanitized;\n}\n"],"mappings":"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"}
@@ -0,0 +1,4 @@
1
+ import { Ciphertext, EncryptionKey, Masked, SecureToken, Sha256Hex, coerceCiphertext, coerceEncryptionKey, decryptData, encryptData, generateSecureToken, hashData, isCiphertext, isEncryptionKey, maskEmail, maskPII, sanitizeForLogging, toCiphertext, toEncryptionKey, unsafeCiphertext, unsafeEncryptionKey } from "./crypto.mjs";
2
+ import { THREAT_DETECTED_MESSAGE, ThreatDetectionConfig, ThreatDetectionResult, ThreatFinding, ThreatType, containsCommandInjection, containsHomoglyphs, containsNoSQLInjection, containsPathTraversal, containsSQLInjection, containsXSSPatterns, detectThreatPatterns, getThreatErrorMessage, isSafeInput, normalizeUnicode, sanitizeForDisplay, validateSafeEmail, validateSafeName, validateSafeText } from "./validators.mjs";
3
+ import { CreditCard, CreditCardSchema, Email, EmailSchema, IPv4, IPv4Schema, PIIRedactionOptions, PIIRedactionOptionsSchema, PhoneNumber, PhoneNumberSchema, SSN, SSNSchema, SafeUrl, SafeUrlSchema, SanitizedString, SanitizedStringSchema, UrlProtocol, UrlProtocolSchema, UserInputOptions, UserInputOptionsSchema, escapeHtml, isValidEmail, isValidPhone, isValidSSN, isValidUrl, parseJsonWithSchema, redactPII, redactPIIEffect, safeStringify, sanitizeHtml, sanitizeJson, sanitizeUrl, sanitizeUrlEffect, stripAnsi, validateUserInput, validateUserInputEffect } from "./sanitize.mjs";
4
+ export { Ciphertext, CreditCard, CreditCardSchema, Email, EmailSchema, EncryptionKey, IPv4, IPv4Schema, Masked, PIIRedactionOptions, PIIRedactionOptionsSchema, PhoneNumber, PhoneNumberSchema, SSN, SSNSchema, SafeUrl, SafeUrlSchema, SanitizedString, SanitizedStringSchema, SecureToken, Sha256Hex, THREAT_DETECTED_MESSAGE, ThreatDetectionConfig, ThreatDetectionResult, ThreatFinding, ThreatType, UrlProtocol, UrlProtocolSchema, UserInputOptions, UserInputOptionsSchema, coerceCiphertext, coerceEncryptionKey, containsCommandInjection, containsHomoglyphs, containsNoSQLInjection, containsPathTraversal, containsSQLInjection, containsXSSPatterns, decryptData, detectThreatPatterns, encryptData, escapeHtml, generateSecureToken, getThreatErrorMessage, hashData, isCiphertext, isEncryptionKey, isSafeInput, isValidEmail, isValidPhone, isValidSSN, isValidUrl, maskEmail, maskPII, normalizeUnicode, parseJsonWithSchema, redactPII, redactPIIEffect, safeStringify, sanitizeForDisplay, sanitizeForLogging, sanitizeHtml, sanitizeJson, sanitizeUrl, sanitizeUrlEffect, stripAnsi, toCiphertext, toEncryptionKey, unsafeCiphertext, unsafeEncryptionKey, validateSafeEmail, validateSafeName, validateSafeText, validateUserInput, validateUserInputEffect };
package/lib/index.mjs ADDED
@@ -0,0 +1,4 @@
1
+ import { coerceCiphertext, coerceEncryptionKey, decryptData, encryptData, generateSecureToken, hashData, isCiphertext, isEncryptionKey, maskEmail, maskPII, sanitizeForLogging, toCiphertext, toEncryptionKey, unsafeCiphertext, unsafeEncryptionKey } from "./crypto.mjs";
2
+ import { THREAT_DETECTED_MESSAGE, containsCommandInjection, containsHomoglyphs, containsNoSQLInjection, containsPathTraversal, containsSQLInjection, containsXSSPatterns, detectThreatPatterns, getThreatErrorMessage, isSafeInput, normalizeUnicode, sanitizeForDisplay, validateSafeEmail, validateSafeName, validateSafeText } from "./validators.mjs";
3
+ import { CreditCardSchema, EmailSchema, IPv4Schema, PIIRedactionOptionsSchema, PhoneNumberSchema, SSNSchema, SafeUrlSchema, SanitizedStringSchema, UrlProtocolSchema, UserInputOptionsSchema, escapeHtml, isValidEmail, isValidPhone, isValidSSN, isValidUrl, parseJsonWithSchema, redactPII, redactPIIEffect, safeStringify, sanitizeHtml, sanitizeJson, sanitizeUrl, sanitizeUrlEffect, stripAnsi, validateUserInput, validateUserInputEffect } from "./sanitize.mjs";
4
+ export { CreditCardSchema, EmailSchema, IPv4Schema, PIIRedactionOptionsSchema, PhoneNumberSchema, SSNSchema, SafeUrlSchema, SanitizedStringSchema, THREAT_DETECTED_MESSAGE, UrlProtocolSchema, UserInputOptionsSchema, coerceCiphertext, coerceEncryptionKey, containsCommandInjection, containsHomoglyphs, containsNoSQLInjection, containsPathTraversal, containsSQLInjection, containsXSSPatterns, decryptData, detectThreatPatterns, encryptData, escapeHtml, generateSecureToken, getThreatErrorMessage, hashData, isCiphertext, isEncryptionKey, isSafeInput, isValidEmail, isValidPhone, isValidSSN, isValidUrl, maskEmail, maskPII, normalizeUnicode, parseJsonWithSchema, redactPII, redactPIIEffect, safeStringify, sanitizeForDisplay, sanitizeForLogging, sanitizeHtml, sanitizeJson, sanitizeUrl, sanitizeUrlEffect, stripAnsi, toCiphertext, toEncryptionKey, unsafeCiphertext, unsafeEncryptionKey, validateSafeEmail, validateSafeName, validateSafeText, validateUserInput, validateUserInputEffect };
@@ -0,0 +1,316 @@
1
+ import { Brand } from "@resq-systems/types";
2
+ import { Exit, Option, Schema } from "effect";
3
+ import { Config } from "dompurify";
4
+
5
+ //#region src/sanitize.d.ts
6
+ /**
7
+ * A Schema with DecodingServices constrained to `never`, allowing synchronous decoding.
8
+ */
9
+ type SyncSchema<T> = Schema.Codec<T, unknown, never>;
10
+ /**
11
+ * Schema for URL protocol validation
12
+ * @compliance NIST 800-53 SI-10 (Information Input Validation)
13
+ */
14
+ declare const UrlProtocolSchema: Schema.Literals<readonly ["http:", "https:", "mailto:", "tel:", "ftp:"]>;
15
+ type UrlProtocol = typeof UrlProtocolSchema.Type;
16
+ /**
17
+ * Schema for PII redaction options
18
+ * @compliance NIST 800-53 AU-3 (Content of Audit Records)
19
+ */
20
+ declare const PIIRedactionOptionsSchema: Schema.Struct<{
21
+ readonly redactEmails: Schema.optional<Schema.Boolean>;
22
+ readonly redactPhones: Schema.optional<Schema.Boolean>;
23
+ readonly redactSSN: Schema.optional<Schema.Boolean>;
24
+ readonly redactCreditCards: Schema.optional<Schema.Boolean>;
25
+ readonly redactIPs: Schema.optional<Schema.Boolean>;
26
+ readonly redactDates: Schema.optional<Schema.Boolean>;
27
+ }>;
28
+ type PIIRedactionOptions = typeof PIIRedactionOptionsSchema.Type;
29
+ /**
30
+ * Schema for user input validation options
31
+ * @compliance NIST 800-53 SI-10 (Information Input Validation)
32
+ */
33
+ declare const UserInputOptionsSchema: Schema.Struct<{
34
+ readonly maxLength: Schema.optional<Schema.Int>;
35
+ readonly allowHtml: Schema.optional<Schema.Boolean>;
36
+ readonly allowNewlines: Schema.optional<Schema.Boolean>;
37
+ readonly trimWhitespace: Schema.optional<Schema.Boolean>;
38
+ }>;
39
+ type UserInputOptions = typeof UserInputOptionsSchema.Type;
40
+ /**
41
+ * Schema for safe URL - validates URL format and protocol
42
+ * @compliance NIST 800-53 SI-10 (Information Input Validation)
43
+ */
44
+ declare const SafeUrlSchema: Schema.String;
45
+ /** A string that has passed {@link isValidUrl} — a validated, injection-safe URL. */
46
+ type SafeUrl = Brand<string, "SafeUrl">;
47
+ /**
48
+ * Schema for sanitized HTML-safe string (validates as string; escaping done at runtime)
49
+ * @compliance NIST 800-53 SI-10 (Information Input Validation)
50
+ */
51
+ declare const SanitizedStringSchema: Schema.String;
52
+ type SanitizedString = typeof SanitizedStringSchema.Type;
53
+ /**
54
+ * Schema for email address validation
55
+ * @compliance NIST 800-53 SI-10 (Information Input Validation)
56
+ */
57
+ declare const EmailSchema: Schema.String;
58
+ /** A string that has passed {@link isValidEmail}. */
59
+ type Email = Brand<string, "Email">;
60
+ /**
61
+ * Schema for phone number validation (US format)
62
+ * @compliance NIST 800-53 SI-10 (Information Input Validation)
63
+ */
64
+ declare const PhoneNumberSchema: Schema.String;
65
+ /** A string that has passed {@link isValidPhone} (US format). */
66
+ type PhoneNumber = Brand<string, "PhoneNumber">;
67
+ /**
68
+ * Schema for SSN validation (US format)
69
+ * @compliance NIST 800-53 SI-10 (Information Input Validation)
70
+ */
71
+ declare const SSNSchema: Schema.String;
72
+ /** A string that has passed {@link isValidSSN} (US format). */
73
+ type SSN = Brand<string, "SSN">;
74
+ /**
75
+ * Schema for credit card number validation
76
+ * @compliance NIST 800-53 SI-10 (Information Input Validation)
77
+ */
78
+ declare const CreditCardSchema: Schema.String;
79
+ /** A string matching the {@link CreditCardSchema} pattern. */
80
+ type CreditCard = Brand<string, "CreditCard">;
81
+ /**
82
+ * Schema for IPv4 address validation
83
+ */
84
+ declare const IPv4Schema: Schema.String;
85
+ /** A string matching the {@link IPv4Schema} dotted-quad pattern. */
86
+ type IPv4 = Brand<string, "IPv4">;
87
+ /**
88
+ * Escapes special HTML characters in a string to their corresponding HTML entities,
89
+ * preventing direct injection of HTML and JavaScript when rendering untrusted content.
90
+ *
91
+ * @param text - The plain text to escape.
92
+ * @returns The escaped string safe for HTML rendering.
93
+ * @compliance NIST 800-53 SI-10 (Information Input Validation)
94
+ *
95
+ * @example
96
+ * ```typescript
97
+ * escapeHtml('<script>alert("xss")</script>');
98
+ * // "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;"
99
+ * ```
100
+ */
101
+ declare const escapeHtml: (text: string) => string;
102
+ /**
103
+ * Validates and sanitizes a user-supplied URL using Effect Schema.
104
+ * Returns an Exit with the sanitized URL or an error.
105
+ *
106
+ * @param url - The URL to be validated and sanitized.
107
+ * @param allowedProtocols - Array of allowed URL protocols.
108
+ * @returns Exit containing the sanitized URL or an error.
109
+ * @compliance NIST 800-53 SI-10 (Information Input Validation)
110
+ *
111
+ * @example
112
+ * ```typescript
113
+ * const result = sanitizeUrlEffect('https://example.com');
114
+ * // Exit.succeed('https://example.com')
115
+ *
116
+ * const invalid = sanitizeUrlEffect('javascript:alert(1)');
117
+ * // Exit.fail(...)
118
+ * ```
119
+ */
120
+ declare const sanitizeUrlEffect: (url: string, allowedProtocols?: readonly UrlProtocol[]) => Exit.Exit<string, Schema.SchemaError>;
121
+ /**
122
+ * Validates and sanitizes a user-supplied URL, ensuring it conforms to allowed protocols
123
+ * and is not a vector for injection attacks like `javascript:` or `data:`.
124
+ *
125
+ * @param url - The URL to be validated and sanitized.
126
+ * @param allowedProtocols - Array of allowed URL protocols.
127
+ * @returns The sanitized URL if valid, or an empty string if unsafe.
128
+ * @compliance NIST 800-53 SI-10 (Information Input Validation)
129
+ *
130
+ * @example
131
+ * ```typescript
132
+ * sanitizeUrl('https://example.com'); // 'https://example.com'
133
+ * sanitizeUrl('javascript:alert(1)'); // ''
134
+ * ```
135
+ */
136
+ declare const sanitizeUrl: (url: string, allowedProtocols?: readonly UrlProtocol[]) => string;
137
+ /**
138
+ * Sanitizes HTML to prevent XSS attacks.
139
+ * Uses DOMPurify under the hood. If DOM is not available (e.g. server-side without JSDOM),
140
+ * it falls back to escaping all HTML characters for safety.
141
+ *
142
+ * NOTE: Server-side HTML sanitization requires `jsdom` to be installed in the consuming application
143
+ * environment; otherwise, it will fall back to escaping HTML characters.
144
+ *
145
+ * @param html - The HTML string to sanitize.
146
+ * @param options - Optional DOMPurify configuration.
147
+ * @returns The sanitized HTML string.
148
+ */
149
+ declare const sanitizeHtml: (html: string, options?: Config) => string;
150
+ /**
151
+ * Validates user input using Effect Schema and returns an Exit.
152
+ *
153
+ * @param input - User input to validate and sanitize.
154
+ * @param options - Validation options.
155
+ * @returns Exit containing sanitized input or error.
156
+ * @compliance NIST 800-53 SI-10 (Information Input Validation)
157
+ */
158
+ declare const validateUserInputEffect: (input: string, options?: UserInputOptions) => Exit.Exit<string, Schema.SchemaError>;
159
+ /**
160
+ * Validates and sanitizes generic user input by trimming, removing HTML tags (unless allowed),
161
+ * normalizing whitespace, and removing dangerous patterns to prevent XSS and basic injection flaws.
162
+ *
163
+ * @param input - User input to validate and sanitize.
164
+ * @param maxLength - Maximum allowed input length. Excess will be truncated.
165
+ * @param allowHtml - If true, HTML tags are preserved; otherwise, all tags are stripped.
166
+ * @returns Sanitized input string with length at most `maxLength`.
167
+ * @compliance NIST 800-53 SI-10 (Information Input Validation)
168
+ *
169
+ * @example
170
+ * ```typescript
171
+ * validateUserInput('<p>Hello!</p>', 50); // "Hello!"
172
+ * validateUserInput('<script>alert(1)</script>test', 100); // "test"
173
+ * ```
174
+ */
175
+ declare const validateUserInput: (input: string, maxLength?: number, allowHtml?: boolean) => string;
176
+ /**
177
+ * Safely parses JSON with Effect Schema validation and prototype pollution protection.
178
+ *
179
+ * @template A - The expected schema type
180
+ * @param jsonString - The JSON string to parse.
181
+ * @param schema - Effect Schema to validate against.
182
+ * @returns Option containing the parsed and validated object.
183
+ * @compliance NIST 800-53 SI-10 (Information Input Validation)
184
+ *
185
+ * @example
186
+ * ```typescript
187
+ * const UserSchema = S.Struct({ name: S.String, age: S.Number });
188
+ * const result = parseJsonWithSchema('{"name":"John","age":30}', UserSchema);
189
+ * // Option.some({ name: 'John', age: 30 })
190
+ * ```
191
+ */
192
+ declare const parseJsonWithSchema: <A>(jsonString: string, schema: SyncSchema<A>) => Option.Option<A>;
193
+ /**
194
+ * Sanitizes and safely parses a JSON string, removing suspicious syntax elements that could
195
+ * potentially result in JSON polyglot exploits or prototype pollution.
196
+ *
197
+ * The result is returned as `unknown` — this function performs **no** schema
198
+ * validation, so it cannot honestly promise any concrete shape for
199
+ * attacker-controlled input. Narrow the result yourself, or prefer
200
+ * {@link parseJsonWithSchema}, which validates against an Effect Schema and
201
+ * returns a typed `Option`.
202
+ *
203
+ * @param jsonString - The JSON string to sanitize and parse.
204
+ * @returns The parsed value (as `unknown`) if valid, or `null` if invalid.
205
+ * @compliance NIST 800-53 SI-10 (Information Input Validation)
206
+ *
207
+ * @example
208
+ * ```typescript
209
+ * const obj = sanitizeJson('{"foo":"bar"}');
210
+ * // obj: unknown — narrow before use, or use parseJsonWithSchema
211
+ * ```
212
+ */
213
+ declare const sanitizeJson: (jsonString: string) => unknown;
214
+ /**
215
+ * Strips ANSI escape codes from a string.
216
+ * Useful for cleaning terminal output before logging to files.
217
+ *
218
+ * @param text - The text potentially containing ANSI codes.
219
+ * @returns The text with ANSI codes removed.
220
+ *
221
+ * @example
222
+ * ```typescript
223
+ * stripAnsi('\x1b[31mRed text\x1b[0m'); // 'Red text'
224
+ * ```
225
+ */
226
+ declare const stripAnsi: (text: string) => string;
227
+ /**
228
+ * Redacts PII from text using Effect Schema validated options.
229
+ *
230
+ * @param text - The text to redact PII from.
231
+ * @param options - Configuration options for redaction.
232
+ * @returns Exit containing redacted text or error.
233
+ * @compliance NIST 800-53 AU-3 (Content of Audit Records)
234
+ */
235
+ declare const redactPIIEffect: (text: string, options?: PIIRedactionOptions) => Exit.Exit<string, Schema.SchemaError>;
236
+ /**
237
+ * Redacts common PII patterns in a string for safe logging.
238
+ * Detects and masks SSNs, credit cards, emails, phone numbers, etc.
239
+ *
240
+ * @param text - The text to redact PII from.
241
+ * @param options - Configuration options for redaction.
242
+ * @returns The text with PII patterns replaced with redaction markers.
243
+ * @compliance NIST 800-53 AU-3 (Content of Audit Records)
244
+ *
245
+ * @example
246
+ * ```typescript
247
+ * redactPII('Contact john@example.com or call 555-123-4567');
248
+ * // 'Contact [EMAIL] or call [PHONE]'
249
+ *
250
+ * redactPII('SSN: 123-45-6789');
251
+ * // 'SSN: [SSN]'
252
+ * ```
253
+ */
254
+ declare const redactPII: (text: string, options?: PIIRedactionOptions & {
255
+ customPatterns?: Array<{
256
+ pattern: RegExp;
257
+ replacement: string;
258
+ }>;
259
+ }) => string;
260
+ /**
261
+ * Creates a safe string representation of an object for logging,
262
+ * automatically redacting sensitive fields.
263
+ *
264
+ * @param obj - The object to stringify.
265
+ * @param sensitiveKeys - Array of key names to redact.
266
+ * @param indent - JSON indentation (default: 2).
267
+ * @returns A JSON string with sensitive values redacted.
268
+ * @compliance NIST 800-53 AU-3 (Content of Audit Records)
269
+ *
270
+ * @example
271
+ * ```typescript
272
+ * safeStringify({ user: 'john', password: 'secret123' }, ['password']);
273
+ * // '{\n "user": "john",\n "password": "[REDACTED]"\n}'
274
+ * ```
275
+ */
276
+ declare const safeStringify: (obj: unknown, sensitiveKeys?: string[], indent?: number) => string;
277
+ /**
278
+ * Validates if a string is a valid email address using Effect Schema.
279
+ *
280
+ * Narrows the input to {@link Email} on success, so validated call sites
281
+ * carry the brand into downstream code.
282
+ *
283
+ * @param email - The string to validate.
284
+ * @returns true if valid email, false otherwise.
285
+ */
286
+ declare const isValidEmail: (email: string) => email is Email;
287
+ /**
288
+ * Validates if a string is a valid phone number using Effect Schema.
289
+ *
290
+ * Narrows the input to {@link PhoneNumber} on success.
291
+ *
292
+ * @param phone - The string to validate.
293
+ * @returns true if valid phone number, false otherwise.
294
+ */
295
+ declare const isValidPhone: (phone: string) => phone is PhoneNumber;
296
+ /**
297
+ * Validates if a string is a valid SSN using Effect Schema.
298
+ *
299
+ * Narrows the input to {@link SSN} on success.
300
+ *
301
+ * @param ssn - The string to validate.
302
+ * @returns true if valid SSN, false otherwise.
303
+ */
304
+ declare const isValidSSN: (ssn: string) => ssn is SSN;
305
+ /**
306
+ * Validates if a string is a safe URL using Effect Schema.
307
+ *
308
+ * Narrows the input to {@link SafeUrl} on success.
309
+ *
310
+ * @param url - The string to validate.
311
+ * @returns true if valid and safe URL, false otherwise.
312
+ */
313
+ declare const isValidUrl: (url: string) => url is SafeUrl;
314
+ //#endregion
315
+ export { CreditCard, CreditCardSchema, Email, EmailSchema, IPv4, IPv4Schema, PIIRedactionOptions, PIIRedactionOptionsSchema, PhoneNumber, PhoneNumberSchema, SSN, SSNSchema, SafeUrl, SafeUrlSchema, SanitizedString, SanitizedStringSchema, UrlProtocol, UrlProtocolSchema, UserInputOptions, UserInputOptionsSchema, escapeHtml, isValidEmail, isValidPhone, isValidSSN, isValidUrl, parseJsonWithSchema, redactPII, redactPIIEffect, safeStringify, sanitizeHtml, sanitizeJson, sanitizeUrl, sanitizeUrlEffect, stripAnsi, validateUserInput, validateUserInputEffect };
316
+ //# sourceMappingURL=sanitize.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sanitize.d.mts","names":[],"sources":["../src/sanitize.ts"],"mappings":";;;;;;;;KAiCK,UAAA,MAAgB,MAAA,CAAE,KAAA,CAAM,CAAA;;;;;cAUhB,iBAAA,EAAiB,MAAA,CAAA,QAAA;AAAA,KAClB,WAAA,UAAqB,iBAAA,CAAkB,IAAA;;;;;cAMtC,yBAAA,EAAyB,MAAA,CAAA,MAAA;EAAA;;;;;;;KAQ1B,mBAAA,UAA6B,yBAAA,CAA0B,IAAA;;;;;cAMtD,sBAAA,EAAsB,MAAA,CAAA,MAAA;EAAA;;;;;KAMvB,gBAAA,UAA0B,sBAAA,CAAuB,IAAA;;;;;cAMhD,aAAA,EAAa,MAAA,CAAA,MAAA;;KAiBd,OAAA,GAAU,KAAA;;;;;cAMT,qBAAA,EAAqB,MAAA,CAAA,MAAA;AAAA,KACtB,eAAA,UAAyB,qBAAA,CAAsB,IAAA;;;;;cAM9C,WAAA,EAAW,MAAA,CAAA,MAAA;;KAIZ,KAAA,GAAQ,KAAA;;;AA9CpB;;cAoDa,iBAAA,EAAiB,MAAA,CAAA,MAAA;;KAIlB,WAAA,GAAc,KAAA;;;;;cAMb,SAAA,EAAS,MAAA,CAAA,MAAA;;KAEV,GAAA,GAAM,KAAA;;;;;cAML,gBAAA,EAAgB,MAAA,CAAA,MAAA;;KAIjB,UAAA,GAAa,KAAA;;;;cAKZ,UAAA,EAAU,MAAA,CAAA,MAAA;;KAEX,IAAA,GAAO,KAAA;;;;;;;;;;;;;;AA3EnB;cA+Fa,UAAA,GAAc,IAAA;;;;AAzF3B;;;;;AAiBA;;;;;AAMA;;;;;cAiGa,iBAAA,GACZ,GAAA,UACA,gBAAA,YAA2B,WAAA,OACzB,IAAA,CAAK,IAAA,SAAa,MAAA,CAAE,WAAA;;;;;AA7FvB;;;;;AAIA;;;;;AAMA;cA+Ha,WAAA,GACZ,GAAA,UACA,gBAAA,YAA2B,WAAA;;;;AA7H5B;;;;;AAMA;;;;cAiLa,YAAA,GAAgB,IAAA,UAAc,OAAA,GAAU,MAAA;AA/KrD;;;;;AAMA;;;AANA,cAoMa,uBAAA,GACZ,KAAA,UACA,OAAA,GAAS,gBAAA,KACP,IAAA,CAAK,IAAA,SAAa,MAAA,CAAE,WAAA;;AA7LvB;;;;;AAKA;;;;;AAEA;;;;;cAqPa,iBAAA,GAAqB,KAAA,UAAe,SAAA,WAAiB,SAAA;;;;;AAlMlE;;;;;;;;;;;;cAuPa,mBAAA,MACZ,UAAA,UACA,MAAA,EAAQ,UAAA,CAAW,CAAA,MACjB,MAAA,CAAO,MAAA,CAAO,CAAA;;;;;AA3MjB;;;;;;;;;AA4DA;;;;;;;cAyLa,YAAA,GAAgB,UAAA;;AApK7B;;;;;;;;;;;cAqMa,SAAA,GAAa,IAAA;;;;;;AAnI1B;;;cAyKa,eAAA,GACZ,IAAA,UACA,OAAA,GAAS,mBAAA,KACP,IAAA,CAAK,IAAA,SAAa,MAAA,CAAE,WAAA;;;;;;AAvHvB;;;;;;;;;;;;;cAwLa,SAAA,GACZ,IAAA,UACA,OAAA,GAAS,mBAAA;EACR,cAAA,GAAiB,KAAA;IAAQ,OAAA,EAAS,MAAA;IAAQ,WAAA;EAAA;AAAA;AA9I5C;;;;;AAiCA;;;;;AAsCA;;;;;;AAvEA,cAiMa,aAAA,GACZ,GAAA,WACA,aAAA,aAUA,MAAA;;;;;;;;;;cA+BY,YAAA,GAAgB,KAAA,aAAgB,KAAA,IAAS,KAAA;AAjGtD;;;;;;;;AAAA,cA6Ga,YAAA,GAAgB,KAAA,aAAgB,KAAA,IAAS,WAAA;;;;;;;;;cAYzC,UAAA,GAAc,GAAA,aAAc,GAAA,IAAO,GAAA;AAnEhD;;;;;;;;AAAA,cA+Ea,UAAA,GAAc,GAAA,aAAc,GAAA,IAAO,OAAA"}