@resq-systems/security 2.0.0 → 2.1.1

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 (90) hide show
  1. package/README.md +1 -1
  2. package/lib/controls/address.d.mts +8 -8
  3. package/lib/controls/address.d.mts.map +1 -1
  4. package/lib/controls/address.mjs.map +1 -1
  5. package/lib/controls/csrf.d.mts +7 -7
  6. package/lib/controls/csrf.d.mts.map +1 -1
  7. package/lib/controls/csrf.mjs +5 -2
  8. package/lib/controls/csrf.mjs.map +1 -1
  9. package/lib/controls/origin.d.mts +6 -6
  10. package/lib/controls/origin.d.mts.map +1 -1
  11. package/lib/controls/origin.mjs +1 -0
  12. package/lib/controls/origin.mjs.map +1 -1
  13. package/lib/controls/payload.d.mts +4 -4
  14. package/lib/controls/payload.d.mts.map +1 -1
  15. package/lib/controls/payload.mjs.map +1 -1
  16. package/lib/controls/query.d.mts +22 -10
  17. package/lib/controls/query.d.mts.map +1 -1
  18. package/lib/controls/query.mjs +42 -23
  19. package/lib/controls/query.mjs.map +1 -1
  20. package/lib/controls/redirect.d.mts +5 -5
  21. package/lib/controls/redirect.d.mts.map +1 -1
  22. package/lib/controls/redirect.mjs.map +1 -1
  23. package/lib/controls/upload.d.mts +7 -7
  24. package/lib/controls/upload.d.mts.map +1 -1
  25. package/lib/controls/upload.mjs.map +1 -1
  26. package/lib/crypto.d.mts +20 -21
  27. package/lib/crypto.d.mts.map +1 -1
  28. package/lib/crypto.mjs +3 -1
  29. package/lib/crypto.mjs.map +1 -1
  30. package/lib/hash.d.mts +5 -5
  31. package/lib/hash.d.mts.map +1 -1
  32. package/lib/hash.mjs +1 -0
  33. package/lib/hash.mjs.map +1 -1
  34. package/lib/paths.d.mts +5 -5
  35. package/lib/paths.d.mts.map +1 -1
  36. package/lib/paths.mjs +1 -0
  37. package/lib/paths.mjs.map +1 -1
  38. package/lib/sanitize.d.mts +36 -37
  39. package/lib/sanitize.d.mts.map +1 -1
  40. package/lib/sanitize.mjs.map +1 -1
  41. package/lib/threats/capec.generated.d.mts +4 -4
  42. package/lib/threats/capec.generated.d.mts.map +1 -1
  43. package/lib/threats/capec.generated.mjs.map +1 -1
  44. package/lib/threats/engine.d.mts +4 -5
  45. package/lib/threats/engine.d.mts.map +1 -1
  46. package/lib/threats/engine.mjs +1 -0
  47. package/lib/threats/engine.mjs.map +1 -1
  48. package/lib/threats/rules/datastore.d.mts +4 -5
  49. package/lib/threats/rules/datastore.d.mts.map +1 -1
  50. package/lib/threats/rules/datastore.mjs.map +1 -1
  51. package/lib/threats/rules/index.d.mts +5 -5
  52. package/lib/threats/rules/index.d.mts.map +1 -1
  53. package/lib/threats/rules/index.mjs.map +1 -1
  54. package/lib/threats/rules/markup.d.mts +4 -5
  55. package/lib/threats/rules/markup.d.mts.map +1 -1
  56. package/lib/threats/rules/markup.mjs +1 -0
  57. package/lib/threats/rules/markup.mjs.map +1 -1
  58. package/lib/threats/rules/protocol.d.mts +4 -5
  59. package/lib/threats/rules/protocol.d.mts.map +1 -1
  60. package/lib/threats/rules/protocol.mjs.map +1 -1
  61. package/lib/threats/rules/system.d.mts +4 -5
  62. package/lib/threats/rules/system.d.mts.map +1 -1
  63. package/lib/threats/rules/system.mjs.map +1 -1
  64. package/lib/threats/rules/web.d.mts +5 -6
  65. package/lib/threats/rules/web.d.mts.map +1 -1
  66. package/lib/threats/rules/web.mjs +1 -1
  67. package/lib/threats/rules/web.mjs.map +1 -1
  68. package/lib/threats/scoring.d.mts +5 -6
  69. package/lib/threats/scoring.d.mts.map +1 -1
  70. package/lib/threats/scoring.mjs +1 -0
  71. package/lib/threats/scoring.mjs.map +1 -1
  72. package/lib/threats/types.d.mts +18 -18
  73. package/lib/threats/types.d.mts.map +1 -1
  74. package/lib/threats/types.mjs.map +1 -1
  75. package/lib/threats/variants.d.mts +3 -4
  76. package/lib/threats/variants.d.mts.map +1 -1
  77. package/lib/threats/variants.mjs.map +1 -1
  78. package/lib/unicode/confusables.d.mts +5 -5
  79. package/lib/unicode/confusables.d.mts.map +1 -1
  80. package/lib/unicode/confusables.mjs +1 -0
  81. package/lib/unicode/confusables.mjs.map +1 -1
  82. package/lib/unicode/index.d.mts +11 -11
  83. package/lib/unicode/index.d.mts.map +1 -1
  84. package/lib/unicode/index.mjs +1 -0
  85. package/lib/unicode/index.mjs.map +1 -1
  86. package/lib/validators.d.mts +89 -39
  87. package/lib/validators.d.mts.map +1 -1
  88. package/lib/validators.mjs +285 -21
  89. package/lib/validators.mjs.map +1 -1
  90. package/package.json +7 -7
@@ -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 * @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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"}
1
+ {"version":3,"file":"crypto.mjs","names":[],"sources":["../src/crypto.ts"],"sourcesContent":["/**\n * Copyright 2026 ResQ Systems, Inc.\n * SPDX-License-Identifier: Apache-2.0\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
@@ -1,6 +1,7 @@
1
1
  //#region src/hash.d.ts
2
2
  /**
3
3
  * Copyright 2026 ResQ Systems, Inc.
4
+ * SPDX-License-Identifier: Apache-2.0
4
5
  *
5
6
  * Licensed under the Apache License, Version 2.0 (the "License");
6
7
  * you may not use this file except in compliance with the License.
@@ -37,7 +38,7 @@
37
38
  * getHashForString("hello") === getHashForString("hello"); // → true
38
39
  * ```
39
40
  */
40
- declare function getHashForString(string: string): string;
41
+ export declare function getHashForString(string: string): string;
41
42
  /**
42
43
  * Hash an arbitrary value by serializing it to JSON and hashing the result.
43
44
  *
@@ -51,7 +52,7 @@ declare function getHashForString(string: string): string;
51
52
  * that stringifies to `undefined` (a bare function, `symbol`, or
52
53
  * `undefined`) makes the downstream `.length` access throw.
53
54
  */
54
- declare function getHashForObject(obj: unknown): string;
55
+ export declare function getHashForObject(obj: unknown): string;
55
56
  /**
56
57
  * Compute a deterministic non-cryptographic 32-bit hash of an `ArrayBuffer`.
57
58
  *
@@ -60,7 +61,7 @@ declare function getHashForObject(obj: unknown): string;
60
61
  * @param buffer - The bytes to hash.
61
62
  * @returns The signed 32-bit hash rendered as a decimal string.
62
63
  */
63
- declare function getHashForBuffer(buffer: ArrayBuffer): string;
64
+ export declare function getHashForBuffer(buffer: ArrayBuffer): string;
64
65
  /**
65
66
  * Reversibly scramble a string by rotating character blocks and shifting digits.
66
67
  *
@@ -77,7 +78,6 @@ declare function getHashForBuffer(buffer: ArrayBuffer): string;
77
78
  * @param str - The string to transform.
78
79
  * @returns The transformed string, same length as `str`.
79
80
  */
80
- declare function lns(str: string): string;
81
+ export declare function lns(str: string): string;
81
82
  //#endregion
82
- export { getHashForBuffer, getHashForObject, getHashForString, lns };
83
83
  //# sourceMappingURL=hash.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"hash.d.mts","names":[],"sources":["../src/hash.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAwCgB,iBAAiB;;;;;;;;;;;;;;iBAsBjB,iBAAiB;;;;;;;;;iBAYjB,iBAAiB,QAAQ;;;;;;;;;;;;;;;;;iBA0BzB,IAAI"}
1
+ {"version":3,"file":"hash.d.mts","names":[],"sources":["../src/hash.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;wBAyCgB,iBAAiB;;;;;;;;;;;;;;wBAsBjB,iBAAiB;;;;;;;;;wBAYjB,iBAAiB,QAAQ;;;;;;;;;;;;;;;;;wBA0BzB,IAAI"}
package/lib/hash.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  //#region src/hash.ts
2
2
  /**
3
3
  * Copyright 2026 ResQ Systems, Inc.
4
+ * SPDX-License-Identifier: Apache-2.0
4
5
  *
5
6
  * Licensed under the Apache License, Version 2.0 (the "License");
6
7
  * you may not use this file except in compliance with the License.
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 * @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"}
1
+ {"version":3,"file":"hash.mjs","names":[],"sources":["../src/hash.ts"],"sourcesContent":["/**\n * Copyright 2026 ResQ Systems, Inc.\n * SPDX-License-Identifier: Apache-2.0\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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAyCA,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/paths.d.mts CHANGED
@@ -1,6 +1,7 @@
1
1
  //#region src/paths.d.ts
2
2
  /**
3
3
  * Copyright 2026 ResQ Systems, Inc.
4
+ * SPDX-License-Identifier: Apache-2.0
4
5
  *
5
6
  * Licensed under the Apache License, Version 2.0 (the "License");
6
7
  * you may not use this file except in compliance with the License.
@@ -15,7 +16,7 @@
15
16
  * limitations under the License.
16
17
  */
17
18
  /** Options for {@link resolveContainedPath}. */
18
- interface ContainmentOptions {
19
+ export interface ContainmentOptions {
19
20
  /**
20
21
  * Treat the base directory itself as an acceptable result. Defaults to `true`.
21
22
  * Pass `false` when the caller requires a path strictly *inside* the base — an
@@ -53,7 +54,7 @@ interface ContainmentOptions {
53
54
  * resolveContainedPath(base, "/etc/passwd"); // null
54
55
  * ```
55
56
  */
56
- declare function resolveContainedPath(baseDirectory: string, untrustedPath: string, options?: ContainmentOptions): string | null;
57
+ export declare function resolveContainedPath(baseDirectory: string, untrustedPath: string, options?: ContainmentOptions): string | null;
57
58
  /**
58
59
  * Boolean form of {@link resolveContainedPath}.
59
60
  *
@@ -62,7 +63,7 @@ declare function resolveContainedPath(baseDirectory: string, untrustedPath: stri
62
63
  * @param options - See {@link ContainmentOptions}.
63
64
  * @returns `true` when the candidate resolves inside the base.
64
65
  */
65
- declare function isPathContained(baseDirectory: string, candidatePath: string, options?: ContainmentOptions): boolean;
66
+ export declare function isPathContained(baseDirectory: string, candidatePath: string, options?: ContainmentOptions): boolean;
66
67
  /**
67
68
  * Reduce an untrusted string to a single safe path segment.
68
69
  *
@@ -86,7 +87,6 @@ declare function isPathContained(baseDirectory: string, candidatePath: string, o
86
87
  * sanitizeFilename("CON.txt"); // "file"
87
88
  * ```
88
89
  */
89
- declare function sanitizeFilename(filename: string, fallback?: string): string;
90
+ export declare function sanitizeFilename(filename: string, fallback?: string): string;
90
91
  //#endregion
91
- export { ContainmentOptions, isPathContained, resolveContainedPath, sanitizeFilename };
92
92
  //# sourceMappingURL=paths.d.mts.map
@@ -1 +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"}
1
+ {"version":3,"file":"paths.d.mts","names":[],"sources":["../src/paths.ts"],"mappings":";;;;;;;;;;;;;;;;;;iBAuCiB;;;;;;WAMP;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;wBAoCM,qBACf,uBACA,uBACA,UAAS;;;;;;;;;wBAuCM,gBACf,uBACA,uBACA,UAAU;;;;;;;;;;;;;;;;;;;;;;;;wBAmDK,iBAAiB,kBAAkB"}
package/lib/paths.mjs CHANGED
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  //#region src/paths.ts
3
3
  /**
4
4
  * Copyright 2026 ResQ Systems, Inc.
5
+ * SPDX-License-Identifier: Apache-2.0
5
6
  *
6
7
  * Licensed under the Apache License, Version 2.0 (the "License");
7
8
  * you may not use this file except in compliance with the License.
package/lib/paths.mjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"paths.mjs","names":[],"sources":["../src/paths.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 Path containment — the *prevention* half of CWE-22, as distinct from\n * the detection rules in `@resq-systems/security/threats`.\n *\n * Detecting `../` is not the control. CWE-22 describes the failure to keep a\n * constructed path inside its restricted directory, so the control is to resolve the\n * candidate against the base and verify the result still lives beneath it. That check\n * holds for inputs no signature catches: an absolute path, a Windows drive-relative\n * path, an alternate separator, or a name whose `..` only appears after normalization.\n *\n * **Node-only.** This module statically imports `node:path` and is therefore published\n * exclusively under the `@resq-systems/security/paths` subpath, never through the\n * package root, so browser bundles of the root barrel stay free of Node builtins.\n *\n * @module @resq-systems/security/paths\n */\n\nimport path from \"node:path\";\n\n//#region Containment\n\n/** Options for {@link resolveContainedPath}. */\nexport interface ContainmentOptions {\n\t/**\n\t * Treat the base directory itself as an acceptable result. Defaults to `true`.\n\t * Pass `false` when the caller requires a path strictly *inside* the base — an\n\t * upload target, say, rather than a listing root.\n\t */\n\treadonly allowBaseItself?: boolean;\n}\n\n/** NUL truncates paths in some syscall layers, so it is rejected before resolution. */\nconst NUL = \"\\u0000\";\n\n/**\n * Resolve an untrusted path against a base directory and verify containment.\n *\n * The candidate is resolved against the base — which also normalizes `.`, `..`, and\n * duplicate separators — and accepted only if the result is the base or sits beneath\n * it. An absolute `untrustedPath` overrides the base under `path.resolve` semantics,\n * so it too is caught by the containment check rather than silently trusted.\n *\n * **This function performs no filesystem I/O**, so it cannot see symlinks. A path that\n * is textually contained may still resolve on disk to a target outside the base. When\n * the target may exist and may be a link, follow up with `fs.promises.realpath` on\n * both the base and the result and re-run {@link isPathContained} on those.\n *\n * @param baseDirectory - Directory the result must stay within. Resolved against the\n * process working directory when relative.\n * @param untrustedPath - Candidate path from an untrusted source.\n * @param options - See {@link ContainmentOptions}.\n * @returns The resolved absolute path when contained, otherwise `null`. Also `null`\n * for non-string arguments, an empty base, or a NUL byte in either argument.\n *\n * @example\n * ```ts\n * const base = \"/srv/uploads\";\n *\n * resolveContainedPath(base, \"avatar.png\"); // \"/srv/uploads/avatar.png\"\n * resolveContainedPath(base, \"nested/a.txt\"); // \"/srv/uploads/nested/a.txt\"\n * resolveContainedPath(base, \"../../etc/passwd\"); // null\n * resolveContainedPath(base, \"/etc/passwd\"); // null\n * ```\n */\nexport function resolveContainedPath(\n\tbaseDirectory: string,\n\tuntrustedPath: string,\n\toptions: ContainmentOptions = {},\n): string | null {\n\tif (typeof baseDirectory !== \"string\" || typeof untrustedPath !== \"string\") {\n\t\treturn null;\n\t}\n\tif (baseDirectory.length === 0) return null;\n\n\t// `a.txt\\0.png` can pass an extension check and then open `a.txt`.\n\tif (untrustedPath.includes(NUL) || baseDirectory.includes(NUL)) {\n\t\treturn null;\n\t}\n\n\tconst { allowBaseItself = true } = options;\n\n\tconst base = path.resolve(baseDirectory);\n\tconst candidate = path.resolve(base, untrustedPath);\n\tconst relative = path.relative(base, candidate);\n\n\tif (relative === \"\") {\n\t\treturn allowBaseItself ? candidate : null;\n\t}\n\n\t// `path.relative` yields a `..`-prefixed path when the candidate escapes, and an\n\t// absolute path when the two sit on different Windows drives.\n\tif (relative === \"..\" || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {\n\t\treturn null;\n\t}\n\n\treturn candidate;\n}\n\n/**\n * Boolean form of {@link resolveContainedPath}.\n *\n * @param baseDirectory - Directory the candidate must stay within.\n * @param candidatePath - Path to test.\n * @param options - See {@link ContainmentOptions}.\n * @returns `true` when the candidate resolves inside the base.\n */\nexport function isPathContained(\n\tbaseDirectory: string,\n\tcandidatePath: string,\n\toptions?: ContainmentOptions,\n): boolean {\n\treturn resolveContainedPath(baseDirectory, candidatePath, options) !== null;\n}\n\n//#endregion\n\n//#region Filename sanitization\n\n/**\n * Characters invalid in a Windows path segment, plus both separators and the C0\n * control range.\n */\n// biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are invalid in filenames and must be stripped\nconst UNSAFE_FILENAME_CHARS = /[<>:\"/\\\\|?*\\u0000-\\u001f\\u007f]/g;\n\n/**\n * Windows device names, reserved regardless of extension — opening `CON.txt` still\n * targets the console device.\n */\nconst RESERVED_WINDOWS_NAMES = /^(?:CON|PRN|AUX|NUL|COM\\d|LPT\\d)(?:\\.|$)/i;\n\n/** Longest filename accepted on common filesystems. */\nconst MAX_FILENAME_LENGTH = 255;\n\n/** Longest extension preserved when truncating an over-long filename. */\nconst MAX_PRESERVED_EXTENSION = 16;\n\n/**\n * Reduce an untrusted string to a single safe path segment.\n *\n * Strips directory separators, control characters, and Windows-reserved characters;\n * removes leading dots so the result cannot become `.`/`..` or a hidden file; trims\n * the trailing dots and spaces Windows silently drops (which is how `evil.php.`\n * becomes `evil.php` after the fact); and refuses reserved device names.\n *\n * The output is a *segment*, never a path. Join it onto a base directory yourself and\n * confirm the result with {@link resolveContainedPath} — that remains the containment\n * control; this is only hygiene.\n *\n * @param filename - Untrusted filename, typically from a multipart upload.\n * @param fallback - Returned when nothing usable survives. Defaults to `\"file\"`.\n * @returns A safe single path segment.\n *\n * @example\n * ```ts\n * sanitizeFilename(\"../../etc/passwd\"); // \"etcpasswd\"\n * sanitizeFilename(\"report.pdf.\"); // \"report.pdf\"\n * sanitizeFilename(\"CON.txt\"); // \"file\"\n * ```\n */\nexport function sanitizeFilename(filename: string, fallback = \"file\"): string {\n\tif (typeof filename !== \"string\" || filename.length === 0) return fallback;\n\n\tlet safe = filename\n\t\t.normalize(\"NFC\")\n\t\t.replace(UNSAFE_FILENAME_CHARS, \"\")\n\t\t.replace(/^\\.+/, \"\")\n\t\t.replace(/[. ]+$/, \"\")\n\t\t.trim();\n\n\tif (safe.length === 0) return fallback;\n\tif (RESERVED_WINDOWS_NAMES.test(safe)) return fallback;\n\n\tif (safe.length > MAX_FILENAME_LENGTH) {\n\t\tconst extension = path.extname(safe).slice(0, MAX_PRESERVED_EXTENSION);\n\t\tsafe = safe.slice(0, MAX_FILENAME_LENGTH - extension.length) + extension;\n\t}\n\n\treturn safe;\n}\n\n//#endregion\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgDA,MAAM,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgCZ,SAAgB,qBACf,eACA,eACA,UAA8B,CAAC,GACf;CAChB,IAAI,OAAO,kBAAkB,YAAY,OAAO,kBAAkB,UACjE,OAAO;CAER,IAAI,cAAc,WAAW,GAAG,OAAO;CAGvC,IAAI,cAAc,SAAS,GAAG,KAAK,cAAc,SAAS,GAAG,GAC5D,OAAO;CAGR,MAAM,EAAE,kBAAkB,SAAS;CAEnC,MAAM,OAAO,KAAK,QAAQ,aAAa;CACvC,MAAM,YAAY,KAAK,QAAQ,MAAM,aAAa;CAClD,MAAM,WAAW,KAAK,SAAS,MAAM,SAAS;CAE9C,IAAI,aAAa,IAChB,OAAO,kBAAkB,YAAY;CAKtC,IAAI,aAAa,QAAQ,SAAS,WAAW,KAAK,KAAK,KAAK,KAAK,KAAK,WAAW,QAAQ,GACxF,OAAO;CAGR,OAAO;AACR;;;;;;;;;AAUA,SAAgB,gBACf,eACA,eACA,SACU;CACV,OAAO,qBAAqB,eAAe,eAAe,OAAO,MAAM;AACxE;;;;;AAWA,MAAM,wBAAwB;;;;;AAM9B,MAAM,yBAAyB;;AAG/B,MAAM,sBAAsB;;AAG5B,MAAM,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;AAyBhC,SAAgB,iBAAiB,UAAkB,WAAW,QAAgB;CAC7E,IAAI,OAAO,aAAa,YAAY,SAAS,WAAW,GAAG,OAAO;CAElE,IAAI,OAAO,SACT,UAAU,KAAK,CAAC,CAChB,QAAQ,uBAAuB,EAAE,CAAC,CAClC,QAAQ,QAAQ,EAAE,CAAC,CACnB,QAAQ,UAAU,EAAE,CAAC,CACrB,KAAK;CAEP,IAAI,KAAK,WAAW,GAAG,OAAO;CAC9B,IAAI,uBAAuB,KAAK,IAAI,GAAG,OAAO;CAE9C,IAAI,KAAK,SAAS,qBAAqB;EACtC,MAAM,YAAY,KAAK,QAAQ,IAAI,CAAC,CAAC,MAAM,GAAG,uBAAuB;EACrE,OAAO,KAAK,MAAM,GAAG,sBAAsB,UAAU,MAAM,IAAI;CAChE;CAEA,OAAO;AACR"}
1
+ {"version":3,"file":"paths.mjs","names":[],"sources":["../src/paths.ts"],"sourcesContent":["/**\n * Copyright 2026 ResQ Systems, Inc.\n * SPDX-License-Identifier: Apache-2.0\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 Path containment — the *prevention* half of CWE-22, as distinct from\n * the detection rules in `@resq-systems/security/threats`.\n *\n * Detecting `../` is not the control. CWE-22 describes the failure to keep a\n * constructed path inside its restricted directory, so the control is to resolve the\n * candidate against the base and verify the result still lives beneath it. That check\n * holds for inputs no signature catches: an absolute path, a Windows drive-relative\n * path, an alternate separator, or a name whose `..` only appears after normalization.\n *\n * **Node-only.** This module statically imports `node:path` and is therefore published\n * exclusively under the `@resq-systems/security/paths` subpath, never through the\n * package root, so browser bundles of the root barrel stay free of Node builtins.\n *\n * @module @resq-systems/security/paths\n */\n\nimport path from \"node:path\";\n\n//#region Containment\n\n/** Options for {@link resolveContainedPath}. */\nexport interface ContainmentOptions {\n\t/**\n\t * Treat the base directory itself as an acceptable result. Defaults to `true`.\n\t * Pass `false` when the caller requires a path strictly *inside* the base — an\n\t * upload target, say, rather than a listing root.\n\t */\n\treadonly allowBaseItself?: boolean;\n}\n\n/** NUL truncates paths in some syscall layers, so it is rejected before resolution. */\nconst NUL = \"\\u0000\";\n\n/**\n * Resolve an untrusted path against a base directory and verify containment.\n *\n * The candidate is resolved against the base — which also normalizes `.`, `..`, and\n * duplicate separators — and accepted only if the result is the base or sits beneath\n * it. An absolute `untrustedPath` overrides the base under `path.resolve` semantics,\n * so it too is caught by the containment check rather than silently trusted.\n *\n * **This function performs no filesystem I/O**, so it cannot see symlinks. A path that\n * is textually contained may still resolve on disk to a target outside the base. When\n * the target may exist and may be a link, follow up with `fs.promises.realpath` on\n * both the base and the result and re-run {@link isPathContained} on those.\n *\n * @param baseDirectory - Directory the result must stay within. Resolved against the\n * process working directory when relative.\n * @param untrustedPath - Candidate path from an untrusted source.\n * @param options - See {@link ContainmentOptions}.\n * @returns The resolved absolute path when contained, otherwise `null`. Also `null`\n * for non-string arguments, an empty base, or a NUL byte in either argument.\n *\n * @example\n * ```ts\n * const base = \"/srv/uploads\";\n *\n * resolveContainedPath(base, \"avatar.png\"); // \"/srv/uploads/avatar.png\"\n * resolveContainedPath(base, \"nested/a.txt\"); // \"/srv/uploads/nested/a.txt\"\n * resolveContainedPath(base, \"../../etc/passwd\"); // null\n * resolveContainedPath(base, \"/etc/passwd\"); // null\n * ```\n */\nexport function resolveContainedPath(\n\tbaseDirectory: string,\n\tuntrustedPath: string,\n\toptions: ContainmentOptions = {},\n): string | null {\n\tif (typeof baseDirectory !== \"string\" || typeof untrustedPath !== \"string\") {\n\t\treturn null;\n\t}\n\tif (baseDirectory.length === 0) return null;\n\n\t// `a.txt\\0.png` can pass an extension check and then open `a.txt`.\n\tif (untrustedPath.includes(NUL) || baseDirectory.includes(NUL)) {\n\t\treturn null;\n\t}\n\n\tconst { allowBaseItself = true } = options;\n\n\tconst base = path.resolve(baseDirectory);\n\tconst candidate = path.resolve(base, untrustedPath);\n\tconst relative = path.relative(base, candidate);\n\n\tif (relative === \"\") {\n\t\treturn allowBaseItself ? candidate : null;\n\t}\n\n\t// `path.relative` yields a `..`-prefixed path when the candidate escapes, and an\n\t// absolute path when the two sit on different Windows drives.\n\tif (relative === \"..\" || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {\n\t\treturn null;\n\t}\n\n\treturn candidate;\n}\n\n/**\n * Boolean form of {@link resolveContainedPath}.\n *\n * @param baseDirectory - Directory the candidate must stay within.\n * @param candidatePath - Path to test.\n * @param options - See {@link ContainmentOptions}.\n * @returns `true` when the candidate resolves inside the base.\n */\nexport function isPathContained(\n\tbaseDirectory: string,\n\tcandidatePath: string,\n\toptions?: ContainmentOptions,\n): boolean {\n\treturn resolveContainedPath(baseDirectory, candidatePath, options) !== null;\n}\n\n//#endregion\n\n//#region Filename sanitization\n\n/**\n * Characters invalid in a Windows path segment, plus both separators and the C0\n * control range.\n */\n// biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are invalid in filenames and must be stripped\nconst UNSAFE_FILENAME_CHARS = /[<>:\"/\\\\|?*\\u0000-\\u001f\\u007f]/g;\n\n/**\n * Windows device names, reserved regardless of extension — opening `CON.txt` still\n * targets the console device.\n */\nconst RESERVED_WINDOWS_NAMES = /^(?:CON|PRN|AUX|NUL|COM\\d|LPT\\d)(?:\\.|$)/i;\n\n/** Longest filename accepted on common filesystems. */\nconst MAX_FILENAME_LENGTH = 255;\n\n/** Longest extension preserved when truncating an over-long filename. */\nconst MAX_PRESERVED_EXTENSION = 16;\n\n/**\n * Reduce an untrusted string to a single safe path segment.\n *\n * Strips directory separators, control characters, and Windows-reserved characters;\n * removes leading dots so the result cannot become `.`/`..` or a hidden file; trims\n * the trailing dots and spaces Windows silently drops (which is how `evil.php.`\n * becomes `evil.php` after the fact); and refuses reserved device names.\n *\n * The output is a *segment*, never a path. Join it onto a base directory yourself and\n * confirm the result with {@link resolveContainedPath} — that remains the containment\n * control; this is only hygiene.\n *\n * @param filename - Untrusted filename, typically from a multipart upload.\n * @param fallback - Returned when nothing usable survives. Defaults to `\"file\"`.\n * @returns A safe single path segment.\n *\n * @example\n * ```ts\n * sanitizeFilename(\"../../etc/passwd\"); // \"etcpasswd\"\n * sanitizeFilename(\"report.pdf.\"); // \"report.pdf\"\n * sanitizeFilename(\"CON.txt\"); // \"file\"\n * ```\n */\nexport function sanitizeFilename(filename: string, fallback = \"file\"): string {\n\tif (typeof filename !== \"string\" || filename.length === 0) return fallback;\n\n\tlet safe = filename\n\t\t.normalize(\"NFC\")\n\t\t.replace(UNSAFE_FILENAME_CHARS, \"\")\n\t\t.replace(/^\\.+/, \"\")\n\t\t.replace(/[. ]+$/, \"\")\n\t\t.trim();\n\n\tif (safe.length === 0) return fallback;\n\tif (RESERVED_WINDOWS_NAMES.test(safe)) return fallback;\n\n\tif (safe.length > MAX_FILENAME_LENGTH) {\n\t\tconst extension = path.extname(safe).slice(0, MAX_PRESERVED_EXTENSION);\n\t\tsafe = safe.slice(0, MAX_FILENAME_LENGTH - extension.length) + extension;\n\t}\n\n\treturn safe;\n}\n\n//#endregion\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiDA,MAAM,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgCZ,SAAgB,qBACf,eACA,eACA,UAA8B,CAAC,GACf;CAChB,IAAI,OAAO,kBAAkB,YAAY,OAAO,kBAAkB,UACjE,OAAO;CAER,IAAI,cAAc,WAAW,GAAG,OAAO;CAGvC,IAAI,cAAc,SAAS,GAAG,KAAK,cAAc,SAAS,GAAG,GAC5D,OAAO;CAGR,MAAM,EAAE,kBAAkB,SAAS;CAEnC,MAAM,OAAO,KAAK,QAAQ,aAAa;CACvC,MAAM,YAAY,KAAK,QAAQ,MAAM,aAAa;CAClD,MAAM,WAAW,KAAK,SAAS,MAAM,SAAS;CAE9C,IAAI,aAAa,IAChB,OAAO,kBAAkB,YAAY;CAKtC,IAAI,aAAa,QAAQ,SAAS,WAAW,KAAK,KAAK,KAAK,KAAK,KAAK,WAAW,QAAQ,GACxF,OAAO;CAGR,OAAO;AACR;;;;;;;;;AAUA,SAAgB,gBACf,eACA,eACA,SACU;CACV,OAAO,qBAAqB,eAAe,eAAe,OAAO,MAAM;AACxE;;;;;AAWA,MAAM,wBAAwB;;;;;AAM9B,MAAM,yBAAyB;;AAG/B,MAAM,sBAAsB;;AAG5B,MAAM,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;AAyBhC,SAAgB,iBAAiB,UAAkB,WAAW,QAAgB;CAC7E,IAAI,OAAO,aAAa,YAAY,SAAS,WAAW,GAAG,OAAO;CAElE,IAAI,OAAO,SACT,UAAU,KAAK,CAAC,CAChB,QAAQ,uBAAuB,EAAE,CAAC,CAClC,QAAQ,QAAQ,EAAE,CAAC,CACnB,QAAQ,UAAU,EAAE,CAAC,CACrB,KAAK;CAEP,IAAI,KAAK,WAAW,GAAG,OAAO;CAC9B,IAAI,uBAAuB,KAAK,IAAI,GAAG,OAAO;CAE9C,IAAI,KAAK,SAAS,qBAAqB;EACtC,MAAM,YAAY,KAAK,QAAQ,IAAI,CAAC,CAAC,MAAM,GAAG,uBAAuB;EACrE,OAAO,KAAK,MAAM,GAAG,sBAAsB,UAAU,MAAM,IAAI;CAChE;CAEA,OAAO;AACR"}
@@ -11,14 +11,14 @@ type SyncSchema<T> = Schema.Codec<T, unknown, never>;
11
11
  * Schema constraining a URL protocol to the recognized safe set.
12
12
  * @compliance NIST 800-53 SI-10 (Information Input Validation)
13
13
  */
14
- declare const UrlProtocolSchema: Schema.Literals<readonly ["http:", "https:", "mailto:", "tel:", "ftp:"]>;
14
+ export declare const UrlProtocolSchema: Schema.Literals<readonly ["http:", "https:", "mailto:", "tel:", "ftp:"]>;
15
15
  /** One of the protocols accepted by {@link UrlProtocolSchema}. */
16
- type UrlProtocol = typeof UrlProtocolSchema.Type;
16
+ export type UrlProtocol = typeof UrlProtocolSchema.Type;
17
17
  /**
18
18
  * Schema for the per-category toggles that drive {@link redactPII}.
19
19
  * @compliance NIST 800-53 AU-3 (Content of Audit Records)
20
20
  */
21
- declare const PIIRedactionOptionsSchema: Schema.Struct<{
21
+ export declare const PIIRedactionOptionsSchema: Schema.Struct<{
22
22
  readonly redactEmails: Schema.optional<Schema.Boolean>;
23
23
  readonly redactPhones: Schema.optional<Schema.Boolean>;
24
24
  readonly redactSSN: Schema.optional<Schema.Boolean>;
@@ -27,20 +27,20 @@ declare const PIIRedactionOptionsSchema: Schema.Struct<{
27
27
  readonly redactDates: Schema.optional<Schema.Boolean>;
28
28
  }>;
29
29
  /** Decoded options accepted by {@link redactPIIEffect} / {@link redactPII}. */
30
- type PIIRedactionOptions = typeof PIIRedactionOptionsSchema.Type;
30
+ export type PIIRedactionOptions = typeof PIIRedactionOptionsSchema.Type;
31
31
  /**
32
32
  * Schema for the options controlling {@link validateUserInputEffect}.
33
33
  * @compliance NIST 800-53 SI-10 (Information Input Validation)
34
34
  */
35
- declare const UserInputOptionsSchema: Schema.Struct<{
35
+ export declare const UserInputOptionsSchema: Schema.Struct<{
36
36
  readonly maxLength: Schema.optional<Schema.Int>;
37
37
  readonly allowHtml: Schema.optional<Schema.Boolean>;
38
38
  readonly allowNewlines: Schema.optional<Schema.Boolean>;
39
39
  readonly trimWhitespace: Schema.optional<Schema.Boolean>;
40
40
  }>;
41
41
  /** Decoded options accepted by {@link validateUserInputEffect}. */
42
- type UserInputOptions = typeof UserInputOptionsSchema.Type;
43
- declare const SafeUrlSchema: Schema.String;
42
+ export type UserInputOptions = typeof UserInputOptionsSchema.Type;
43
+ export declare const SafeUrlSchema: Schema.String;
44
44
  /**
45
45
  * A URL string vouched safe against scheme-based injection. Mint one by
46
46
  * narrowing through the {@link isValidUrl} type guard (backed by
@@ -52,13 +52,13 @@ declare const SafeUrlSchema: Schema.String;
52
52
  * It does **not** guarantee the host is reachable or trusted — for that, validate the
53
53
  * resolved origin with `isAllowedOrigin` from `@resq-systems/security/controls`.
54
54
  */
55
- type SafeUrl = Brand<string, "SafeUrl">;
55
+ export type SafeUrl = Brand<string, "SafeUrl">;
56
56
  /**
57
57
  * Schema for a sanitized HTML-safe string — validates the value is a string; the
58
58
  * actual escaping is applied at runtime by the sanitization helpers.
59
59
  * @compliance NIST 800-53 SI-10 (Information Input Validation)
60
60
  */
61
- declare const SanitizedStringSchema: Schema.String;
61
+ export declare const SanitizedStringSchema: Schema.String;
62
62
  /**
63
63
  * A string carrying the {@link SanitizedStringSchema} contract. The schema is
64
64
  * `S.String` alone, so decoding asserts only that the value is a string —
@@ -66,7 +66,7 @@ declare const SanitizedStringSchema: Schema.String;
66
66
  * (e.g. {@link escapeHtml}). The type name signals intent, not a proof of
67
67
  * escaping.
68
68
  */
69
- type SanitizedString = typeof SanitizedStringSchema.Type;
69
+ export type SanitizedString = typeof SanitizedStringSchema.Type;
70
70
  /**
71
71
  * Schema for email address validation.
72
72
  *
@@ -75,43 +75,43 @@ type SanitizedString = typeof SanitizedStringSchema.Type;
75
75
  * sync with `@resq-systems/email-templates`'s `EmailAddress` brand.
76
76
  * @compliance NIST 800-53 SI-10 (Information Input Validation)
77
77
  */
78
- declare const EmailSchema: Schema.String;
78
+ export declare const EmailSchema: Schema.String;
79
79
  /**
80
80
  * An email address that matches {@link EmailSchema}. Mint one by narrowing
81
81
  * through the {@link isValidEmail} type guard. The brand guarantees only
82
82
  * syntactic well-formedness (including IDN/Punycode TLDs) — not that the
83
83
  * mailbox exists or is deliverable.
84
84
  */
85
- type Email = Brand<string, "Email">;
85
+ export type Email = Brand<string, "Email">;
86
86
  /**
87
87
  * Schema for phone number validation (US format).
88
88
  * @compliance NIST 800-53 SI-10 (Information Input Validation)
89
89
  */
90
- declare const PhoneNumberSchema: Schema.String;
90
+ export declare const PhoneNumberSchema: Schema.String;
91
91
  /**
92
92
  * A US-format phone number matching {@link PhoneNumberSchema}. Mint one by
93
93
  * narrowing through the {@link isValidPhone} type guard. The brand asserts
94
94
  * the digit/separator shape only; it neither normalizes formatting nor
95
95
  * confirms the number is assigned.
96
96
  */
97
- type PhoneNumber = Brand<string, "PhoneNumber">;
97
+ export type PhoneNumber = Brand<string, "PhoneNumber">;
98
98
  /**
99
99
  * Schema for SSN validation (US format).
100
100
  * @compliance NIST 800-53 SI-10 (Information Input Validation)
101
101
  */
102
- declare const SSNSchema: Schema.String;
102
+ export declare const SSNSchema: Schema.String;
103
103
  /**
104
104
  * A US Social Security Number matching {@link SSNSchema}. Mint one by
105
105
  * narrowing through the {@link isValidSSN} type guard. The brand asserts
106
106
  * the `NNN-NN-NNNN` shape only — it does not validate area/group ranges or
107
107
  * confirm the number was ever issued. Treat any value as sensitive PII.
108
108
  */
109
- type SSN = Brand<string, "SSN">;
109
+ export type SSN = Brand<string, "SSN">;
110
110
  /**
111
111
  * Schema for credit card number validation.
112
112
  * @compliance NIST 800-53 SI-10 (Information Input Validation)
113
113
  */
114
- declare const CreditCardSchema: Schema.String;
114
+ export declare const CreditCardSchema: Schema.String;
115
115
  /**
116
116
  * A card number matching the {@link CreditCardSchema} pattern (13–16 digits
117
117
  * with optional group separators). No exported type guard mints this brand;
@@ -119,18 +119,18 @@ declare const CreditCardSchema: Schema.String;
119
119
  * shape check only — it performs **no** Luhn checksum and does not identify
120
120
  * the issuer. Treat any value as sensitive PII.
121
121
  */
122
- type CreditCard = Brand<string, "CreditCard">;
122
+ export type CreditCard = Brand<string, "CreditCard">;
123
123
  /**
124
124
  * Schema for IPv4 address validation.
125
125
  */
126
- declare const IPv4Schema: Schema.String;
126
+ export declare const IPv4Schema: Schema.String;
127
127
  /**
128
128
  * A dotted-quad string matching {@link IPv4Schema}. No exported type guard
129
129
  * mints this brand; decode {@link IPv4Schema} directly. The pattern checks
130
130
  * four dot-separated groups of 1–3 digits only — it does **not** bound each
131
131
  * octet to `0–255`, so `999.0.0.1` still matches.
132
132
  */
133
- type IPv4 = Brand<string, "IPv4">;
133
+ export type IPv4 = Brand<string, "IPv4">;
134
134
  /**
135
135
  * Escapes special HTML characters in a string to their corresponding HTML entities,
136
136
  * preventing direct injection of HTML and JavaScript when rendering untrusted content.
@@ -145,7 +145,7 @@ type IPv4 = Brand<string, "IPv4">;
145
145
  * // "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;"
146
146
  * ```
147
147
  */
148
- declare const escapeHtml: (text: string) => string;
148
+ export declare const escapeHtml: (text: string) => string;
149
149
  /**
150
150
  * Validates and sanitizes a user-supplied URL using Effect Schema.
151
151
  * Returns an Exit with the sanitized URL or an error.
@@ -171,7 +171,7 @@ declare const escapeHtml: (text: string) => string;
171
171
  * // Exit.fail(...)
172
172
  * ```
173
173
  */
174
- declare const sanitizeUrlEffect: (url: string, allowedProtocols?: readonly UrlProtocol[]) => Exit.Exit<string, Schema.SchemaError>;
174
+ export declare const sanitizeUrlEffect: (url: string, allowedProtocols?: readonly UrlProtocol[]) => Exit.Exit<string, Schema.SchemaError>;
175
175
  /**
176
176
  * Validates and sanitizes a user-supplied URL, ensuring it conforms to allowed protocols
177
177
  * and is not a vector for injection attacks like `javascript:` or `data:`.
@@ -187,7 +187,7 @@ declare const sanitizeUrlEffect: (url: string, allowedProtocols?: readonly UrlPr
187
187
  * sanitizeUrl('javascript:alert(1)'); // ''
188
188
  * ```
189
189
  */
190
- declare const sanitizeUrl: (url: string, allowedProtocols?: readonly UrlProtocol[]) => string;
190
+ export declare const sanitizeUrl: (url: string, allowedProtocols?: readonly UrlProtocol[]) => string;
191
191
  /**
192
192
  * Sanitizes HTML to prevent XSS attacks.
193
193
  * Uses DOMPurify under the hood. If DOM is not available (e.g. server-side without JSDOM),
@@ -207,7 +207,7 @@ declare const sanitizeUrl: (url: string, allowedProtocols?: readonly UrlProtocol
207
207
  * @returns The sanitized HTML string, or the escaped string when no DOM is
208
208
  * available.
209
209
  */
210
- declare const sanitizeHtml: (html: string, options?: Config) => string;
210
+ export declare const sanitizeHtml: (html: string, options?: Config) => string;
211
211
  /**
212
212
  * Validates user input using Effect Schema and returns an Exit.
213
213
  *
@@ -224,7 +224,7 @@ declare const sanitizeHtml: (html: string, options?: Config) => string;
224
224
  * to `maxLength`; failure carries a {@link S.SchemaError}.
225
225
  * @compliance NIST 800-53 SI-10 (Information Input Validation)
226
226
  */
227
- declare const validateUserInputEffect: (input: string, options?: UserInputOptions) => Exit.Exit<string, Schema.SchemaError>;
227
+ export declare const validateUserInputEffect: (input: string, options?: UserInputOptions) => Exit.Exit<string, Schema.SchemaError>;
228
228
  /**
229
229
  * Validates and sanitizes generic user input by trimming, removing HTML tags (unless allowed),
230
230
  * normalizing whitespace, and removing dangerous patterns to prevent XSS and basic injection flaws.
@@ -241,7 +241,7 @@ declare const validateUserInputEffect: (input: string, options?: UserInputOption
241
241
  * validateUserInput('<script>alert(1)</script>test', 100); // "test"
242
242
  * ```
243
243
  */
244
- declare const validateUserInput: (input: string, maxLength?: number, allowHtml?: boolean) => string;
244
+ export declare const validateUserInput: (input: string, maxLength?: number, allowHtml?: boolean) => string;
245
245
  /**
246
246
  * Safely parses JSON with Effect Schema validation and prototype pollution protection.
247
247
  *
@@ -267,7 +267,7 @@ declare const validateUserInput: (input: string, maxLength?: number, allowHtml?:
267
267
  * // Option.some({ name: 'John', age: 30 })
268
268
  * ```
269
269
  */
270
- declare const parseJsonWithSchema: <A>(jsonString: string, schema: SyncSchema<A>) => Option.Option<A>;
270
+ export declare const parseJsonWithSchema: <A>(jsonString: string, schema: SyncSchema<A>) => Option.Option<A>;
271
271
  /**
272
272
  * Sanitizes and safely parses a JSON string, removing suspicious syntax elements that could
273
273
  * potentially result in JSON polyglot exploits or prototype pollution.
@@ -288,7 +288,7 @@ declare const parseJsonWithSchema: <A>(jsonString: string, schema: SyncSchema<A>
288
288
  * // obj: unknown — narrow before use, or use parseJsonWithSchema
289
289
  * ```
290
290
  */
291
- declare const sanitizeJson: (jsonString: string) => unknown;
291
+ export declare const sanitizeJson: (jsonString: string) => unknown;
292
292
  /**
293
293
  * Strips ANSI escape sequences from a string.
294
294
  *
@@ -310,7 +310,7 @@ declare const sanitizeJson: (jsonString: string) => unknown;
310
310
  * stripAnsi('\x1b[31mRed text\x1b[0m'); // 'Red text'
311
311
  * ```
312
312
  */
313
- declare const stripAnsi: (text: string) => string;
313
+ export declare const stripAnsi: (text: string) => string;
314
314
  /**
315
315
  * Redacts PII from text using Effect Schema validated options.
316
316
  *
@@ -325,7 +325,7 @@ declare const stripAnsi: (text: string) => string;
325
325
  * carries a {@link S.SchemaError} from invalid `options`.
326
326
  * @compliance NIST 800-53 AU-3 (Content of Audit Records)
327
327
  */
328
- declare const redactPIIEffect: (text: string, options?: PIIRedactionOptions) => Exit.Exit<string, Schema.SchemaError>;
328
+ export declare const redactPIIEffect: (text: string, options?: PIIRedactionOptions) => Exit.Exit<string, Schema.SchemaError>;
329
329
  /**
330
330
  * Redacts common PII patterns in a string for safe logging.
331
331
  * Detects and masks SSNs, credit cards, emails, phone numbers, etc.
@@ -350,7 +350,7 @@ declare const redactPIIEffect: (text: string, options?: PIIRedactionOptions) =>
350
350
  * // 'SSN: [SSN]'
351
351
  * ```
352
352
  */
353
- declare const redactPII: (text: string, options?: PIIRedactionOptions & {
353
+ export declare const redactPII: (text: string, options?: PIIRedactionOptions & {
354
354
  customPatterns?: Array<{
355
355
  pattern: RegExp;
356
356
  replacement: string;
@@ -379,7 +379,7 @@ declare const redactPII: (text: string, options?: PIIRedactionOptions & {
379
379
  * // '{\n "user": "john",\n "password": "[REDACTED]"\n}'
380
380
  * ```
381
381
  */
382
- declare const safeStringify: (obj: unknown, sensitiveKeys?: string[], indent?: number) => string;
382
+ export declare const safeStringify: (obj: unknown, sensitiveKeys?: string[], indent?: number) => string;
383
383
  /**
384
384
  * Validates if a string is a valid email address using Effect Schema.
385
385
  *
@@ -389,7 +389,7 @@ declare const safeStringify: (obj: unknown, sensitiveKeys?: string[], indent?: n
389
389
  * @param email - The string to validate.
390
390
  * @returns true if valid email, false otherwise.
391
391
  */
392
- declare const isValidEmail: (email: string) => email is Email;
392
+ export declare const isValidEmail: (email: string) => email is Email;
393
393
  /**
394
394
  * Validates if a string is a valid phone number using Effect Schema.
395
395
  *
@@ -398,7 +398,7 @@ declare const isValidEmail: (email: string) => email is Email;
398
398
  * @param phone - The string to validate.
399
399
  * @returns true if valid phone number, false otherwise.
400
400
  */
401
- declare const isValidPhone: (phone: string) => phone is PhoneNumber;
401
+ export declare const isValidPhone: (phone: string) => phone is PhoneNumber;
402
402
  /**
403
403
  * Validates if a string is a valid SSN using Effect Schema.
404
404
  *
@@ -407,7 +407,7 @@ declare const isValidPhone: (phone: string) => phone is PhoneNumber;
407
407
  * @param ssn - The string to validate.
408
408
  * @returns true if valid SSN, false otherwise.
409
409
  */
410
- declare const isValidSSN: (ssn: string) => ssn is SSN;
410
+ export declare const isValidSSN: (ssn: string) => ssn is SSN;
411
411
  /**
412
412
  * Validates if a string is a safe URL using Effect Schema.
413
413
  *
@@ -416,7 +416,6 @@ declare const isValidSSN: (ssn: string) => ssn is SSN;
416
416
  * @param url - The string to validate.
417
417
  * @returns true if valid and safe URL, false otherwise.
418
418
  */
419
- declare const isValidUrl: (url: string) => url is SafeUrl;
419
+ export declare const isValidUrl: (url: string) => url is SafeUrl;
420
420
  //#endregion
421
- 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 };
422
421
  //# sourceMappingURL=sanitize.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"sanitize.d.mts","names":[],"sources":["../src/sanitize.ts"],"mappings":";;;;;;;;KAoCK,WAAW,KAAK,OAAE,MAAM;;;;;cAShB,mBAAiB,OAAA;;KAElB,qBAAqB,kBAAkB;;;;;cAMtC,2BAAyB,OAAA;;;;;;;;;KAS1B,6BAA6B,0BAA0B;;;;;cAMtD,wBAAsB,OAAA;;;;;;;KAOvB,0BAA0B,uBAAuB;cAuBhD,eAAa,OAAA;;;;;;;;;;;;KA4Bd,UAAU;;;;;;cAOT,uBAAqB,OAAA;;;;;;;;KAQtB,yBAAyB,sBAAsB;;;;;;;;;cAU9C,aAAW,OAAA;;;;;;;KASZ,QAAQ;;;;;cAMP,mBAAiB,OAAA;;;;;;;KASlB,cAAc;;;;;cAMb,WAAS,OAAA;;;;;;;KAOV,MAAM;;;;;cAML,kBAAgB,OAAA;;;;;;;;KAUjB,aAAa;;;;cAKZ,YAAU,OAAA;;;;;;;KAOX,OAAO;;;;;;;;;;;;;;;cAmBN,aAAc;;;;;;;;;;;;;;;;;;;;;;;;;;cAsCd,oBACZ,aACA,4BAA2B,kBACzB,KAAK,aAAa,OAAE;;;;;;;;;;;;;;;;cA+CV,cACZ,aACA,4BAA2B;;;;;;;;;;;;;;;;;;;;cAyGf,eAAgB,cAAc,UAAU;;;;;;;;;;;;;;;;;cA6BxC,0BACZ,eACA,UAAS,qBACP,KAAK,aAAa,OAAE;;;;;;;;;;;;;;;;;cA+DV,oBAAqB,eAAe,oBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;cAmFrD,sBAAuB,GACnC,oBACA,QAAQ,WAAW,OACjB,OAAO,OAAO;;;;;;;;;;;;;;;;;;;;;cA0CJ,eAAgB;;;;;;;;;;;;;;;;;;;;;;cA0ChB,YAAa;;;;;;;;;;;;;;;cAoEb,kBACZ,cACA,UAAS,wBACP,KAAK,aAAa,OAAE;;;;;;;;;;;;;;;;;;;;;;;;;cAuEV,YACZ,cACA,UAAS;EACR,iBAAiB;IAAQ,SAAS;IAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;cA0D/B,gBACZ,cACA,0BAUA;;;;;;;;;;cA+BY,eAAgB,kBAAgB,SAAS;;;;;;;;;cAYzC,eAAgB,kBAAgB,SAAS;;;;;;;;;cAYzC,aAAc,gBAAc,OAAO;;;;;;;;;cAYnC,aAAc,gBAAc,OAAO"}
1
+ {"version":3,"file":"sanitize.d.mts","names":[],"sources":["../src/sanitize.ts"],"mappings":";;;;;;;;KAqCK,WAAW,KAAK,OAAE,MAAM;;;;;qBAShB,mBAAiB,OAAA;;YAElB,qBAAqB,kBAAkB;;;;;qBAMtC,2BAAyB,OAAA;;;;;;;;;YAS1B,6BAA6B,0BAA0B;;;;;qBAMtD,wBAAsB,OAAA;;;;;;;YAOvB,0BAA0B,uBAAuB;qBAuBhD,eAAa,OAAA;;;;;;;;;;;;YA4Bd,UAAU;;;;;;qBAOT,uBAAqB,OAAA;;;;;;;;YAQtB,yBAAyB,sBAAsB;;;;;;;;;qBAU9C,aAAW,OAAA;;;;;;;YASZ,QAAQ;;;;;qBAMP,mBAAiB,OAAA;;;;;;;YASlB,cAAc;;;;;qBAMb,WAAS,OAAA;;;;;;;YAOV,MAAM;;;;;qBAML,kBAAgB,OAAA;;;;;;;;YAUjB,aAAa;;;;qBAKZ,YAAU,OAAA;;;;;;;YAOX,OAAO;;;;;;;;;;;;;;;qBAmBN,aAAc;;;;;;;;;;;;;;;;;;;;;;;;;;qBAsCd,oBACZ,aACA,4BAA2B,kBACzB,KAAK,aAAa,OAAE;;;;;;;;;;;;;;;;qBA+CV,cACZ,aACA,4BAA2B;;;;;;;;;;;;;;;;;;;;qBAyGf,eAAgB,cAAc,UAAU;;;;;;;;;;;;;;;;;qBA6BxC,0BACZ,eACA,UAAS,qBACP,KAAK,aAAa,OAAE;;;;;;;;;;;;;;;;;qBA+DV,oBAAqB,eAAe,oBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;qBAmFrD,sBAAuB,GACnC,oBACA,QAAQ,WAAW,OACjB,OAAO,OAAO;;;;;;;;;;;;;;;;;;;;;qBA0CJ,eAAgB;;;;;;;;;;;;;;;;;;;;;;qBA0ChB,YAAa;;;;;;;;;;;;;;;qBAoEb,kBACZ,cACA,UAAS,wBACP,KAAK,aAAa,OAAE;;;;;;;;;;;;;;;;;;;;;;;;;qBAuEV,YACZ,cACA,UAAS;EACR,iBAAiB;IAAQ,SAAS;IAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;qBA0D/B,gBACZ,cACA,0BAUA;;;;;;;;;;qBA+BY,eAAgB,kBAAgB,SAAS;;;;;;;;;qBAYzC,eAAgB,kBAAgB,SAAS;;;;;;;;;qBAYzC,aAAc,gBAAc,OAAO;;;;;;;;;qBAYnC,aAAc,gBAAc,OAAO"}