@resq-systems/security 1.0.5 → 2.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.
Files changed (108) hide show
  1. package/README.md +236 -33
  2. package/lib/controls/address.d.mts +142 -0
  3. package/lib/controls/address.d.mts.map +1 -0
  4. package/lib/controls/address.mjs +533 -0
  5. package/lib/controls/address.mjs.map +1 -0
  6. package/lib/controls/csrf.d.mts +91 -0
  7. package/lib/controls/csrf.d.mts.map +1 -0
  8. package/lib/controls/csrf.mjs +200 -0
  9. package/lib/controls/csrf.mjs.map +1 -0
  10. package/lib/controls/index.d.mts +8 -0
  11. package/lib/controls/index.mjs +8 -0
  12. package/lib/controls/origin.d.mts +95 -0
  13. package/lib/controls/origin.d.mts.map +1 -0
  14. package/lib/controls/origin.mjs +156 -0
  15. package/lib/controls/origin.mjs.map +1 -0
  16. package/lib/controls/payload.d.mts +84 -0
  17. package/lib/controls/payload.d.mts.map +1 -0
  18. package/lib/controls/payload.mjs +147 -0
  19. package/lib/controls/payload.mjs.map +1 -0
  20. package/lib/controls/query.d.mts +157 -0
  21. package/lib/controls/query.d.mts.map +1 -0
  22. package/lib/controls/query.mjs +368 -0
  23. package/lib/controls/query.mjs.map +1 -0
  24. package/lib/controls/redirect.d.mts +92 -0
  25. package/lib/controls/redirect.d.mts.map +1 -0
  26. package/lib/controls/redirect.mjs +110 -0
  27. package/lib/controls/redirect.mjs.map +1 -0
  28. package/lib/controls/upload.d.mts +108 -0
  29. package/lib/controls/upload.d.mts.map +1 -0
  30. package/lib/controls/upload.mjs +374 -0
  31. package/lib/controls/upload.mjs.map +1 -0
  32. package/lib/crypto.d.mts +18 -5
  33. package/lib/crypto.d.mts.map +1 -1
  34. package/lib/crypto.mjs +35 -24
  35. package/lib/crypto.mjs.map +1 -1
  36. package/lib/hash.d.mts +51 -6
  37. package/lib/hash.d.mts.map +1 -1
  38. package/lib/hash.mjs +51 -6
  39. package/lib/hash.mjs.map +1 -1
  40. package/lib/index.d.mts +17 -2
  41. package/lib/index.mjs +19 -2
  42. package/lib/paths.d.mts +92 -0
  43. package/lib/paths.d.mts.map +1 -0
  44. package/lib/paths.mjs +140 -0
  45. package/lib/paths.mjs.map +1 -0
  46. package/lib/sanitize.d.mts +137 -35
  47. package/lib/sanitize.d.mts.map +1 -1
  48. package/lib/sanitize.mjs +170 -46
  49. package/lib/sanitize.mjs.map +1 -1
  50. package/lib/threats/capec.generated.d.mts +59 -0
  51. package/lib/threats/capec.generated.d.mts.map +1 -0
  52. package/lib/threats/capec.generated.mjs +644 -0
  53. package/lib/threats/capec.generated.mjs.map +1 -0
  54. package/lib/threats/engine.d.mts +94 -0
  55. package/lib/threats/engine.d.mts.map +1 -0
  56. package/lib/threats/engine.mjs +167 -0
  57. package/lib/threats/engine.mjs.map +1 -0
  58. package/lib/threats/index.d.mts +11 -0
  59. package/lib/threats/index.mjs +11 -0
  60. package/lib/threats/rules/datastore.d.mts +13 -0
  61. package/lib/threats/rules/datastore.d.mts.map +1 -0
  62. package/lib/threats/rules/datastore.mjs +366 -0
  63. package/lib/threats/rules/datastore.mjs.map +1 -0
  64. package/lib/threats/rules/index.d.mts +54 -0
  65. package/lib/threats/rules/index.d.mts.map +1 -0
  66. package/lib/threats/rules/index.mjs +121 -0
  67. package/lib/threats/rules/index.mjs.map +1 -0
  68. package/lib/threats/rules/markup.d.mts +28 -0
  69. package/lib/threats/rules/markup.d.mts.map +1 -0
  70. package/lib/threats/rules/markup.mjs +373 -0
  71. package/lib/threats/rules/markup.mjs.map +1 -0
  72. package/lib/threats/rules/protocol.d.mts +49 -0
  73. package/lib/threats/rules/protocol.d.mts.map +1 -0
  74. package/lib/threats/rules/protocol.mjs +175 -0
  75. package/lib/threats/rules/protocol.mjs.map +1 -0
  76. package/lib/threats/rules/system.d.mts +19 -0
  77. package/lib/threats/rules/system.d.mts.map +1 -0
  78. package/lib/threats/rules/system.mjs +455 -0
  79. package/lib/threats/rules/system.mjs.map +1 -0
  80. package/lib/threats/rules/web.d.mts +26 -0
  81. package/lib/threats/rules/web.d.mts.map +1 -0
  82. package/lib/threats/rules/web.mjs +412 -0
  83. package/lib/threats/rules/web.mjs.map +1 -0
  84. package/lib/threats/scoring.d.mts +59 -0
  85. package/lib/threats/scoring.d.mts.map +1 -0
  86. package/lib/threats/scoring.mjs +111 -0
  87. package/lib/threats/scoring.mjs.map +1 -0
  88. package/lib/threats/types.d.mts +245 -0
  89. package/lib/threats/types.d.mts.map +1 -0
  90. package/lib/threats/types.mjs +52 -0
  91. package/lib/threats/types.mjs.map +1 -0
  92. package/lib/threats/variants.d.mts +57 -0
  93. package/lib/threats/variants.d.mts.map +1 -0
  94. package/lib/threats/variants.mjs +144 -0
  95. package/lib/threats/variants.mjs.map +1 -0
  96. package/lib/unicode/confusables.d.mts +82 -0
  97. package/lib/unicode/confusables.d.mts.map +1 -0
  98. package/lib/unicode/confusables.mjs +954 -0
  99. package/lib/unicode/confusables.mjs.map +1 -0
  100. package/lib/unicode/index.d.mts +126 -0
  101. package/lib/unicode/index.d.mts.map +1 -0
  102. package/lib/unicode/index.mjs +288 -0
  103. package/lib/unicode/index.mjs.map +1 -0
  104. package/lib/validators.d.mts +341 -164
  105. package/lib/validators.d.mts.map +1 -1
  106. package/lib/validators.mjs +519 -338
  107. package/lib/validators.mjs.map +1 -1
  108. package/package.json +35 -8
package/lib/crypto.mjs CHANGED
@@ -18,29 +18,25 @@ import { promisify } from "node:util";
18
18
  * limitations under the License.
19
19
  */
20
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)
21
+ * @fileoverview Server-side cryptographic utilities — AES-256-GCM authenticated
22
+ * encryption, SHA-256 hashing, secure token generation, and PII masking — guarded
23
+ * by nominal branded types for keys, ciphertext, and masked output. Supports SOC 2,
24
+ * ISO 27001, and NIST 800-53 SC-28 (data at rest) / SC-13 (cryptographic protection)
25
+ * controls.
26
+ *
27
+ * @module @resq-systems/security/crypto
30
28
  */
31
29
  const scryptAsync = promisify(scrypt);
32
- /** AES-256-GCM encryption algorithm */
30
+ /** AES-256-GCM encryption algorithm. */
33
31
  const ALGORITHM = "aes-256-gcm";
34
- /** Initialization vector length in bytes */
32
+ /** Initialization vector length in bytes. */
35
33
  const IV_LENGTH = 16;
36
- /** Authentication tag length in bytes */
37
- const AUTH_TAG_LENGTH = 16;
38
- /** Salt length for key derivation */
34
+ /** Salt length for key derivation, in bytes. */
39
35
  const SALT_LENGTH = 32;
40
- /** Derived key length (256 bits for AES-256) */
36
+ /** Derived key length in bytes (256 bits for AES-256). */
41
37
  const KEY_LENGTH = 32;
42
38
  /** Minimum decoded byte length of a well-formed {@link Ciphertext} envelope. */
43
- const CIPHERTEXT_MIN_BYTES = SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH;
39
+ const CIPHERTEXT_MIN_BYTES = 64;
44
40
  /**
45
41
  * Smart constructors for {@link EncryptionKey}. The runtime check is a
46
42
  * non-empty string — scrypt stretches any non-empty secret into a 256-bit
@@ -103,7 +99,13 @@ async function deriveKey(password, salt) {
103
99
  * the salt/IV are recovered on decryption.
104
100
  *
105
101
  * @throws From the underlying Node crypto primitives if `encryptionKey`
106
- * is empty or scrypt fails.
102
+ * is empty or scrypt fails. Failure surfaces as a rejected `Promise`,
103
+ * never a resolved error value.
104
+ *
105
+ * Draws from the platform CSPRNG (`randomBytes`) each call, so it is not
106
+ * a pure function and its output is non-deterministic. There is no
107
+ * `AbortSignal` hook — once awaited the scrypt work runs to completion.
108
+ * Independent calls share no state and are safe to run concurrently.
107
109
  *
108
110
  * @compliance NIST 800-53 SC-28 (Protection of Information at Rest),
109
111
  * SC-13 (Cryptographic Protection).
@@ -143,7 +145,9 @@ async function encryptData(plaintext, encryptionKey) {
143
145
  *
144
146
  * @throws Error if the tag does not verify (wrong key, modified
145
147
  * ciphertext, truncated payload). Catch this and treat it as a
146
- * security event, not a recoverable error.
148
+ * security event, not a recoverable error. The rejection comes back
149
+ * as a rejected `Promise`. No `AbortSignal` is honoured; concurrent
150
+ * calls are independent and share no state.
147
151
  *
148
152
  * @example
149
153
  * ```ts
@@ -153,10 +157,11 @@ async function encryptData(plaintext, encryptionKey) {
153
157
  async function decryptData(encryptedData, encryptionKey) {
154
158
  const combined = Buffer.from(encryptedData, "base64");
155
159
  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
+ const iv = combined.subarray(SALT_LENGTH, 48);
161
+ const authTag = combined.subarray(48, 64);
162
+ const ciphertext = combined.subarray(64);
163
+ const key = await deriveKey(encryptionKey, salt);
164
+ const decipher = createDecipheriv(ALGORITHM, key, iv);
160
165
  decipher.setAuthTag(authTag);
161
166
  return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
162
167
  }
@@ -259,8 +264,14 @@ function maskEmail(email) {
259
264
  * key, e.g. `"token"` matches `"refreshToken"` and `"id_token"`.
260
265
  *
261
266
  * @returns A new object with sensitive fields redacted and emails
262
- * masked. Nested objects are recursed; arrays and primitives pass
263
- * through unchanged.
267
+ * masked. Any non-null object value is recursed and comes back as a
268
+ * plain object keyed by its enumerable own properties — so arrays
269
+ * return as index-keyed objects (`["a"]` → `{ "0": "a" }`) and class
270
+ * instances / `Date`s lose their prototype. Only primitives, `null`,
271
+ * and `undefined` pass through unchanged.
272
+ *
273
+ * @throws {RangeError} On a circular reference — recursion has no cycle
274
+ * guard, so a self-referential object overflows the call stack.
264
275
  *
265
276
  * @example
266
277
  * ```ts
@@ -1 +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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"}
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 * @fileoverview Server-side cryptographic utilities — AES-256-GCM authenticated\n * encryption, SHA-256 hashing, secure token generation, and PII masking — guarded\n * by nominal branded types for keys, ciphertext, and masked output. Supports SOC 2,\n * ISO 27001, and NIST 800-53 SC-28 (data at rest) / SC-13 (cryptographic protection)\n * controls.\n *\n * @module @resq-systems/security/crypto\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\n//#region Constants\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, in bytes. */\nconst SALT_LENGTH = 32;\n/** Derived key length in bytes (256 bits for AES-256). */\nconst KEY_LENGTH = 32;\n//#endregion\n\n//#region Branded Types\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//#endregion\n\n//#region Internal\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//#endregion\n\n//#region Public API\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. Failure surfaces as a rejected `Promise`,\n * never a resolved error value.\n *\n * Draws from the platform CSPRNG (`randomBytes`) each call, so it is not\n * a pure function and its output is non-deterministic. There is no\n * `AbortSignal` hook — once awaited the scrypt work runs to completion.\n * Independent calls share no state and are safe to run concurrently.\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. The rejection comes back\n * as a rejected `Promise`. No `AbortSignal` is honoured; concurrent\n * calls are independent and share no state.\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. Any non-null object value is recursed and comes back as a\n * plain object keyed by its enumerable own properties — so arrays\n * return as index-keyed objects (`[\"a\"]` → `{ \"0\": \"a\" }`) and class\n * instances / `Date`s lose their prototype. Only primitives, `null`,\n * and `undefined` pass through unchanged.\n *\n * @throws {RangeError} On a circular reference — recursion has no cycle\n * guard, so a self-referential object overflows the call stack.\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//#endregion\n"],"mappings":"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"}
package/lib/hash.d.mts CHANGED
@@ -15,22 +15,67 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  /**
18
- * Hash a string using the FNV-1a algorithm.
18
+ * @fileoverview Fast, deterministic, non-cryptographic hashing helpers — a 32-bit
19
+ * string/buffer hash for cache keys and change detection, plus a lightweight string
20
+ * obfuscation transform. Not suitable for security-sensitive digests; use the
21
+ * SHA-256 helper in {@link @resq-systems/security/crypto} for those.
19
22
  *
20
- * Generates a deterministic hash value for a given string using a variant of the FNV-1a
21
- * (Fowler-Noll-Vo) algorithm. The hash is returned as a string representation of a 32-bit integer.
23
+ * @module @resq-systems/security/hash
24
+ */
25
+ /**
26
+ * Compute a deterministic non-cryptographic 32-bit hash of a string.
27
+ *
28
+ * Uses the classic `hash * 31 + charCode` accumulation (expressed as
29
+ * `(hash << 5) - hash`), yielding the same value for the same input across runs.
30
+ * Intended for cache keys and change detection, not for security.
31
+ *
32
+ * @param string - The input to hash.
33
+ * @returns The signed 32-bit hash rendered as a decimal string.
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * getHashForString("hello") === getHashForString("hello"); // → true
38
+ * ```
22
39
  */
23
40
  declare function getHashForString(string: string): string;
24
41
  /**
25
- * Hash an object by converting it to JSON and then hashing the resulting string.
42
+ * Hash an arbitrary value by serializing it to JSON and hashing the result.
43
+ *
44
+ * Key ordering follows `JSON.stringify`, so two structurally equal objects with
45
+ * differently ordered keys can hash differently.
46
+ *
47
+ * @param obj - Any JSON-serializable value.
48
+ * @returns The 32-bit hash of the serialized form, as a decimal string.
49
+ * @throws {TypeError} When `obj` cannot be serialized: a circular
50
+ * reference or a `BigInt` makes `JSON.stringify` throw, and a value
51
+ * that stringifies to `undefined` (a bare function, `symbol`, or
52
+ * `undefined`) makes the downstream `.length` access throw.
26
53
  */
27
54
  declare function getHashForObject(obj: unknown): string;
28
55
  /**
29
- * Hash an ArrayBuffer using the FNV-1a algorithm.
56
+ * Compute a deterministic non-cryptographic 32-bit hash of an `ArrayBuffer`.
57
+ *
58
+ * Applies the same accumulation as {@link getHashForString} over each byte.
59
+ *
60
+ * @param buffer - The bytes to hash.
61
+ * @returns The signed 32-bit hash rendered as a decimal string.
30
62
  */
31
63
  declare function getHashForBuffer(buffer: ArrayBuffer): string;
32
64
  /**
33
- * Applies a string transformation algorithm that rearranges and modifies characters.
65
+ * Reversibly scramble a string by rotating character blocks and shifting digits.
66
+ *
67
+ * A lightweight, non-cryptographic obfuscation: it reorders characters via a fixed
68
+ * sequence of block rotations, reverses the result, and maps each digit `d` to
69
+ * `d < 5 ? d + 5 : d > 5 ? d - 5 : d`. Provides obscurity, not security — do not
70
+ * use it to protect secrets.
71
+ *
72
+ * Despite "scramble", the transform is **not a true inverse**: the digit map sends
73
+ * both `0` and `5` to `5`, so any input containing those digits cannot be
74
+ * recovered unambiguously. Non-digit characters (including whitespace) are only
75
+ * reordered, never substituted. Deterministic and free of side effects.
76
+ *
77
+ * @param str - The string to transform.
78
+ * @returns The transformed string, same length as `str`.
34
79
  */
35
80
  declare function lns(str: string): string;
36
81
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"hash.d.mts","names":[],"sources":["../src/hash.ts"],"mappings":";;AAsBA;;;;;AAYA;;;;;AAOA;;;;;AAaA;;;;;iBAhCgB,gBAAA,CAAiB,MAAA;;;;iBAYjB,gBAAA,CAAiB,GAAA;;;;iBAOjB,gBAAA,CAAiB,MAAA,EAAQ,WAAA;;;;iBAazB,GAAA,CAAI,GAAA"}
1
+ {"version":3,"file":"hash.d.mts","names":[],"sources":["../src/hash.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAwCgB,iBAAiB;;;;;;;;;;;;;;iBAsBjB,iBAAiB;;;;;;;;;iBAYjB,iBAAiB,QAAQ;;;;;;;;;;;;;;;;;iBA0BzB,IAAI"}
package/lib/hash.mjs CHANGED
@@ -15,10 +15,27 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  /**
18
- * Hash a string using the FNV-1a algorithm.
18
+ * @fileoverview Fast, deterministic, non-cryptographic hashing helpers — a 32-bit
19
+ * string/buffer hash for cache keys and change detection, plus a lightweight string
20
+ * obfuscation transform. Not suitable for security-sensitive digests; use the
21
+ * SHA-256 helper in {@link @resq-systems/security/crypto} for those.
19
22
  *
20
- * Generates a deterministic hash value for a given string using a variant of the FNV-1a
21
- * (Fowler-Noll-Vo) algorithm. The hash is returned as a string representation of a 32-bit integer.
23
+ * @module @resq-systems/security/hash
24
+ */
25
+ /**
26
+ * Compute a deterministic non-cryptographic 32-bit hash of a string.
27
+ *
28
+ * Uses the classic `hash * 31 + charCode` accumulation (expressed as
29
+ * `(hash << 5) - hash`), yielding the same value for the same input across runs.
30
+ * Intended for cache keys and change detection, not for security.
31
+ *
32
+ * @param string - The input to hash.
33
+ * @returns The signed 32-bit hash rendered as a decimal string.
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * getHashForString("hello") === getHashForString("hello"); // → true
38
+ * ```
22
39
  */
23
40
  function getHashForString(string) {
24
41
  let hash = 0;
@@ -29,13 +46,28 @@ function getHashForString(string) {
29
46
  return `${hash}`;
30
47
  }
31
48
  /**
32
- * Hash an object by converting it to JSON and then hashing the resulting string.
49
+ * Hash an arbitrary value by serializing it to JSON and hashing the result.
50
+ *
51
+ * Key ordering follows `JSON.stringify`, so two structurally equal objects with
52
+ * differently ordered keys can hash differently.
53
+ *
54
+ * @param obj - Any JSON-serializable value.
55
+ * @returns The 32-bit hash of the serialized form, as a decimal string.
56
+ * @throws {TypeError} When `obj` cannot be serialized: a circular
57
+ * reference or a `BigInt` makes `JSON.stringify` throw, and a value
58
+ * that stringifies to `undefined` (a bare function, `symbol`, or
59
+ * `undefined`) makes the downstream `.length` access throw.
33
60
  */
34
61
  function getHashForObject(obj) {
35
62
  return getHashForString(JSON.stringify(obj));
36
63
  }
37
64
  /**
38
- * Hash an ArrayBuffer using the FNV-1a algorithm.
65
+ * Compute a deterministic non-cryptographic 32-bit hash of an `ArrayBuffer`.
66
+ *
67
+ * Applies the same accumulation as {@link getHashForString} over each byte.
68
+ *
69
+ * @param buffer - The bytes to hash.
70
+ * @returns The signed 32-bit hash rendered as a decimal string.
39
71
  */
40
72
  function getHashForBuffer(buffer) {
41
73
  const view = new DataView(buffer);
@@ -47,7 +79,20 @@ function getHashForBuffer(buffer) {
47
79
  return `${hash}`;
48
80
  }
49
81
  /**
50
- * Applies a string transformation algorithm that rearranges and modifies characters.
82
+ * Reversibly scramble a string by rotating character blocks and shifting digits.
83
+ *
84
+ * A lightweight, non-cryptographic obfuscation: it reorders characters via a fixed
85
+ * sequence of block rotations, reverses the result, and maps each digit `d` to
86
+ * `d < 5 ? d + 5 : d > 5 ? d - 5 : d`. Provides obscurity, not security — do not
87
+ * use it to protect secrets.
88
+ *
89
+ * Despite "scramble", the transform is **not a true inverse**: the digit map sends
90
+ * both `0` and `5` to `5`, so any input containing those digits cannot be
91
+ * recovered unambiguously. Non-digit characters (including whitespace) are only
92
+ * reordered, never substituted. Deterministic and free of side effects.
93
+ *
94
+ * @param str - The string to transform.
95
+ * @returns The transformed string, same length as `str`.
51
96
  */
52
97
  function lns(str) {
53
98
  const result = str.split("");
package/lib/hash.mjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"hash.mjs","names":[],"sources":["../src/hash.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 * Hash a string using the FNV-1a algorithm.\n *\n * Generates a deterministic hash value for a given string using a variant of the FNV-1a\n * (Fowler-Noll-Vo) algorithm. The hash is returned as a string representation of a 32-bit integer.\n */\nexport function getHashForString(string: string): string {\n\tlet hash = 0;\n\tfor (let i = 0; i < string.length; i++) {\n\t\thash = (hash << 5) - hash + string.charCodeAt(i);\n\t\thash |= 0; // Convert to 32bit integer\n\t}\n\treturn `${hash}`;\n}\n\n/**\n * Hash an object by converting it to JSON and then hashing the resulting string.\n */\nexport function getHashForObject(obj: unknown): string {\n\treturn getHashForString(JSON.stringify(obj));\n}\n\n/**\n * Hash an ArrayBuffer using the FNV-1a algorithm.\n */\nexport function getHashForBuffer(buffer: ArrayBuffer): string {\n\tconst view = new DataView(buffer);\n\tlet hash = 0;\n\tfor (let i = 0; i < view.byteLength; i++) {\n\t\thash = (hash << 5) - hash + view.getUint8(i);\n\t\thash |= 0; // Convert to 32bit integer\n\t}\n\treturn `${hash}`;\n}\n\n/**\n * Applies a string transformation algorithm that rearranges and modifies characters.\n */\nexport function lns(str: string): string {\n\tconst result = str.split(\"\");\n\tresult.push(...result.splice(0, Math.round(result.length / 5)));\n\tresult.push(...result.splice(0, Math.round(result.length / 4)));\n\tresult.push(...result.splice(0, Math.round(result.length / 3)));\n\tresult.push(...result.splice(0, Math.round(result.length / 2)));\n\treturn result\n\t\t.reverse()\n\t\t.map((n) => {\n\t\t\tconst num = Number(n);\n\t\t\treturn Number.isNaN(num) || n.trim() === \"\"\n\t\t\t\t? n\n\t\t\t\t: num < 5\n\t\t\t\t\t? String(5 + num)\n\t\t\t\t\t: num > 5\n\t\t\t\t\t\t? String(num - 5)\n\t\t\t\t\t\t: n;\n\t\t})\n\t\t.join(\"\");\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAsBA,SAAgB,iBAAiB,QAAwB;CACxD,IAAI,OAAO;AACX,MAAK,IAAI,IAAI,GAAG,IAAI,OAAO,QAAQ,KAAK;AACvC,UAAQ,QAAQ,KAAK,OAAO,OAAO,WAAW,EAAE;AAChD,UAAQ;;AAET,QAAO,GAAG;;;;;AAMX,SAAgB,iBAAiB,KAAsB;AACtD,QAAO,iBAAiB,KAAK,UAAU,IAAI,CAAC;;;;;AAM7C,SAAgB,iBAAiB,QAA6B;CAC7D,MAAM,OAAO,IAAI,SAAS,OAAO;CACjC,IAAI,OAAO;AACX,MAAK,IAAI,IAAI,GAAG,IAAI,KAAK,YAAY,KAAK;AACzC,UAAQ,QAAQ,KAAK,OAAO,KAAK,SAAS,EAAE;AAC5C,UAAQ;;AAET,QAAO,GAAG;;;;;AAMX,SAAgB,IAAI,KAAqB;CACxC,MAAM,SAAS,IAAI,MAAM,GAAG;AAC5B,QAAO,KAAK,GAAG,OAAO,OAAO,GAAG,KAAK,MAAM,OAAO,SAAS,EAAE,CAAC,CAAC;AAC/D,QAAO,KAAK,GAAG,OAAO,OAAO,GAAG,KAAK,MAAM,OAAO,SAAS,EAAE,CAAC,CAAC;AAC/D,QAAO,KAAK,GAAG,OAAO,OAAO,GAAG,KAAK,MAAM,OAAO,SAAS,EAAE,CAAC,CAAC;AAC/D,QAAO,KAAK,GAAG,OAAO,OAAO,GAAG,KAAK,MAAM,OAAO,SAAS,EAAE,CAAC,CAAC;AAC/D,QAAO,OACL,SAAS,CACT,KAAK,MAAM;EACX,MAAM,MAAM,OAAO,EAAE;AACrB,SAAO,OAAO,MAAM,IAAI,IAAI,EAAE,MAAM,KAAK,KACtC,IACA,MAAM,IACL,OAAO,IAAI,IAAI,GACf,MAAM,IACL,OAAO,MAAM,EAAE,GACf;GACJ,CACD,KAAK,GAAG"}
1
+ {"version":3,"file":"hash.mjs","names":[],"sources":["../src/hash.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 * @fileoverview Fast, deterministic, non-cryptographic hashing helpers — a 32-bit\n * string/buffer hash for cache keys and change detection, plus a lightweight string\n * obfuscation transform. Not suitable for security-sensitive digests; use the\n * SHA-256 helper in {@link @resq-systems/security/crypto} for those.\n *\n * @module @resq-systems/security/hash\n */\n\n/**\n * Compute a deterministic non-cryptographic 32-bit hash of a string.\n *\n * Uses the classic `hash * 31 + charCode` accumulation (expressed as\n * `(hash << 5) - hash`), yielding the same value for the same input across runs.\n * Intended for cache keys and change detection, not for security.\n *\n * @param string - The input to hash.\n * @returns The signed 32-bit hash rendered as a decimal string.\n *\n * @example\n * ```ts\n * getHashForString(\"hello\") === getHashForString(\"hello\"); // → true\n * ```\n */\nexport function getHashForString(string: string): string {\n\tlet hash = 0;\n\tfor (let i = 0; i < string.length; i++) {\n\t\thash = (hash << 5) - hash + string.charCodeAt(i);\n\t\thash |= 0; // Coerce the accumulator back to a signed 32-bit integer.\n\t}\n\treturn `${hash}`;\n}\n\n/**\n * Hash an arbitrary value by serializing it to JSON and hashing the result.\n *\n * Key ordering follows `JSON.stringify`, so two structurally equal objects with\n * differently ordered keys can hash differently.\n *\n * @param obj - Any JSON-serializable value.\n * @returns The 32-bit hash of the serialized form, as a decimal string.\n * @throws {TypeError} When `obj` cannot be serialized: a circular\n * reference or a `BigInt` makes `JSON.stringify` throw, and a value\n * that stringifies to `undefined` (a bare function, `symbol`, or\n * `undefined`) makes the downstream `.length` access throw.\n */\nexport function getHashForObject(obj: unknown): string {\n\treturn getHashForString(JSON.stringify(obj));\n}\n\n/**\n * Compute a deterministic non-cryptographic 32-bit hash of an `ArrayBuffer`.\n *\n * Applies the same accumulation as {@link getHashForString} over each byte.\n *\n * @param buffer - The bytes to hash.\n * @returns The signed 32-bit hash rendered as a decimal string.\n */\nexport function getHashForBuffer(buffer: ArrayBuffer): string {\n\tconst view = new DataView(buffer);\n\tlet hash = 0;\n\tfor (let i = 0; i < view.byteLength; i++) {\n\t\thash = (hash << 5) - hash + view.getUint8(i);\n\t\thash |= 0; // Coerce the accumulator back to a signed 32-bit integer.\n\t}\n\treturn `${hash}`;\n}\n\n/**\n * Reversibly scramble a string by rotating character blocks and shifting digits.\n *\n * A lightweight, non-cryptographic obfuscation: it reorders characters via a fixed\n * sequence of block rotations, reverses the result, and maps each digit `d` to\n * `d < 5 ? d + 5 : d > 5 ? d - 5 : d`. Provides obscurity, not security — do not\n * use it to protect secrets.\n *\n * Despite \"scramble\", the transform is **not a true inverse**: the digit map sends\n * both `0` and `5` to `5`, so any input containing those digits cannot be\n * recovered unambiguously. Non-digit characters (including whitespace) are only\n * reordered, never substituted. Deterministic and free of side effects.\n *\n * @param str - The string to transform.\n * @returns The transformed string, same length as `str`.\n */\nexport function lns(str: string): string {\n\tconst result = str.split(\"\");\n\tresult.push(...result.splice(0, Math.round(result.length / 5)));\n\tresult.push(...result.splice(0, Math.round(result.length / 4)));\n\tresult.push(...result.splice(0, Math.round(result.length / 3)));\n\tresult.push(...result.splice(0, Math.round(result.length / 2)));\n\treturn result\n\t\t.reverse()\n\t\t.map((n) => {\n\t\t\tconst num = Number(n);\n\t\t\treturn Number.isNaN(num) || n.trim() === \"\"\n\t\t\t\t? n\n\t\t\t\t: num < 5\n\t\t\t\t\t? String(5 + num)\n\t\t\t\t\t: num > 5\n\t\t\t\t\t\t? String(num - 5)\n\t\t\t\t\t\t: n;\n\t\t})\n\t\t.join(\"\");\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAwCA,SAAgB,iBAAiB,QAAwB;CACxD,IAAI,OAAO;CACX,KAAK,IAAI,IAAI,GAAG,IAAI,OAAO,QAAQ,KAAK;EACvC,QAAQ,QAAQ,KAAK,OAAO,OAAO,WAAW,CAAC;EAC/C,QAAQ;CACT;CACA,OAAO,GAAG;AACX;;;;;;;;;;;;;;AAeA,SAAgB,iBAAiB,KAAsB;CACtD,OAAO,iBAAiB,KAAK,UAAU,GAAG,CAAC;AAC5C;;;;;;;;;AAUA,SAAgB,iBAAiB,QAA6B;CAC7D,MAAM,OAAO,IAAI,SAAS,MAAM;CAChC,IAAI,OAAO;CACX,KAAK,IAAI,IAAI,GAAG,IAAI,KAAK,YAAY,KAAK;EACzC,QAAQ,QAAQ,KAAK,OAAO,KAAK,SAAS,CAAC;EAC3C,QAAQ;CACT;CACA,OAAO,GAAG;AACX;;;;;;;;;;;;;;;;;AAkBA,SAAgB,IAAI,KAAqB;CACxC,MAAM,SAAS,IAAI,MAAM,EAAE;CAC3B,OAAO,KAAK,GAAG,OAAO,OAAO,GAAG,KAAK,MAAM,OAAO,SAAS,CAAC,CAAC,CAAC;CAC9D,OAAO,KAAK,GAAG,OAAO,OAAO,GAAG,KAAK,MAAM,OAAO,SAAS,CAAC,CAAC,CAAC;CAC9D,OAAO,KAAK,GAAG,OAAO,OAAO,GAAG,KAAK,MAAM,OAAO,SAAS,CAAC,CAAC,CAAC;CAC9D,OAAO,KAAK,GAAG,OAAO,OAAO,GAAG,KAAK,MAAM,OAAO,SAAS,CAAC,CAAC,CAAC;CAC9D,OAAO,OACL,QAAQ,CAAC,CACT,KAAK,MAAM;EACX,MAAM,MAAM,OAAO,CAAC;EACpB,OAAO,OAAO,MAAM,GAAG,KAAK,EAAE,KAAK,MAAM,KACtC,IACA,MAAM,IACL,OAAO,IAAI,GAAG,IACd,MAAM,IACL,OAAO,MAAM,CAAC,IACd;CACN,CAAC,CAAC,CACD,KAAK,EAAE;AACV"}
package/lib/index.d.mts CHANGED
@@ -1,5 +1,20 @@
1
+ import { AddressClassification, OutboundRejectionReason, OutboundUrlPolicy, OutboundUrlVerdict, assertOutboundUrl, classifyAddress, isPubliclyRoutableAddress } from "./controls/address.mjs";
2
+ import { CsrfFailureReason, CsrfTokenOptions, CsrfVerification, CsrfVerifyOptions, createCsrfToken, verifyCsrfToken } from "./controls/csrf.mjs";
3
+ import { CorsResponsePolicy, OriginPolicyOptions, checkCorsResponsePolicy, isAllowedOrigin, normalizeOrigin } from "./controls/origin.mjs";
4
+ import { RedirectPolicyOptions, RedirectRejectionReason, RedirectVerdict, resolveRedirectTarget } from "./controls/redirect.mjs";
5
+ import { GraphQLRequestAnalysis, GraphQLRequestLimits, QueryComplexity, QueryComplexityLimits, analyzeGraphQLRequest, analyzeQueryComplexity, validateJsonpCallback } from "./controls/query.mjs";
6
+ import { JsonPayloadLimits, JsonPayloadReport, checkJsonPayloadLimits } from "./controls/payload.mjs";
7
+ import { FileTypeName, UploadCandidate, UploadRejectionReason, UploadVerdict, assertUploadType, detectFileSignature } from "./controls/upload.mjs";
1
8
  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
9
  import { getHashForBuffer, getHashForObject, getHashForString, lns } from "./hash.mjs";
3
- 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";
10
+ import { ALL_THREAT_CONTEXTS, CONFIDENCE_MULTIPLIERS, DEFAULT_THREAT_POLICY, EventContext, InputSource, InputVariant, InputVariantKind, SEVERITY_WEIGHTS, ThreatConfidence, ThreatContext, ThreatFinding, ThreatPolicy, ThreatRule, ThreatSeverity, ThreatType, ThreatVerdict } from "./threats/types.mjs";
11
+ import { THREAT_DETECTED_MESSAGE, ThreatDetectionConfig, ThreatDetectionResult, ThreatSummary, containsCommandInjection, containsHomoglyphs, containsNoSQLInjection, containsPathTraversal, containsPrototypePollution, containsSQLInjection, containsXSSPatterns, detectThreatPatterns, encodeJsonForScript, encodeLogValue, escapeCsvField, escapeHtmlAttribute, escapeHtmlText, getThreatErrorMessage, isSafeInput, normalizeUnicode, sanitizeForDisplay, toCsvRow, validatePersonName, validateSafeEmail, validateSafeName, validateSafeText } from "./validators.mjs";
12
+ import { MAX_SCAN_LENGTH, ThreatScanOptions, ThreatScanResult, scanForThreats } from "./threats/engine.mjs";
13
+ import { THREAT_RULES, assertRuleCatalogIsValid, getRulesForContexts } from "./threats/rules/index.mjs";
14
+ import { ThreatTypeSummary, calculateThreatScore, scoreForFinding, summarizeByType, verdictForScore } from "./threats/scoring.mjs";
15
+ import { buildInputVariants, decodeHtmlEntities, tryPercentDecode } from "./threats/variants.mjs";
16
+ import { ATTACK_PATTERNS, AttackPattern, attackPatternsForCwe } from "./threats/capec.generated.mjs";
17
+ import { areConfusable, foldConfusables, getSkeleton } from "./unicode/confusables.mjs";
18
+ import { IdentifierRestrictionLevel, IdentifierSecurityResult, UnicodeScript, analyzeIdentifier, containsBidiControls, containsInvisibleCharacters, getRestrictionLevel, getScripts, isSafeIdentifier, stripInvisibleCharacters } from "./unicode/index.mjs";
4
19
  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";
5
- 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, getHashForBuffer, getHashForObject, getHashForString, getThreatErrorMessage, hashData, isCiphertext, isEncryptionKey, isSafeInput, isValidEmail, isValidPhone, isValidSSN, isValidUrl, lns, maskEmail, maskPII, normalizeUnicode, parseJsonWithSchema, redactPII, redactPIIEffect, safeStringify, sanitizeForDisplay, sanitizeForLogging, sanitizeHtml, sanitizeJson, sanitizeUrl, sanitizeUrlEffect, stripAnsi, toCiphertext, toEncryptionKey, unsafeCiphertext, unsafeEncryptionKey, validateSafeEmail, validateSafeName, validateSafeText, validateUserInput, validateUserInputEffect };
20
+ export { ALL_THREAT_CONTEXTS, ATTACK_PATTERNS, type AddressClassification, type AttackPattern, CONFIDENCE_MULTIPLIERS, type Ciphertext, type CorsResponsePolicy, type CreditCard, CreditCardSchema, type CsrfFailureReason, type CsrfTokenOptions, type CsrfVerification, type CsrfVerifyOptions, DEFAULT_THREAT_POLICY, type Email, EmailSchema, type EncryptionKey, type EventContext, type FileTypeName, type GraphQLRequestAnalysis, type GraphQLRequestLimits, type IPv4, IPv4Schema, type IdentifierRestrictionLevel, type IdentifierSecurityResult, type InputSource, type InputVariant, type InputVariantKind, type JsonPayloadLimits, type JsonPayloadReport, MAX_SCAN_LENGTH, type Masked, type OriginPolicyOptions, type OutboundRejectionReason, type OutboundUrlPolicy, type OutboundUrlVerdict, type PIIRedactionOptions, PIIRedactionOptionsSchema, type PhoneNumber, PhoneNumberSchema, type QueryComplexity, type QueryComplexityLimits, type RedirectPolicyOptions, type RedirectRejectionReason, type RedirectVerdict, SEVERITY_WEIGHTS, type SSN, SSNSchema, type SafeUrl, SafeUrlSchema, type SanitizedString, SanitizedStringSchema, type SecureToken, type Sha256Hex, THREAT_DETECTED_MESSAGE, THREAT_RULES, type ThreatConfidence, type ThreatContext, type ThreatDetectionConfig, type ThreatDetectionResult, type ThreatFinding, type ThreatPolicy, type ThreatRule, type ThreatScanOptions, type ThreatScanResult, type ThreatSeverity, type ThreatSummary, type ThreatType, type ThreatTypeSummary, type ThreatVerdict, type UnicodeScript, type UploadCandidate, type UploadRejectionReason, type UploadVerdict, type UrlProtocol, UrlProtocolSchema, type UserInputOptions, UserInputOptionsSchema, analyzeGraphQLRequest, analyzeIdentifier, analyzeQueryComplexity, areConfusable, assertOutboundUrl, assertRuleCatalogIsValid, assertUploadType, attackPatternsForCwe, buildInputVariants, calculateThreatScore, checkCorsResponsePolicy, checkJsonPayloadLimits, classifyAddress, coerceCiphertext, coerceEncryptionKey, containsBidiControls, containsCommandInjection, containsHomoglyphs, containsInvisibleCharacters, containsNoSQLInjection, containsPathTraversal, containsPrototypePollution, containsSQLInjection, containsXSSPatterns, createCsrfToken, decodeHtmlEntities, decryptData, detectFileSignature, detectThreatPatterns, encodeJsonForScript, encodeLogValue, encryptData, escapeCsvField, escapeHtml, escapeHtmlAttribute, escapeHtmlText, foldConfusables, generateSecureToken, getHashForBuffer, getHashForObject, getHashForString, getRestrictionLevel, getRulesForContexts, getScripts, getSkeleton, getThreatErrorMessage, hashData, isAllowedOrigin, isCiphertext, isEncryptionKey, isPubliclyRoutableAddress, isSafeIdentifier, isSafeInput, isValidEmail, isValidPhone, isValidSSN, isValidUrl, lns, maskEmail, maskPII, normalizeOrigin, normalizeUnicode, parseJsonWithSchema, redactPII, redactPIIEffect, resolveRedirectTarget, safeStringify, sanitizeForDisplay, sanitizeForLogging, sanitizeHtml, sanitizeJson, sanitizeUrl, sanitizeUrlEffect, scanForThreats, scoreForFinding, stripAnsi, stripInvisibleCharacters, summarizeByType, toCiphertext, toCsvRow, toEncryptionKey, tryPercentDecode, unsafeCiphertext, unsafeEncryptionKey, validateJsonpCallback, validatePersonName, validateSafeEmail, validateSafeName, validateSafeText, validateUserInput, validateUserInputEffect, verdictForScore, verifyCsrfToken };
package/lib/index.mjs CHANGED
@@ -1,5 +1,22 @@
1
1
  import { coerceCiphertext, coerceEncryptionKey, decryptData, encryptData, generateSecureToken, hashData, isCiphertext, isEncryptionKey, maskEmail, maskPII, sanitizeForLogging, toCiphertext, toEncryptionKey, unsafeCiphertext, unsafeEncryptionKey } from "./crypto.mjs";
2
2
  import { getHashForBuffer, getHashForObject, getHashForString, lns } from "./hash.mjs";
3
- import { THREAT_DETECTED_MESSAGE, containsCommandInjection, containsHomoglyphs, containsNoSQLInjection, containsPathTraversal, containsSQLInjection, containsXSSPatterns, detectThreatPatterns, getThreatErrorMessage, isSafeInput, normalizeUnicode, sanitizeForDisplay, validateSafeEmail, validateSafeName, validateSafeText } from "./validators.mjs";
3
+ import { ALL_THREAT_CONTEXTS, CONFIDENCE_MULTIPLIERS, DEFAULT_THREAT_POLICY, SEVERITY_WEIGHTS } from "./threats/types.mjs";
4
+ import { THREAT_RULES, assertRuleCatalogIsValid, getRulesForContexts } from "./threats/rules/index.mjs";
5
+ import { calculateThreatScore, scoreForFinding, summarizeByType, verdictForScore } from "./threats/scoring.mjs";
6
+ import { buildInputVariants, decodeHtmlEntities, tryPercentDecode } from "./threats/variants.mjs";
7
+ import { MAX_SCAN_LENGTH, scanForThreats } from "./threats/engine.mjs";
8
+ import { areConfusable, foldConfusables, getSkeleton } from "./unicode/confusables.mjs";
9
+ import { analyzeIdentifier, containsBidiControls, containsInvisibleCharacters, getRestrictionLevel, getScripts, isSafeIdentifier, stripInvisibleCharacters } from "./unicode/index.mjs";
10
+ import { THREAT_DETECTED_MESSAGE, containsCommandInjection, containsHomoglyphs, containsNoSQLInjection, containsPathTraversal, containsPrototypePollution, containsSQLInjection, containsXSSPatterns, detectThreatPatterns, encodeJsonForScript, encodeLogValue, escapeCsvField, escapeHtmlAttribute, escapeHtmlText, getThreatErrorMessage, isSafeInput, normalizeUnicode, sanitizeForDisplay, toCsvRow, validatePersonName, validateSafeEmail, validateSafeName, validateSafeText } from "./validators.mjs";
11
+ import { ATTACK_PATTERNS, attackPatternsForCwe } from "./threats/capec.generated.mjs";
12
+ import "./threats/index.mjs";
13
+ import { assertOutboundUrl, classifyAddress, isPubliclyRoutableAddress } from "./controls/address.mjs";
14
+ import { createCsrfToken, verifyCsrfToken } from "./controls/csrf.mjs";
15
+ import { checkCorsResponsePolicy, isAllowedOrigin, normalizeOrigin } from "./controls/origin.mjs";
16
+ import { resolveRedirectTarget } from "./controls/redirect.mjs";
17
+ import { analyzeGraphQLRequest, analyzeQueryComplexity, validateJsonpCallback } from "./controls/query.mjs";
18
+ import { checkJsonPayloadLimits } from "./controls/payload.mjs";
19
+ import { assertUploadType, detectFileSignature } from "./controls/upload.mjs";
20
+ import "./controls/index.mjs";
4
21
  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";
5
- 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, getHashForBuffer, getHashForObject, getHashForString, getThreatErrorMessage, hashData, isCiphertext, isEncryptionKey, isSafeInput, isValidEmail, isValidPhone, isValidSSN, isValidUrl, lns, maskEmail, maskPII, normalizeUnicode, parseJsonWithSchema, redactPII, redactPIIEffect, safeStringify, sanitizeForDisplay, sanitizeForLogging, sanitizeHtml, sanitizeJson, sanitizeUrl, sanitizeUrlEffect, stripAnsi, toCiphertext, toEncryptionKey, unsafeCiphertext, unsafeEncryptionKey, validateSafeEmail, validateSafeName, validateSafeText, validateUserInput, validateUserInputEffect };
22
+ export { ALL_THREAT_CONTEXTS, ATTACK_PATTERNS, CONFIDENCE_MULTIPLIERS, CreditCardSchema, DEFAULT_THREAT_POLICY, EmailSchema, IPv4Schema, MAX_SCAN_LENGTH, PIIRedactionOptionsSchema, PhoneNumberSchema, SEVERITY_WEIGHTS, SSNSchema, SafeUrlSchema, SanitizedStringSchema, THREAT_DETECTED_MESSAGE, THREAT_RULES, UrlProtocolSchema, UserInputOptionsSchema, analyzeGraphQLRequest, analyzeIdentifier, analyzeQueryComplexity, areConfusable, assertOutboundUrl, assertRuleCatalogIsValid, assertUploadType, attackPatternsForCwe, buildInputVariants, calculateThreatScore, checkCorsResponsePolicy, checkJsonPayloadLimits, classifyAddress, coerceCiphertext, coerceEncryptionKey, containsBidiControls, containsCommandInjection, containsHomoglyphs, containsInvisibleCharacters, containsNoSQLInjection, containsPathTraversal, containsPrototypePollution, containsSQLInjection, containsXSSPatterns, createCsrfToken, decodeHtmlEntities, decryptData, detectFileSignature, detectThreatPatterns, encodeJsonForScript, encodeLogValue, encryptData, escapeCsvField, escapeHtml, escapeHtmlAttribute, escapeHtmlText, foldConfusables, generateSecureToken, getHashForBuffer, getHashForObject, getHashForString, getRestrictionLevel, getRulesForContexts, getScripts, getSkeleton, getThreatErrorMessage, hashData, isAllowedOrigin, isCiphertext, isEncryptionKey, isPubliclyRoutableAddress, isSafeIdentifier, isSafeInput, isValidEmail, isValidPhone, isValidSSN, isValidUrl, lns, maskEmail, maskPII, normalizeOrigin, normalizeUnicode, parseJsonWithSchema, redactPII, redactPIIEffect, resolveRedirectTarget, safeStringify, sanitizeForDisplay, sanitizeForLogging, sanitizeHtml, sanitizeJson, sanitizeUrl, sanitizeUrlEffect, scanForThreats, scoreForFinding, stripAnsi, stripInvisibleCharacters, summarizeByType, toCiphertext, toCsvRow, toEncryptionKey, tryPercentDecode, unsafeCiphertext, unsafeEncryptionKey, validateJsonpCallback, validatePersonName, validateSafeEmail, validateSafeName, validateSafeText, validateUserInput, validateUserInputEffect, verdictForScore, verifyCsrfToken };
@@ -0,0 +1,92 @@
1
+ //#region src/paths.d.ts
2
+ /**
3
+ * Copyright 2026 ResQ Systems, Inc.
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /** Options for {@link resolveContainedPath}. */
18
+ interface ContainmentOptions {
19
+ /**
20
+ * Treat the base directory itself as an acceptable result. Defaults to `true`.
21
+ * Pass `false` when the caller requires a path strictly *inside* the base — an
22
+ * upload target, say, rather than a listing root.
23
+ */
24
+ readonly allowBaseItself?: boolean;
25
+ }
26
+ /**
27
+ * Resolve an untrusted path against a base directory and verify containment.
28
+ *
29
+ * The candidate is resolved against the base — which also normalizes `.`, `..`, and
30
+ * duplicate separators — and accepted only if the result is the base or sits beneath
31
+ * it. An absolute `untrustedPath` overrides the base under `path.resolve` semantics,
32
+ * so it too is caught by the containment check rather than silently trusted.
33
+ *
34
+ * **This function performs no filesystem I/O**, so it cannot see symlinks. A path that
35
+ * is textually contained may still resolve on disk to a target outside the base. When
36
+ * the target may exist and may be a link, follow up with `fs.promises.realpath` on
37
+ * both the base and the result and re-run {@link isPathContained} on those.
38
+ *
39
+ * @param baseDirectory - Directory the result must stay within. Resolved against the
40
+ * process working directory when relative.
41
+ * @param untrustedPath - Candidate path from an untrusted source.
42
+ * @param options - See {@link ContainmentOptions}.
43
+ * @returns The resolved absolute path when contained, otherwise `null`. Also `null`
44
+ * for non-string arguments, an empty base, or a NUL byte in either argument.
45
+ *
46
+ * @example
47
+ * ```ts
48
+ * const base = "/srv/uploads";
49
+ *
50
+ * resolveContainedPath(base, "avatar.png"); // "/srv/uploads/avatar.png"
51
+ * resolveContainedPath(base, "nested/a.txt"); // "/srv/uploads/nested/a.txt"
52
+ * resolveContainedPath(base, "../../etc/passwd"); // null
53
+ * resolveContainedPath(base, "/etc/passwd"); // null
54
+ * ```
55
+ */
56
+ declare function resolveContainedPath(baseDirectory: string, untrustedPath: string, options?: ContainmentOptions): string | null;
57
+ /**
58
+ * Boolean form of {@link resolveContainedPath}.
59
+ *
60
+ * @param baseDirectory - Directory the candidate must stay within.
61
+ * @param candidatePath - Path to test.
62
+ * @param options - See {@link ContainmentOptions}.
63
+ * @returns `true` when the candidate resolves inside the base.
64
+ */
65
+ declare function isPathContained(baseDirectory: string, candidatePath: string, options?: ContainmentOptions): boolean;
66
+ /**
67
+ * Reduce an untrusted string to a single safe path segment.
68
+ *
69
+ * Strips directory separators, control characters, and Windows-reserved characters;
70
+ * removes leading dots so the result cannot become `.`/`..` or a hidden file; trims
71
+ * the trailing dots and spaces Windows silently drops (which is how `evil.php.`
72
+ * becomes `evil.php` after the fact); and refuses reserved device names.
73
+ *
74
+ * The output is a *segment*, never a path. Join it onto a base directory yourself and
75
+ * confirm the result with {@link resolveContainedPath} — that remains the containment
76
+ * control; this is only hygiene.
77
+ *
78
+ * @param filename - Untrusted filename, typically from a multipart upload.
79
+ * @param fallback - Returned when nothing usable survives. Defaults to `"file"`.
80
+ * @returns A safe single path segment.
81
+ *
82
+ * @example
83
+ * ```ts
84
+ * sanitizeFilename("../../etc/passwd"); // "etcpasswd"
85
+ * sanitizeFilename("report.pdf."); // "report.pdf"
86
+ * sanitizeFilename("CON.txt"); // "file"
87
+ * ```
88
+ */
89
+ declare function sanitizeFilename(filename: string, fallback?: string): string;
90
+ //#endregion
91
+ export { ContainmentOptions, isPathContained, resolveContainedPath, sanitizeFilename };
92
+ //# sourceMappingURL=paths.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paths.d.mts","names":[],"sources":["../src/paths.ts"],"mappings":";;;;;;;;;;;;;;;;;UAsCiB;;;;;;WAMP;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAoCM,qBACf,uBACA,uBACA,UAAS;;;;;;;;;iBAuCM,gBACf,uBACA,uBACA,UAAU;;;;;;;;;;;;;;;;;;;;;;;;iBAmDK,iBAAiB,kBAAkB"}