@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/README.md +280 -0
- package/lib/crypto.d.mts +195 -0
- package/lib/crypto.d.mts.map +1 -0
- package/lib/crypto.mjs +294 -0
- package/lib/crypto.mjs.map +1 -0
- package/lib/index.d.mts +4 -0
- package/lib/index.mjs +4 -0
- package/lib/sanitize.d.mts +316 -0
- package/lib/sanitize.d.mts.map +1 -0
- package/lib/sanitize.mjs +529 -0
- package/lib/sanitize.mjs.map +1 -0
- package/lib/validators.d.mts +269 -0
- package/lib/validators.d.mts.map +1 -0
- package/lib/validators.mjs +487 -0
- package/lib/validators.mjs.map +1 -0
- package/package.json +86 -0
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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"}
|
package/lib/index.d.mts
ADDED
|
@@ -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
|
+
* // "<script>alert("xss")</script>"
|
|
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"}
|