@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
@@ -1 +1 @@
1
- {"version":3,"file":"validators.mjs","names":[],"sources":["../src/validators.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\nimport { assertNever } from \"@resq-systems/types\";\n\n// ============================================\n// Threat Pattern Definitions\n// ============================================\n\n/**\n * XSS attack patterns\n * Detects script injection, event handlers, and dangerous URIs\n */\nconst XSS_PATTERNS = [\n\t// Script tags (opening only — avoids ReDoS from greedy cross-tag matching)\n\t/<script\\b/gi,\n\t// Event handlers\n\t/\\bon\\w+\\s*=/gi,\n\t// JavaScript URIs\n\t/javascript\\s*:/gi,\n\t// Data URIs with script content\n\t/data\\s*:\\s*text\\/html/gi,\n\t/data\\s*:\\s*application\\/javascript/gi,\n\t// Expression evaluation\n\t/expression\\s*\\(/gi,\n\t// VBScript\n\t/vbscript\\s*:/gi,\n\t// Iframe injection\n\t/<iframe\\b/gi,\n\t// Object/embed injection\n\t/<object\\b/gi,\n\t/<embed\\b/gi,\n\t// Style-based attacks\n\t/<style\\b/gi,\n\t// Document manipulation\n\t/document\\s*\\.\\s*(cookie|domain|write|location)/gi,\n\t// Window manipulation\n\t/window\\s*\\.\\s*(location|open|eval)/gi,\n\t// Eval and Function constructor\n\t/\\beval\\s*\\(/gi,\n\t/\\bnew\\s+Function\\s*\\(/gi,\n\t// innerHTML manipulation\n\t/\\.innerHTML\\s*=/gi,\n\t// Prototype pollution\n\t/__proto__/gi,\n\t/constructor\\s*\\[/gi,\n];\n\n/**\n * SQL injection patterns\n * Detects common SQL attack vectors\n */\nconst SQL_INJECTION_PATTERNS = [\n\t// UNION-based injection\n\t/\\bUNION\\s+(ALL\\s+)?SELECT\\b/gi,\n\t// DROP/DELETE/TRUNCATE attacks\n\t/\\bDROP\\s+(TABLE|DATABASE|INDEX|VIEW)\\b/gi,\n\t/\\bDELETE\\s+FROM\\b/gi,\n\t/\\bTRUNCATE\\s+TABLE\\b/gi,\n\t// Comment-based attacks\n\t/--\\s*$/gm,\n\t/\\/\\*[\\s\\S]*?\\*\\//g,\n\t// Always-true conditions\n\t/'\\s*OR\\s+'[\\d\\w]+'\\s*=\\s*'[\\d\\w]+/gi,\n\t/'\\s*OR\\s+\\d+\\s*=\\s*\\d+/gi,\n\t/\"\\s*OR\\s+\"[\\d\\w]+\"\\s*=\\s*\"[\\d\\w]+/gi,\n\t/1\\s*=\\s*1/g,\n\t// Stacked queries\n\t/;\\s*(SELECT|INSERT|UPDATE|DELETE|DROP|EXEC|UNION)/gi,\n\t// Time-based blind injection\n\t/SLEEP\\s*\\(\\s*\\d+\\s*\\)/gi,\n\t/WAITFOR\\s+DELAY/gi,\n\t/BENCHMARK\\s*\\(/gi,\n\t// Information schema access\n\t/INFORMATION_SCHEMA/gi,\n\t// Hex encoding bypass\n\t/0x[0-9a-f]+/gi,\n\t// EXEC/EXECUTE\n\t/\\bEXEC(UTE)?\\s*\\(/gi,\n\t// xp_ procedures (SQL Server)\n\t/\\bxp_\\w+/gi,\n];\n\n/**\n * NoSQL injection patterns\n * Detects MongoDB and other NoSQL attack vectors\n */\nconst NOSQL_INJECTION_PATTERNS = [\n\t// MongoDB operators\n\t/\\$(?:gt|gte|lt|lte|ne|eq|in|nin|and|or|not|nor|exists|type|mod|regex|text|where|all|elemMatch|size|slice|expr|jsonSchema|meta)\\b/gi,\n\t// JavaScript execution in MongoDB\n\t/\\$where\\s*:/gi,\n\t/\\$function\\s*:/gi,\n\t// Operator injection\n\t/\\{\\s*\\$[a-z]+\\s*:/gi,\n\t// Array injection\n\t/\\[\\s*\\$[a-z]+\\s*\\]/gi,\n];\n\n/**\n * Path traversal patterns\n * Detects directory traversal attacks\n */\nconst PATH_TRAVERSAL_PATTERNS = [\n\t// Directory traversal\n\t/\\.\\.[/\\\\]/g,\n\t// URL-encoded traversal\n\t/%2e%2e[%2f%5c]/gi,\n\t/%252e%252e%252f/gi,\n\t// Double-encoded\n\t/\\.\\.%2f/gi,\n\t/\\.\\.%5c/gi,\n\t// Null byte injection\n\t/%00/g,\n\t// Common sensitive paths\n\t/\\/etc\\/passwd/gi,\n\t/\\/etc\\/shadow/gi,\n\t/\\/proc\\/self/gi,\n\t/C:\\\\Windows/gi,\n\t/C:\\\\System32/gi,\n];\n\n/**\n * Homoglyph patterns\n * Detects Unicode characters that look like ASCII but aren't\n * Used in phishing and IDN homograph attacks\n */\nconst HOMOGLYPH_MAP: Record<string, string[]> = {\n\ta: [\"а\", \"ɑ\", \"α\", \"а\"], // Cyrillic а, Latin alpha, Greek alpha\n\tc: [\"с\", \"ϲ\", \"ⅽ\"], // Cyrillic с, Greek lunate sigma\n\te: [\"е\", \"ε\", \"ė\"], // Cyrillic е, Greek epsilon\n\to: [\"о\", \"ο\", \"ᴏ\", \"०\"], // Cyrillic о, Greek omicron\n\tp: [\"р\", \"ρ\"], // Cyrillic р, Greek rho\n\ts: [\"ѕ\", \"ꜱ\"], // Cyrillic ѕ\n\tx: [\"х\", \"χ\"], // Cyrillic х, Greek chi\n\ty: [\"у\", \"γ\"], // Cyrillic у, Greek gamma\n\tB: [\"В\", \"Β\"], // Cyrillic В, Greek Beta\n\tH: [\"Н\", \"Η\"], // Cyrillic Н, Greek Eta\n\tK: [\"К\", \"Κ\"], // Cyrillic К, Greek Kappa\n\tM: [\"М\", \"Μ\"], // Cyrillic М, Greek Mu\n\tP: [\"Р\", \"Ρ\"], // Cyrillic Р, Greek Rho\n\tT: [\"Т\", \"Τ\"], // Cyrillic Т, Greek Tau\n};\n\n// ============================================\n// Detection Functions\n// ============================================\n\n/**\n * Outcome of {@link detectThreatPatterns}.\n *\n * `isSafe` is the boolean shortcut; `threats` is the full list of\n * findings (one per detector that fired). Use\n * {@link getThreatErrorMessage} to render a user-facing message for\n * the first finding.\n */\nexport interface ThreatDetectionResult {\n\t/** `true` when no detectors fired. Equivalent to `threats.length === 0`. */\n\tisSafe: boolean;\n\t/** All findings produced by enabled detectors, in detector order. */\n\tthreats: ThreatFinding[];\n}\n\n/**\n * A single detector hit. Detectors that fire return at most one\n * finding per call (one example is enough to reject the input).\n */\nexport interface ThreatFinding {\n\t/** Which detector matched. */\n\ttype: ThreatType;\n\t/** Human-readable description suitable for log lines (not for end users — use {@link getThreatErrorMessage} instead). */\n\tdescription: string;\n\t/** First 50 chars of the matching substring, for diagnostics. Truncated to prevent leaking large payloads in logs. */\n\tmatchedPattern?: string;\n}\n\n/**\n * The closed set of threat categories the validators recognize. Add\n * new categories here when adding a new detector.\n */\nexport type ThreatType =\n\t| \"xss\"\n\t| \"sql_injection\"\n\t| \"nosql_injection\"\n\t| \"command_injection\"\n\t| \"path_traversal\"\n\t| \"homoglyph\";\n\n/**\n * Detect XSS-style payloads (script tags, event handlers, dangerous\n * URI schemes, prototype pollution, …) in a UTF-8 input.\n *\n * Inputs longer than 100 000 characters are truncated before scanning\n * to bound regex evaluation cost and prevent ReDoS on crafted\n * payloads. Returns at most one finding — the regex catalog is\n * exhaustive enough that the first hit is sufficient for a\n * reject-or-sanitize decision.\n *\n * @param input - String to scan.\n * @returns Empty array when nothing matches, or a single\n * {@link ThreatFinding} of type `\"xss\"`.\n *\n * @example\n * ```ts\n * containsXSSPatterns(`<img src=x onerror=\"alert(1)\">`);\n * // → [{ type: \"xss\", description: \"...\", matchedPattern: \"onerror=\" }]\n * ```\n */\nexport function containsXSSPatterns(input: string): ThreatFinding[] {\n\tconst findings: ThreatFinding[] = [];\n\t// Limit input length to prevent ReDoS on crafted payloads\n\tconst bounded = input.length > 100_000 ? input.slice(0, 100_000) : input;\n\n\tfor (const pattern of XSS_PATTERNS) {\n\t\tconst match = bounded.match(pattern);\n\t\tif (match) {\n\t\t\tfindings.push({\n\t\t\t\ttype: \"xss\",\n\t\t\t\tdescription: \"Potential cross-site scripting (XSS) detected\",\n\t\t\t\tmatchedPattern: match[0].slice(0, 50),\n\t\t\t});\n\t\t\tbreak; // One finding per type is enough\n\t\t}\n\t}\n\n\treturn findings;\n}\n\n/**\n * Detect SQL-injection patterns (UNION SELECT, DROP TABLE,\n * comment-based bypasses, always-true tautologies, stacked queries)\n * in input.\n *\n * **Not a replacement for parameterised queries.** Use this as a\n * defense-in-depth signal in addition to a properly bound prepared\n * statement, never as the only barrier.\n *\n * @param input - String to scan. Truncated at 100 000 characters.\n * @returns Empty array, or one finding of type `\"sql_injection\"`.\n */\nexport function containsSQLInjection(input: string): ThreatFinding[] {\n\tconst findings: ThreatFinding[] = [];\n\tconst bounded = input.length > 100_000 ? input.slice(0, 100_000) : input;\n\n\tfor (const pattern of SQL_INJECTION_PATTERNS) {\n\t\tconst match = bounded.match(pattern);\n\t\tif (match) {\n\t\t\tfindings.push({\n\t\t\t\ttype: \"sql_injection\",\n\t\t\t\tdescription: \"Potential SQL injection detected\",\n\t\t\t\tmatchedPattern: match[0].slice(0, 50),\n\t\t\t});\n\t\t\tbreak;\n\t\t}\n\t}\n\n\treturn findings;\n}\n\n/**\n * Detect NoSQL-injection patterns — Mongo-style operator injection\n * (`$where`, `$ne`, `$regex`), JavaScript-in-query payloads, and\n * structural manipulators that can bypass auth filters in document\n * stores.\n *\n * @param input - String to scan.\n * @returns Empty array, or one finding of type `\"nosql_injection\"`.\n */\nexport function containsNoSQLInjection(input: string): ThreatFinding[] {\n\tconst findings: ThreatFinding[] = [];\n\n\tfor (const pattern of NOSQL_INJECTION_PATTERNS) {\n\t\tconst match = input.match(pattern);\n\t\tif (match) {\n\t\t\tfindings.push({\n\t\t\t\ttype: \"nosql_injection\",\n\t\t\t\tdescription: \"Potential NoSQL injection detected\",\n\t\t\t\tmatchedPattern: match[0].slice(0, 50),\n\t\t\t});\n\t\t\tbreak;\n\t\t}\n\t}\n\n\treturn findings;\n}\n\n/**\n * Detect shell command-injection patterns: command substitution\n * (`$(...)`, backticks), chained dangerous commands (`; rm`, `; curl`,\n * …) and shell-piped exec (`| sh`, `| bash`).\n *\n * **Off by default in {@link detectThreatPatterns}** — these patterns\n * occasionally fire on legitimate user content. Enable explicitly\n * (`checkCommandInjection: true`) only when input flows into a child\n * process or shell.\n *\n * @param input - String to scan. Truncated at 100 000 characters.\n * @returns Empty array, or one finding of type `\"command_injection\"`.\n */\nexport function containsCommandInjection(input: string): ThreatFinding[] {\n\tconst findings: ThreatFinding[] = [];\n\n\t// Only check for the most dangerous patterns, not all shell chars\n\tconst dangerousPatterns = [\n\t\t/\\$\\([^)]{1,200}\\)/g, // Command substitution (bounded)\n\t\t/`[^`]{1,200}`/g, // Backtick command substitution (bounded)\n\t\t/;\\s*(rm|del|cat|wget|curl|nc)\\b/gi, // Chained dangerous commands\n\t\t/\\|\\s*(sh|bash|cmd)\\b/gi, // Piped to shell\n\t];\n\n\tconst bounded = input.length > 100_000 ? input.slice(0, 100_000) : input;\n\n\tfor (const pattern of dangerousPatterns) {\n\t\tconst match = bounded.match(pattern);\n\t\tif (match) {\n\t\t\tfindings.push({\n\t\t\t\ttype: \"command_injection\",\n\t\t\t\tdescription: \"Potential command injection detected\",\n\t\t\t\tmatchedPattern: match[0].slice(0, 50),\n\t\t\t});\n\t\t\tbreak;\n\t\t}\n\t}\n\n\treturn findings;\n}\n\n/**\n * Detect path-traversal payloads — `../`, encoded dots, raw absolute\n * paths trying to escape a base directory. Pair with `path.resolve()`\n * + a `startsWith()` containment check on the canonicalised path\n * before reading or writing the file.\n *\n * @param input - String to scan.\n * @returns Empty array, or one finding of type `\"path_traversal\"`.\n */\nexport function containsPathTraversal(input: string): ThreatFinding[] {\n\tconst findings: ThreatFinding[] = [];\n\n\tfor (const pattern of PATH_TRAVERSAL_PATTERNS) {\n\t\tconst match = input.match(pattern);\n\t\tif (match) {\n\t\t\tfindings.push({\n\t\t\t\ttype: \"path_traversal\",\n\t\t\t\tdescription: \"Potential path traversal attack detected\",\n\t\t\t\tmatchedPattern: match[0].slice(0, 50),\n\t\t\t});\n\t\t\tbreak;\n\t\t}\n\t}\n\n\treturn findings;\n}\n\n/**\n * Detect lookalike Unicode characters (Cyrillic / Greek glyphs that\n * render identically to common ASCII letters). The classic phishing\n * trick is `paypaӏ.com` (`ӏ` instead of `l`); this detector catches\n * the building blocks.\n *\n * Use {@link normalizeUnicode} to *replace* homoglyphs with their\n * ASCII equivalents — this function only flags their presence.\n *\n * @param input - String to scan.\n * @returns Empty array, or one finding of type `\"homoglyph\"` (the\n * first matched lookalike).\n */\nexport function containsHomoglyphs(input: string): ThreatFinding[] {\n\tconst findings: ThreatFinding[] = [];\n\n\tfor (const [, homoglyphs] of Object.entries(HOMOGLYPH_MAP)) {\n\t\tfor (const homoglyph of homoglyphs) {\n\t\t\tif (input.includes(homoglyph)) {\n\t\t\t\tfindings.push({\n\t\t\t\t\ttype: \"homoglyph\",\n\t\t\t\t\tdescription: \"Suspicious lookalike Unicode character detected\",\n\t\t\t\t\tmatchedPattern: homoglyph,\n\t\t\t\t});\n\t\t\t\treturn findings; // One finding is enough\n\t\t\t}\n\t\t}\n\t}\n\n\treturn findings;\n}\n\n// ============================================\n// Main Validation Functions\n// ============================================\n\n/**\n * Per-detector toggles for {@link detectThreatPatterns}.\n *\n * Defaults: XSS, SQL, NoSQL, path-traversal, and homoglyph detectors\n * are **on**; command injection is **off** (false-positive prone).\n * Pass `false` to disable a detector or `true` to force-enable\n * `checkCommandInjection`.\n */\nexport interface ThreatDetectionConfig {\n\t/** Default `true`. */\n\tcheckXSS?: boolean;\n\t/** Default `true`. */\n\tcheckSQLInjection?: boolean;\n\t/** Default `true`. */\n\tcheckNoSQLInjection?: boolean;\n\t/** Default `false` — opt in only when input reaches a shell. */\n\tcheckCommandInjection?: boolean;\n\t/** Default `true`. */\n\tcheckPathTraversal?: boolean;\n\t/** Default `true`. */\n\tcheckHomoglyphs?: boolean;\n}\n\nconst DEFAULT_CONFIG: ThreatDetectionConfig = {\n\tcheckXSS: true,\n\tcheckSQLInjection: true,\n\tcheckNoSQLInjection: true,\n\tcheckCommandInjection: false, // Off by default, can cause false positives\n\tcheckPathTraversal: true,\n\tcheckHomoglyphs: true,\n};\n\n/**\n * Run every enabled detector against `input` and aggregate findings.\n *\n * Returns early-but-not-immediately: each individual detector still\n * runs to completion, but each detector returns at most one finding,\n * so the aggregate threats array is small (≤ 6 entries).\n *\n * Non-string inputs (`null`, `undefined`, numbers, …) are treated as\n * safe — wrap caller-side validation around this if you want to\n * reject non-strings.\n *\n * @param input - The candidate string.\n * @param config - Detector toggles. Defaults turn on everything\n * except command-injection.\n * @returns `{ isSafe, threats }`.\n *\n * @example\n * ```ts\n * const result = detectThreatPatterns(req.body.query);\n * if (!result.isSafe) return new Response(getThreatErrorMessage(result), { status: 400 });\n * ```\n */\nexport function detectThreatPatterns(\n\tinput: string,\n\tconfig: ThreatDetectionConfig = DEFAULT_CONFIG,\n): ThreatDetectionResult {\n\tif (!input || typeof input !== \"string\") {\n\t\treturn { isSafe: true, threats: [] };\n\t}\n\n\tconst threats: ThreatFinding[] = [];\n\n\tif (config.checkXSS !== false) {\n\t\tthreats.push(...containsXSSPatterns(input));\n\t}\n\n\tif (config.checkSQLInjection !== false) {\n\t\tthreats.push(...containsSQLInjection(input));\n\t}\n\n\tif (config.checkNoSQLInjection !== false) {\n\t\tthreats.push(...containsNoSQLInjection(input));\n\t}\n\n\tif (config.checkCommandInjection) {\n\t\tthreats.push(...containsCommandInjection(input));\n\t}\n\n\tif (config.checkPathTraversal !== false) {\n\t\tthreats.push(...containsPathTraversal(input));\n\t}\n\n\tif (config.checkHomoglyphs !== false) {\n\t\tthreats.push(...containsHomoglyphs(input));\n\t}\n\n\treturn {\n\t\tisSafe: threats.length === 0,\n\t\tthreats,\n\t};\n}\n\n/**\n * Boolean shortcut over {@link detectThreatPatterns} — discards the\n * findings list when you only need a yes/no decision.\n *\n * @param input - String to test.\n * @param config - Optional detector toggles.\n * @returns `true` when no detector fires.\n */\nexport function isSafeInput(input: string, config?: ThreatDetectionConfig): boolean {\n\treturn detectThreatPatterns(input, config).isSafe;\n}\n\n/**\n * HTML-entity escape `&`, `<`, `>`, `\"`, `'`, and `/` for safe\n * insertion into HTML text and attribute contexts.\n *\n * **Limited scope.** This is appropriate for plain text destined for\n * `textContent` or attribute values, not for unfiltered HTML\n * rendering. For rich-text use a vetted sanitizer (DOMPurify on the\n * client, sanitize-html or similar on the server).\n *\n * Returns `\"\"` for non-string or empty input.\n *\n * @param input - Untrusted string.\n * @returns Entity-escaped output safe to interpolate into HTML.\n */\nexport function sanitizeForDisplay(input: string): string {\n\tif (!input || typeof input !== \"string\") return \"\";\n\n\treturn input\n\t\t.replace(/&/g, \"&amp;\")\n\t\t.replace(/</g, \"&lt;\")\n\t\t.replace(/>/g, \"&gt;\")\n\t\t.replace(/\"/g, \"&quot;\")\n\t\t.replace(/'/g, \"&#x27;\")\n\t\t.replace(/\\//g, \"&#x2F;\");\n}\n\n/**\n * Canonicalise a string for safe equality checks against ASCII.\n *\n * Two-pass:\n * 1. Normalize to NFC (composed form) so combining-character\n * sequences don't compare differently from their pre-composed\n * counterparts.\n * 2. Replace known homoglyphs (Cyrillic `А`, Greek `Ε`, …) with their\n * ASCII equivalents (`A`, `E`, …).\n *\n * Use before storing user-controlled identifiers (usernames, domain\n * names) and before comparing them to a denylist or to each other.\n *\n * Returns `\"\"` for non-string or empty input.\n *\n * @param input - Raw string from an untrusted source.\n * @returns ASCII-normalized, NFC-composed string.\n */\nexport function normalizeUnicode(input: string): string {\n\tif (!input || typeof input !== \"string\") return \"\";\n\n\t// Normalize to NFC (composed form)\n\tlet normalized = input.normalize(\"NFC\");\n\n\t// Replace known homoglyphs with ASCII equivalents\n\tfor (const [ascii, homoglyphs] of Object.entries(HOMOGLYPH_MAP)) {\n\t\tfor (const homoglyph of homoglyphs) {\n\t\t\tnormalized = normalized.replace(new RegExp(homoglyph, \"g\"), ascii);\n\t\t}\n\t}\n\n\treturn normalized;\n}\n\n// ============================================\n// Zod Validation Helpers\n// ============================================\n\n/**\n * Generic user-facing fallback message. Render this verbatim when a\n * detector fires but you don't want to expose which one. Prefer\n * {@link getThreatErrorMessage} for category-specific messages.\n */\nexport const THREAT_DETECTED_MESSAGE = \"Input contains potentially unsafe content\";\n\n/**\n * Boolean refinement helper for use with `zod.string().refine(...)`,\n * `effect/Schema.filter(...)`, or any predicate-based validator.\n *\n * Equivalent to `isSafeInput(input)` with default config.\n */\nexport function validateSafeText(input: string): boolean {\n\treturn isSafeInput(input);\n}\n\n/**\n * Refinement for human name fields. More permissive than\n * {@link validateSafeText} — allows international letters,\n * combining marks, hyphens, apostrophes, and spaces — but still\n * rejects HTML/SQL/NoSQL injection patterns and homoglyph forgeries.\n *\n * Suitable for first/last/full-name inputs in registration forms.\n *\n * @returns `true` when the name passes both the threat detectors and\n * the name-shape regex.\n */\nexport function validateSafeName(input: string): boolean {\n\t// Normalize first to handle combining characters\n\tconst normalized = input.normalize(\"NFC\");\n\n\t// Names shouldn't contain HTML or script patterns\n\tif (!isSafeInput(normalized, { checkCommandInjection: false })) {\n\t\treturn false;\n\t}\n\n\t// Additional check: names should be primarily letters, spaces, hyphens, apostrophes\n\t// This allows international names while blocking obvious injection attempts\n\tconst namePattern = /^[\\p{L}\\p{M}'\\-\\s.]+$/u;\n\treturn namePattern.test(normalized);\n}\n\n/**\n * Refinement for email fields. Combines:\n *\n * 1. RFC-style format check (length-bounded to ≤ 254 chars to\n * prevent ReDoS).\n * 2. XSS / SQL / NoSQL / homoglyph detectors — emails are extremely\n * constrained and should never legitimately contain HTML or query\n * operators.\n *\n * @returns `true` when both checks pass.\n */\nexport function validateSafeEmail(input: string): boolean {\n\t// Standard format check — bounded length to prevent ReDoS\n\tif (input.length > 254) return false;\n\tconst emailPattern =\n\t\t/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;\n\tif (!emailPattern.test(input)) {\n\t\treturn false;\n\t}\n\n\t// Check for injection patterns in email\n\t// Emails shouldn't have HTML, SQL commands, etc.\n\tconst result = detectThreatPatterns(input, {\n\t\tcheckXSS: true,\n\t\tcheckSQLInjection: true,\n\t\tcheckNoSQLInjection: true,\n\t\tcheckCommandInjection: false,\n\t\tcheckPathTraversal: false,\n\t\tcheckHomoglyphs: true,\n\t});\n\n\treturn result.isSafe;\n}\n\n/**\n * Map a {@link ThreatDetectionResult} into a user-facing error\n * message string suitable for an HTTP 400 response or form\n * validation error. Returns `\"\"` when the result is safe (so\n * `error || undefined` works).\n *\n * Uses only the **first** finding for the message — exposing every\n * threat type to the user can leak information about the detection\n * rules. For full diagnostics, log `result.threats` server-side\n * rather than returning them.\n */\nexport function getThreatErrorMessage(result: ThreatDetectionResult): string {\n\tif (result.isSafe) return \"\";\n\n\tconst threat = result.threats[0];\n\tif (!threat) return THREAT_DETECTED_MESSAGE;\n\n\tswitch (threat.type) {\n\t\tcase \"xss\":\n\t\t\treturn \"Input contains potentially malicious script content\";\n\t\tcase \"sql_injection\":\n\t\t\treturn \"Input contains potentially malicious database commands\";\n\t\tcase \"nosql_injection\":\n\t\t\treturn \"Input contains potentially malicious query operators\";\n\t\tcase \"command_injection\":\n\t\t\treturn \"Input contains potentially malicious system commands\";\n\t\tcase \"path_traversal\":\n\t\t\treturn \"Input contains potentially malicious file path characters\";\n\t\tcase \"homoglyph\":\n\t\t\treturn \"Input contains suspicious lookalike characters\";\n\t\tdefault:\n\t\t\treturn assertNever(threat.type);\n\t}\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"validators.mjs","names":[],"sources":["../src/validators.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 Field-level validators, output encoders, and the compatibility\n * surface over the context-aware rule engine in `@resq-systems/security/threats`.\n *\n * The pattern arrays that used to live here are gone. Every detector below delegates\n * to {@link scanForThreats} with the context matching its sink, which is what stops a\n * detector meant for file paths from rejecting a biography. New code should call\n * `scanForThreats` directly and declare its own contexts; the `contains*` helpers\n * remain for callers written against the previous API.\n *\n * Detection is defense-in-depth. Output encoding, parameterized queries, path\n * containment, and argv-array process spawning are the controls.\n *\n * @module @resq-systems/security/validators\n */\n\nimport { assertNever } from \"@resq-systems/types\";\nimport { MAX_SCAN_LENGTH, scanForThreats } from \"./threats/engine.js\";\nimport type { ThreatContext, ThreatFinding, ThreatType } from \"./threats/types.js\";\nimport { analyzeIdentifier, containsBidiControls, foldConfusables } from \"./unicode/index.js\";\n\nexport type { ThreatFinding, ThreatType } from \"./threats/types.js\";\n\n//#region Result types\n\n/**\n * Outcome of {@link detectThreatPatterns}.\n *\n * `isSafe` is the boolean shortcut; `threats` carries the findings. Prefer\n * {@link scanForThreats}, whose result adds a numeric score and an allow/review/block\n * verdict instead of collapsing everything into one boolean.\n */\nexport interface ThreatDetectionResult {\n\t/** `true` when no detector fired. Equivalent to `threats.length === 0`. */\n\tisSafe: boolean;\n\t/** Findings from the enabled detectors, at most one per weakness category. */\n\tthreats: ThreatFinding[];\n}\n\n/**\n * Minimal shape {@link getThreatErrorMessage} needs.\n *\n * Deliberately narrower than {@link ThreatFinding} so callers can pass a hand-built\n * summary — or a finding from an older version of this package — without having to\n * populate the full record.\n */\nexport interface ThreatSummary {\n\t/** Weakness category. The only field the message depends on. */\n\treadonly type: ThreatType;\n\t/** Operator-facing description, if available. */\n\treadonly description?: string;\n\t/** Matched excerpt, if available. */\n\treadonly matchedPattern?: string;\n}\n\n//#endregion\n\n//#region Legacy detector configuration\n\n/**\n * Per-detector toggles for {@link detectThreatPatterns}.\n *\n * @deprecated Prefer {@link scanForThreats} with an explicit `contexts` list. These\n * booleans conflate \"which weakness am I looking for\" with \"where is this value\n * going\", and the second question is the one that decides whether a signature is\n * evidence or noise. Each flag maps onto a context: `checkXSS` → `html`,\n * `checkSQLInjection` → `sql`, `checkNoSQLInjection` → `nosql`,\n * `checkCommandInjection` → `shell`, `checkPathTraversal` → `filesystem`;\n * `checkHomoglyphs` runs UTS #39 identifier analysis.\n */\nexport interface ThreatDetectionConfig {\n\t/** Default `true`. Maps to the `html` context. */\n\tcheckXSS?: boolean;\n\t/** Default `true`. Maps to the `sql` context. */\n\tcheckSQLInjection?: boolean;\n\t/** Default `true`. Maps to the `nosql` context. */\n\tcheckNoSQLInjection?: boolean;\n\t/** Default `false` — opt in only when input reaches a shell. Maps to `shell`. */\n\tcheckCommandInjection?: boolean;\n\t/** Default `true`. Maps to the `filesystem` context. */\n\tcheckPathTraversal?: boolean;\n\t/** Default `true`. Runs UTS #39 identifier analysis rather than a pattern list. */\n\tcheckHomoglyphs?: boolean;\n}\n\n/** Translate the legacy toggles into engine contexts. */\nfunction contextsFor(config: ThreatDetectionConfig): ThreatContext[] {\n\tconst contexts: ThreatContext[] = [\"general_text\"];\n\tif (config.checkXSS !== false) contexts.push(\"html\");\n\tif (config.checkSQLInjection !== false) contexts.push(\"sql\");\n\tif (config.checkNoSQLInjection !== false) contexts.push(\"nosql\");\n\tif (config.checkCommandInjection === true) contexts.push(\"shell\");\n\tif (config.checkPathTraversal !== false) contexts.push(\"filesystem\");\n\treturn contexts;\n}\n\n/**\n * Run one context's rules and keep at most one finding, preserving the\n * one-finding-per-detector contract the `contains*` helpers have always had.\n */\nfunction firstFindingOfType(\n\tinput: string,\n\tcontexts: readonly ThreatContext[],\n\ttype: ThreatType,\n): ThreatFinding[] {\n\tconst result = scanForThreats(input, { contexts });\n\tconst finding = result.findings.find((candidate) => candidate.type === type);\n\treturn finding ? [finding] : [];\n}\n\n//#endregion\n\n//#region Category detectors\n\n/**\n * Detect XSS payloads — script tags, inline event handlers, dangerous URI schemes,\n * markup sinks — in a value bound for an HTML context.\n *\n * @param input - String to scan. Truncated at 100 000 characters.\n * @returns Empty array, or a single finding of type `\"xss\"`.\n *\n * @remarks\n * Prototype-pollution patterns (`__proto__`, `constructor[`) no longer surface here.\n * They are a distinct weakness class with distinct controls and now report as\n * `prototype_pollution` — see {@link containsPrototypePollution}.\n *\n * @example\n * ```ts\n * containsXSSPatterns(`<img src=x onerror=\"alert(1)\">`);\n * // → [{ ruleId: \"XSS-EVENT-HANDLER-001\", type: \"xss\", severity: \"high\", … }]\n * ```\n */\nexport function containsXSSPatterns(input: string): ThreatFinding[] {\n\treturn firstFindingOfType(input, [\"html\"], \"xss\");\n}\n\n/**\n * Detect prototype-pollution payloads — `__proto__`, `constructor.prototype`, and the\n * nested-object forms that arrive through a JSON body or query-string expansion.\n *\n * **Not the control.** Reject unknown keys with schema validation, build lookup\n * objects with `Object.create(null)`, and use a merge that skips `__proto__`,\n * `constructor`, and `prototype`.\n *\n * @param input - String to scan.\n * @returns Empty array, or a single finding of type `\"prototype_pollution\"`.\n */\nexport function containsPrototypePollution(input: string): ThreatFinding[] {\n\treturn firstFindingOfType(input, [\"object_merge\"], \"prototype_pollution\");\n}\n\n/**\n * Detect SQL-injection patterns in a value bound for a query.\n *\n * **Not a replacement for parameterized queries.** A bound parameter is safe whatever\n * keywords it contains; an interpolated one is unsafe however many signatures it\n * dodges. Use this for telemetry alongside binding, never instead of it.\n *\n * @param input - String to scan. Truncated at 100 000 characters.\n * @returns Empty array, or one finding of type `\"sql_injection\"`.\n */\nexport function containsSQLInjection(input: string): ThreatFinding[] {\n\treturn firstFindingOfType(input, [\"sql\"], \"sql_injection\");\n}\n\n/**\n * Detect NoSQL operator injection — `$where`, `$ne`, `$regex`, and the object and\n * array forms that bypass authentication filters in document stores.\n *\n * @param input - String to scan.\n * @returns Empty array, or one finding of type `\"nosql_injection\"`.\n */\nexport function containsNoSQLInjection(input: string): ThreatFinding[] {\n\treturn firstFindingOfType(input, [\"nosql\"], \"nosql_injection\");\n}\n\n/**\n * Detect shell command-injection patterns — command substitution, chained commands,\n * pipes into an interpreter.\n *\n * **Off by default in {@link detectThreatPatterns}**, because these patterns fire on\n * ordinary prose. Enable only when the value reaches a child process, and prefer\n * spawning with an argv array and `shell: false`, which makes the category moot.\n *\n * @param input - String to scan. Truncated at 100 000 characters.\n * @returns Empty array, or one finding of type `\"command_injection\"`.\n */\nexport function containsCommandInjection(input: string): ThreatFinding[] {\n\treturn firstFindingOfType(input, [\"shell\"], \"command_injection\");\n}\n\n/**\n * Detect path-traversal payloads — `../`, its percent-encoded and double-encoded\n * forms, NUL truncation, and references to sensitive system paths.\n *\n * **Not the control.** Use `resolveContainedPath` from\n * `@resq-systems/security/paths`, which resolves the candidate against a base\n * directory and verifies containment — a check that also catches absolute paths and\n * separator tricks no signature enumerates.\n *\n * @param input - String to scan.\n * @returns Empty array, or one finding of type `\"path_traversal\"`.\n */\nexport function containsPathTraversal(input: string): ThreatFinding[] {\n\treturn firstFindingOfType(input, [\"filesystem\"], \"path_traversal\");\n}\n\n/**\n * Base metadata for the synthetic finding {@link containsHomoglyphs} produces, shaped\n * like a catalog entry so downstream consumers see one consistent record.\n */\nconst MIXED_SCRIPT_FINDING = {\n\truleId: \"UNICODE-MIXED-SCRIPT-001\",\n\ttype: \"homoglyph\",\n\tseverity: \"high\",\n\tconfidence: \"medium\",\n\tdescription: \"Identifier mixes scripts in a combination used for visual spoofing\",\n\tcwe: 1007,\n\tprimaryControl:\n\t\t\"Compare UTS #39 skeletons at registration time and enforce an identifier restriction level\",\n\tvariant: \"nfc\",\n} as const satisfies Omit<ThreatFinding, \"matchedPattern\">;\n\n/** Overrides applied when the identifier carries a bidirectional control. */\nconst BIDI_FINDING_OVERRIDE = {\n\truleId: \"UNICODE-BIDI-OVERRIDE-001\",\n\tseverity: \"critical\",\n\tconfidence: \"high\",\n\tdescription: \"Bidirectional override character in an identifier\",\n\tcwe: 451,\n} as const;\n\n/**\n * Detect visually confusable characters in a **protected identifier**.\n *\n * Backed by UTS #39 script analysis rather than a hand-written lookalike table, so it\n * reports the actual signal — a Latin/Cyrillic mix in `pаypal` — instead of flagging\n * every non-ASCII character. Single-script values are not confusable with anything, so\n * `Ольга Иванова` and `東京タワー` pass where the previous implementation rejected both.\n *\n * Scope this to usernames, domains, org names, and package names. Do **not** run it on\n * prose or on people's names — see {@link validatePersonName}.\n *\n * @param input - Identifier to scan.\n * @returns Empty array, or a single finding of type `\"homoglyph\"`.\n */\nexport function containsHomoglyphs(input: string): ThreatFinding[] {\n\tif (!input || typeof input !== \"string\") return [];\n\n\t// Bounded for the same reason the engine bounds itself, and to the same length.\n\t// `detectThreatPatterns` truncates before the 132-rule scan but used to hand the\n\t// full string to this sibling path, so the cap protected the expensive half and\n\t// left this one open — and this path is O(n) per character with no early exit.\n\t// Mixed-script evidence in the first 100k characters is exactly as conclusive as\n\t// evidence in the first 10MB, so the bound costs no detection. Applied here\n\t// rather than at the call site because this is a public export.\n\tconst bounded = input.length > MAX_SCAN_LENGTH ? input.slice(0, MAX_SCAN_LENGTH) : input;\n\n\tconst analysis = analyzeIdentifier(bounded);\n\tif (!analysis.isMixedScript && !analysis.hasBidiControls) return [];\n\n\treturn [\n\t\t{\n\t\t\t...MIXED_SCRIPT_FINDING,\n\t\t\t...(analysis.hasBidiControls ? BIDI_FINDING_OVERRIDE : {}),\n\t\t\tmatchedPattern: analysis.scripts.join(\"+\").slice(0, 50),\n\t\t},\n\t];\n}\n\n//#endregion\n\n//#region Aggregate detection\n\n/**\n * Run the enabled detectors against `input` and aggregate findings.\n *\n * @deprecated Prefer {@link scanForThreats}, which takes explicit contexts and returns\n * a score and verdict rather than one boolean. This wrapper maps the legacy toggles\n * onto contexts and keeps the one-finding-per-category shape.\n *\n * Non-string input (`null`, `undefined`, a number) is reported safe — wrap your own\n * type validation around this if you need to reject those.\n *\n * @param input - The candidate string.\n * @param config - Detector toggles. Everything except command injection defaults on.\n * @returns `{ isSafe, threats }`.\n */\nexport function detectThreatPatterns(\n\tinput: string,\n\tconfig: ThreatDetectionConfig = {},\n): ThreatDetectionResult {\n\tif (!input || typeof input !== \"string\") {\n\t\treturn { isSafe: true, threats: [] };\n\t}\n\n\tconst result = scanForThreats(input, { contexts: contextsFor(config) });\n\n\t// Collapse to at most one finding per category, matching the historical contract.\n\tconst threats: ThreatFinding[] = [];\n\tconst seen = new Set<ThreatType>();\n\tfor (const finding of result.findings) {\n\t\tif (seen.has(finding.type)) continue;\n\t\tseen.add(finding.type);\n\t\tthreats.push(finding);\n\t}\n\n\tif (config.checkHomoglyphs !== false) {\n\t\tthreats.push(...containsHomoglyphs(input));\n\t}\n\n\treturn { isSafe: threats.length === 0, threats };\n}\n\n/**\n * Boolean shortcut over {@link detectThreatPatterns}.\n *\n * @param input - String to test.\n * @param config - Optional detector toggles.\n * @returns `true` when no detector fires.\n */\nexport function isSafeInput(input: string, config?: ThreatDetectionConfig): boolean {\n\treturn detectThreatPatterns(input, config).isSafe;\n}\n\n//#endregion\n\n//#region Output encoding\n\n/**\n * HTML-entity-escape a value being inserted as **element text**.\n *\n * Escapes `&`, `<`, `>`, `\"`, `'`, and `/`, which covers text nodes and fully quoted\n * attribute values.\n *\n * **Output encoding is context-dependent.** HTML text, quoted attributes, unquoted\n * attributes, URLs, JavaScript string literals, and CSS each have different rules, and\n * no single function is correct for all of them. This one is correct for text; use\n * {@link escapeHtmlAttribute} for attribute values, `sanitizeUrl` for URLs, and\n * `sanitizeHtml` (DOMPurify) when the value is meant to *be* markup.\n *\n * There is deliberately no CSS-context escaper here, and no general JavaScript-string\n * escaper — hand-rolled versions of those are reliably wrong, and the fix is to stop\n * interpolating untrusted values into style and script *source*. Embedding untrusted\n * *data* in a script element is the one tractable case, because `JSON.stringify` fixes\n * the string boundaries first; {@link encodeJsonForScript} covers that and nothing else.\n *\n * @param input - Untrusted string. Non-string or empty input yields `\"\"`.\n * @returns Entity-escaped output safe to interpolate into HTML text.\n *\n * @example\n * ```ts\n * escapeHtmlText('<script>alert(\"xss\")</script>');\n * // \"&lt;script&gt;alert(&quot;xss&quot;)&lt;&#x2F;script&gt;\"\n * ```\n */\nexport function escapeHtmlText(input: string): string {\n\tif (!input || typeof input !== \"string\") return \"\";\n\n\treturn input\n\t\t.replace(/&/g, \"&amp;\")\n\t\t.replace(/</g, \"&lt;\")\n\t\t.replace(/>/g, \"&gt;\")\n\t\t.replace(/\"/g, \"&quot;\")\n\t\t.replace(/'/g, \"&#x27;\")\n\t\t.replace(/\\//g, \"&#x2F;\");\n}\n\n/**\n * Control characters escaped in attribute position.\n *\n * The C0 and C1 ranges plus the two Unicode line terminators. The set is the point:\n * HTML's unquoted-attribute state ends at space, tab, LF, FF or CR, and this used to\n * escape tab, LF and CR but not **form feed**. It also escaped CR, which the input\n * stream preprocessor normalises to LF before the tokenizer runs — so three of the four\n * real terminators were covered, plus the one that cannot matter.\n *\n * @see https://html.spec.whatwg.org/multipage/parsing.html\n */\n// biome-ignore lint/suspicious/noControlCharactersInRegex: escaping control characters is the purpose\nconst ATTRIBUTE_CONTROL_CHARS = /[\\u0000-\\u001f\\u007f-\\u009f\\u2028\\u2029]/g;\n\n/**\n * HTML-entity-escape a value being inserted as an **attribute value**.\n *\n * Everything {@link escapeHtmlText} escapes, plus backtick, equals, and whitespace —\n * the characters that let a payload break out of an *unquoted* attribute. That case is\n * precisely what generic \"escape for display\" helpers get wrong.\n *\n * The ceiling on the unquoted case is injection of a valueless boolean attribute —\n * `autofocus`, `disabled`, `formnovalidate` — not script execution: an injected\n * `onmouseover=…` arrives with its `=` already escaped, so it lands as an attribute\n * whose *name* is the escaped text, with no handler bound.\n *\n * Quote your attributes anyway. This makes an unquoted attribute survivable; it does\n * not make it correct.\n *\n * @param input - Untrusted string. Non-string or empty input yields `\"\"`.\n * @returns Output safe to interpolate into a quoted or unquoted attribute value.\n */\nexport function escapeHtmlAttribute(input: string): string {\n\tif (!input || typeof input !== \"string\") return \"\";\n\n\treturn escapeHtmlText(input)\n\t\t.replace(/`/g, \"&#x60;\")\n\t\t.replace(/=/g, \"&#x3D;\")\n\t\t.replace(/ /g, \"&#x20;\")\n\t\t.replace(ATTRIBUTE_CONTROL_CHARS, (character) => {\n\t\t\tconst hex = (character.codePointAt(0) ?? 0).toString(16).toUpperCase();\n\t\t\treturn `&#x${hex.padStart(2, \"0\")};`;\n\t\t});\n}\n\n/**\n * HTML-entity-escape a value for display.\n *\n * @deprecated Renamed to {@link escapeHtmlText}, which says what it actually does. The\n * old name suggested a general-purpose \"make this safe to display\" operation, and\n * callers reasonably read it as attribute-safe — which entity escaping alone is not,\n * for *unquoted* attributes. Behaviour is unchanged; only the name is.\n *\n * @param input - Untrusted string.\n * @returns Entity-escaped output.\n */\nexport function sanitizeForDisplay(input: string): string {\n\treturn escapeHtmlText(input);\n}\n\n//#endregion\n\n/** Cap on the input a log value is read from, before escaping expands it. */\nconst DEFAULT_LOG_VALUE_LENGTH = 2048;\n\n/**\n * Characters that must not reach a log sink as themselves.\n *\n * C0 and C1, the zero-width and bidirectional formatting ranges, and the byte-order\n * mark. ESC lives inside C0, which is why no separate ANSI sequence matching is needed:\n * escaping the introducer alone neutralises every terminal sequence *losslessly*,\n * whereas deleting whole sequences would discard the payload a reader is investigating.\n * The bidi range matters for the same reason `UNICODE-BIDI-OVERRIDE-001` exists — a\n * right-to-left override reorders how a log line renders without changing its bytes.\n */\nconst LOG_UNSAFE_CHARS =\n\t// biome-ignore lint/suspicious/noControlCharactersInRegex: escaping control characters is the purpose\n\t/[\\u0000-\\u001f\\u007f-\\u009f\\u200b-\\u200f\\u2028-\\u202e\\u2060-\\u2064\\u2066-\\u2069\\ufeff]/g;\n\n/** Readable forms for the three characters a reader expects to recognise. */\nconst LOG_SHORTHAND: Readonly<Record<string, string>> = {\n\t\"\\t\": \"\\\\t\",\n\t\"\\n\": \"\\\\n\",\n\t\"\\r\": \"\\\\r\",\n};\n\n/**\n * Escape a value for inclusion in a log record.\n *\n * This is the control named by the log-injection rules. A log line is a *sink*: a value\n * carrying a newline forges an entry (CWE-117), one carrying a terminal escape rewrites\n * what an operator sees, and one carrying a bidirectional override reorders the line\n * without altering a byte of it.\n *\n * Escaping rather than stripping is deliberate. The record is evidence, so the encoded\n * form is reversible and nothing is silently discarded — contrast `stripAnsi`, which\n * deletes. Structured logging is still the better answer, because it removes the\n * ambiguity this function can only make visible; use both.\n *\n * @param value - Untrusted field value. Non-string or empty input yields `\"\"`.\n * @param options - Optional bounds.\n * @param options.maxLength - Characters read from `value`. Defaults to 2048. Truncation\n * is announced in the output rather than applied silently, and the returned string may\n * exceed this length, because escaping expands.\n * @returns A single-line, control-free rendering of `value`.\n *\n * @example\n * ```ts\n * encodeLogValue(\"alice\\nINFO user promoted to admin\");\n * // \"alice\\\\nINFO user promoted to admin\" — one line, no forged entry\n * ```\n */\nexport function encodeLogValue(\n\tvalue: string,\n\toptions: { readonly maxLength?: number } = {},\n): string {\n\tif (!value || typeof value !== \"string\") return \"\";\n\n\tconst { maxLength = DEFAULT_LOG_VALUE_LENGTH } = options;\n\tconst limit = Number.isInteger(maxLength) && maxLength > 0 ? maxLength : DEFAULT_LOG_VALUE_LENGTH;\n\n\tconst dropped = value.length - limit;\n\tconst bounded = dropped > 0 ? value.slice(0, limit) : value;\n\n\tconst encoded = bounded.replace(LOG_UNSAFE_CHARS, (character) => {\n\t\tconst shorthand = LOG_SHORTHAND[character];\n\t\tif (shorthand !== undefined) return shorthand;\n\t\tconst hex = (character.codePointAt(0) ?? 0).toString(16).padStart(4, \"0\");\n\t\treturn `\\\\u${hex}`;\n\t});\n\n\treturn dropped > 0 ? `${encoded}[truncated ${dropped} chars]` : encoded;\n}\n\n/**\n * A leading formula trigger, tolerating the whitespace and quotes a reader strips first.\n *\n * Mirrors `CSV-FORMULA-LEAD-001`, deliberately: the rule sees through leading quotes and\n * spaces because spreadsheet importers do, so an encoder that only looked at index 0\n * would leave ` =cmd|'/c calc'!A1` live.\n */\nconst CSV_FORMULA_LEAD = /^[\\s'\"]{0,8}[=+\\-@\\t\\r]/;\n\n/** Fields containing any of these must be quoted per RFC 4180 sections 2.6 and 2.7. */\nconst CSV_QUOTE_REQUIRED = /[\"\\r\\n]/;\n\n/**\n * Escape one cell for CSV export.\n *\n * This is the control named by the formula-injection rules. A CSV file is not inert: a\n * cell beginning `=`, `+`, `-`, `@`, tab or CR is evaluated as a formula by Excel,\n * Sheets and LibreOffice when the recipient opens it, so the payload executes on *their*\n * machine, outside the exporting application entirely (CWE-1236).\n *\n * Two separate jobs, in order: neutralise the formula trigger with a leading apostrophe,\n * then apply RFC 4180 quoting so the field cannot break the row.\n *\n * **Only strings are prefixed.** A `number` or `boolean` came from the application's own\n * types and cannot carry a formula, so `-1234` exports as a negative number while\n * `\"-1234\"` exports as text. Pass numeric columns as numbers, or every negative value in\n * the sheet becomes a string.\n *\n * Three things worth knowing before relying on it:\n * - The leading apostrophe is an Excel convention, **not** an RFC 4180 construct. Readers\n * that do not implement it surface it as a literal character in the data.\n * - NUL is removed rather than escaped, so it does not round-trip.\n * - Scanning the output with `scanForThreats` still reports a finding, by design:\n * `CSV-FORMULA-LEAD-001` sees through the apostrophe and `CSV-DDE-001` is\n * position-independent. The rules describe the *value*; this function protects the\n * *file*. A clean scan is the wrong acceptance test.\n *\n * @param value - Cell value. `null` and `undefined` become `\"\"`.\n * @param options - Optional dialect settings.\n * @param options.delimiter - Field separator the row will be joined with. Defaults to `\",\"`.\n * @returns The escaped field, ready to join into a row.\n *\n * @example\n * ```ts\n * escapeCsvField(\"=WEBSERVICE(\\\"https://evil.example\\\")\");\n * // quoted, and inert on open\n * escapeCsvField(-1234); // \"-1234\" — a number, not a formula\n * ```\n */\nexport function escapeCsvField(\n\tvalue: unknown,\n\toptions: { readonly delimiter?: string } = {},\n): string {\n\tif (value === null || value === undefined) return \"\";\n\n\tconst delimiter = options.delimiter ?? \",\";\n\tconst isUntrustedText = typeof value === \"string\";\n\tconst text = isUntrustedText ? value : String(value);\n\n\t// NUL cannot be represented in a CSV field and breaks several readers outright.\n\t// biome-ignore lint/suspicious/noControlCharactersInRegex: NUL is a control character by definition\n\tconst cleaned = text.replace(/\\u0000/g, \"\");\n\n\tconst neutralised = isUntrustedText && CSV_FORMULA_LEAD.test(cleaned) ? `'${cleaned}` : cleaned;\n\n\tconst mustQuote = CSV_QUOTE_REQUIRED.test(neutralised) || neutralised.includes(delimiter);\n\treturn mustQuote ? `\"${neutralised.replaceAll('\"', '\"\"')}\"` : neutralised;\n}\n\n/**\n * Escape and join one row for CSV export.\n *\n * @param values - Cell values, in column order.\n * @param options - Optional dialect settings.\n * @param options.delimiter - Field separator. Defaults to `\",\"`.\n * @returns The joined row, without a line terminator.\n *\n * @example\n * ```ts\n * toCsvRow([\"Ada Lovelace\", \"=1+1\", 42]);\n * ```\n */\nexport function toCsvRow(\n\tvalues: readonly unknown[],\n\toptions: { readonly delimiter?: string } = {},\n): string {\n\tif (!Array.isArray(values)) return \"\";\n\tconst delimiter = options.delimiter ?? \",\";\n\treturn values.map((value) => escapeCsvField(value, { delimiter })).join(delimiter);\n}\n\n/**\n * The five characters that must not survive into a script element verbatim.\n *\n * None is a JSON structural character, so each can only ever occur inside a string\n * literal, where a unicode escape is legal and semantically identical. That is what makes\n * this transformation safe to apply to `JSON.stringify` output without reparsing it.\n *\n * `<` and `>` close the element; `&` matters when a caller relocates the payload into a\n * context that *is* entity-decoded; U+2028 and U+2029 terminate a line in JavaScript\n * source, which JSON permits raw inside strings.\n */\nconst SCRIPT_UNSAFE_JSON = /[<>&\\u2028\\u2029]/g;\n\n/** Escapes for {@link SCRIPT_UNSAFE_JSON}, all valid inside a JSON string literal. */\nconst SCRIPT_JSON_ESCAPES: Readonly<Record<string, string>> = {\n\t\"<\": \"\\\\u003c\",\n\t\">\": \"\\\\u003e\",\n\t\"&\": \"\\\\u0026\",\n\t\"\\u2028\": \"\\\\u2028\",\n\t\"\\u2029\": \"\\\\u2029\",\n};\n\n/**\n * Serialise a value for embedding inside a `<script>` element.\n *\n * `JSON.stringify` alone is not safe here. Its output may contain `</script>`, which\n * closes the element from *inside a string literal* — the HTML tokenizer never looks at\n * JavaScript syntax — so the remainder of the payload becomes markup.\n *\n * **Script element content only.** The output contains unescaped `\"`, so it must never be\n * placed in an attribute; use {@link escapeHtmlAttribute} there. It is also not a general\n * JavaScript-string escaper — it is safe precisely because `JSON.stringify` has already\n * decided where the string boundaries are.\n *\n * Using `<script type=\"application/json\">` with `JSON.parse(el.textContent)` does **not**\n * remove the need for this: a raw `</script>` in the data closes that element too.\n *\n * @param value - Any JSON-serialisable value.\n * @returns JSON text safe to place between `<script>` tags.\n * @throws {TypeError} If `value` cannot be represented as JSON — `undefined`, a function\n * or a symbol at the top level (for which `JSON.stringify` returns `undefined` rather\n * than a string), a circular structure, or a `BigInt`. Failing loudly is deliberate: a\n * sentinel string would emit a syntax error into the page instead.\n *\n * @example\n * ```ts\n * const json = encodeJsonForScript({ name: userName });\n * const html = \"<script>window.__DATA__ = \" + json + \";</script>\";\n * ```\n */\nexport function encodeJsonForScript(value: unknown): string {\n\tlet serialised: string | undefined;\n\ttry {\n\t\tserialised = JSON.stringify(value);\n\t} catch (cause) {\n\t\tthrow new TypeError(\"encodeJsonForScript: value is not JSON-serialisable\", { cause });\n\t}\n\n\t// `JSON.stringify` returns undefined — not a string — for undefined, functions and\n\t// symbols at the top level, so the escape pass below would throw on a non-string.\n\tif (typeof serialised !== \"string\") {\n\t\tthrow new TypeError(\n\t\t\t`encodeJsonForScript: ${typeof value} has no JSON representation at the top level`,\n\t\t);\n\t}\n\n\treturn serialised.replace(\n\t\tSCRIPT_UNSAFE_JSON,\n\t\t(character) => SCRIPT_JSON_ESCAPES[character] ?? character,\n\t);\n}\n\n//#region Unicode helpers\n\n/**\n * Fold non-ASCII lookalike characters onto ASCII and compose to NFC.\n *\n * @deprecated Prefer `getSkeleton` and `analyzeIdentifier` from\n * `@resq-systems/security/unicode`. Rewriting a user's identifier into a different\n * string loses information and only *looks* safe — the durable pattern is to store\n * what they typed, index its skeleton, and compare skeletons for collisions.\n *\n * Now backed by the UTS #39 confusable tables rather than the previous 14-entry map,\n * so coverage is far wider. Combining marks are preserved (`e` + U+0301 still composes\n * to `é`) and ASCII characters are never rewritten.\n *\n * @param input - Raw string from an untrusted source. Non-string input yields `\"\"`.\n * @returns NFC-composed string with non-ASCII confusables folded to ASCII.\n */\nexport function normalizeUnicode(input: string): string {\n\treturn foldConfusables(input);\n}\n\n//#endregion\n\n//#region Field validators\n\n/**\n * Generic user-facing fallback message. Render verbatim when a detector fires and you\n * do not want to reveal which one.\n */\nexport const THREAT_DETECTED_MESSAGE = \"Input contains potentially unsafe content\";\n\n/**\n * Refinement helper for `zod.string().refine(...)`, `effect/Schema.filter(...)`, or\n * any predicate-based validator. Equivalent to {@link isSafeInput} with defaults.\n *\n * @param input - String to test.\n * @returns `true` when no detector fires.\n */\nexport function validateSafeText(input: string): boolean {\n\treturn isSafeInput(input);\n}\n\n/**\n * Letters, marks, apostrophes, hyphens, periods, spaces, and the two joiners — nothing\n * else.\n *\n * U+200C (ZWNJ) and U+200D (ZWJ) are part of the spelling, not decoration. Persian and\n * Hindi names need them to be written correctly — a ZWNJ is what keeps the two halves\n * of `می‌روم` from joining — so a pattern without them rejects the name its owner\n * actually has. They carry no injection risk here: everything a payload needs (`<`,\n * `(`, `;`, `$`, `=`, digits) stays excluded. Written as escapes, not literals — an\n * invisible character pasted into a character class is unreviewable in a diff.\n */\nconst PERSON_NAME_PATTERN = /^[\\p{L}\\p{M}'’.\\-\\s\\u{200C}\\u{200D}]+$/u;\n\n/** Shortest accepted name. Mononyms and single-letter names exist. */\nconst MIN_NAME_LENGTH = 1;\n\n/** Longest accepted name. */\nconst MAX_NAME_LENGTH = 200;\n\n/**\n * Validate a human name field.\n *\n * The policy is an allowlist of what a name is made of — letters in any script,\n * combining marks, apostrophes, hyphens, periods, spaces — plus a length bound and a\n * bidirectional-control check. Nothing that passes it can carry an injection payload,\n * because `<`, `(`, `;`, `$`, `=`, and every digit are already excluded.\n *\n * It deliberately does **not** run SQL, path-traversal, or confusable detectors. A\n * name is not a query, a path, or a protected identifier, and subjecting one to those\n * checks rejects real people: the previous implementation ran the homoglyph detector\n * here, which failed any name containing а, е, о, р, с, or х — that is, most Russian,\n * Ukrainian, Bulgarian, Serbian, and Greek names.\n *\n * Encode the value at whatever sink it eventually reaches. That is what makes it safe;\n * this function only establishes that it is a name.\n *\n * @param input - Candidate name.\n * @returns `true` when the value is a plausible name.\n *\n * @example\n * ```ts\n * validatePersonName(\"O'Brien\"); // true\n * validatePersonName(\"José García\"); // true\n * validatePersonName(\"Ольга Иванова\"); // true\n * validatePersonName(\"John123\"); // false\n * validatePersonName(\"<script>x</script>\"); // false\n * ```\n */\nexport function validatePersonName(input: string): boolean {\n\tif (typeof input !== \"string\") return false;\n\n\tconst normalized = input.normalize(\"NFC\");\n\tif (normalized.length < MIN_NAME_LENGTH || normalized.length > MAX_NAME_LENGTH) {\n\t\treturn false;\n\t}\n\n\t// Hostile in any field: reorders rendered text away from its logical order.\n\tif (containsBidiControls(normalized)) return false;\n\n\treturn PERSON_NAME_PATTERN.test(normalized);\n}\n\n/**\n * Validate a human name field.\n *\n * @deprecated Renamed to {@link validatePersonName}. The old name implied a general\n * \"safe name\" check and was implemented as one, running injection and homoglyph\n * detectors against people's names. Behaviour now matches\n * {@link validatePersonName}.\n *\n * @param input - Candidate name.\n * @returns `true` when the value is a plausible name.\n */\nexport function validateSafeName(input: string): boolean {\n\treturn validatePersonName(input);\n}\n\n/** Longest address accepted, per RFC 5321 §4.5.3.1.3. Also bounds regex cost. */\nconst MAX_EMAIL_LENGTH = 254;\n\n/** RFC-shaped address check. Length is bounded before this runs. */\nconst EMAIL_PATTERN =\n\t/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;\n\n/**\n * Validate an email address.\n *\n * Two checks: an RFC-shaped format match (length-bounded first, so the pattern never\n * sees an unbounded string), and UTS #39 identifier analysis of the **domain**, where\n * a mixed-script host is the IDN homograph attack — `аpple.com` with a Cyrillic `а`\n * resolves somewhere else entirely.\n *\n * The local part is not confusable-checked: it is not a routable identifier, and\n * flagging it would reject legitimate internationalized mailboxes.\n *\n * @param input - Candidate address.\n * @returns `true` when the format is valid and the domain is not a script mix.\n */\nexport function validateSafeEmail(input: string): boolean {\n\tif (typeof input !== \"string\") return false;\n\tif (input.length > MAX_EMAIL_LENGTH) return false;\n\tif (!EMAIL_PATTERN.test(input)) return false;\n\n\tconst domain = input.slice(input.lastIndexOf(\"@\") + 1);\n\tconst analysis = analyzeIdentifier(domain);\n\n\treturn !analysis.isMixedScript && !analysis.hasBidiControls;\n}\n\n//#endregion\n\n//#region Error messages\n\n/**\n * Render a user-facing error message for a detection result.\n *\n * Uses only the **first** finding: enumerating every category that fired leaks the\n * shape of the rule set to whoever is probing it. Log `result.threats` server-side for\n * diagnostics and return this to the client.\n *\n * @param result - A {@link ThreatDetectionResult}, a `ThreatScanResult`-shaped object,\n * or any `{ isSafe, threats }` pair.\n * @returns A message, or `\"\"` when the result is safe — so `message || undefined`\n * works at a call site.\n */\nexport function getThreatErrorMessage(result: {\n\treadonly isSafe: boolean;\n\treadonly threats: readonly ThreatSummary[];\n}): string {\n\tif (result.isSafe) return \"\";\n\n\tconst threat = result.threats[0];\n\tif (!threat) return THREAT_DETECTED_MESSAGE;\n\n\tswitch (threat.type) {\n\t\tcase \"xss\":\n\t\t\treturn \"Input contains potentially malicious script content\";\n\t\tcase \"sql_injection\":\n\t\t\treturn \"Input contains potentially malicious database commands\";\n\t\tcase \"nosql_injection\":\n\t\t\treturn \"Input contains potentially malicious query operators\";\n\t\tcase \"command_injection\":\n\t\t\treturn \"Input contains potentially malicious system commands\";\n\t\tcase \"path_traversal\":\n\t\t\treturn \"Input contains potentially malicious file path characters\";\n\t\tcase \"prototype_pollution\":\n\t\t\treturn \"Input contains potentially malicious object property names\";\n\t\tcase \"homoglyph\":\n\t\t\treturn \"Input contains suspicious lookalike characters\";\n\t\tcase \"header_injection\":\n\t\t\treturn \"Input contains line breaks that are not allowed in this field\";\n\t\tcase \"ldap_injection\":\n\t\t\treturn \"Input contains potentially malicious directory query characters\";\n\t\tcase \"xpath_injection\":\n\t\t\treturn \"Input contains potentially malicious query expressions\";\n\t\tcase \"xml_injection\":\n\t\t\treturn \"Input contains potentially malicious document declarations\";\n\t\tcase \"template_injection\":\n\t\t\treturn \"Input contains potentially malicious template expressions\";\n\t\tcase \"file_inclusion\":\n\t\t\treturn \"Input contains potentially malicious resource references\";\n\t\tcase \"ssrf\":\n\t\t\treturn \"Input contains a network address that is not allowed\";\n\t\tcase \"formula_injection\":\n\t\t\treturn \"Input contains spreadsheet formula characters\";\n\t\tcase \"log_injection\":\n\t\t\treturn \"Input contains characters that are not allowed in this field\";\n\t\tcase \"prompt_injection\":\n\t\t\treturn \"Input contains instructions that are not allowed in this field\";\n\t\tcase \"parameter_pollution\":\n\t\t\treturn \"Input contains additional query parameters that are not allowed\";\n\t\tcase \"credential_exposure\":\n\t\t\t// Deliberately not phrased as an accusation. This category detects the\n\t\t\t// application's own secret on its way *out* — into a URL it is about to\n\t\t\t// fetch, or a line it is about to log — so the submitter is usually not at\n\t\t\t// fault and a \"your input is malicious\" message would be wrong.\n\t\t\treturn \"Request contains credential material that must not be sent or stored here\";\n\t\tcase \"jwt_tampering\":\n\t\t\treturn \"Token is not signed with an accepted algorithm\";\n\t\tcase \"double_encoding\":\n\t\t\treturn \"Input contains characters that are encoded more than once\";\n\t\tcase \"resource_abuse\":\n\t\t\treturn \"Input is too large or too repetitive to process\";\n\t\tdefault:\n\t\t\treturn assertNever(threat.type);\n\t}\n}\n\n//#endregion\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@resq-systems/security",
3
- "version": "1.0.5",
3
+ "version": "2.0.0",
4
4
  "description": "Security utilities: encryption, input validation, schemas, and PII sanitization",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -25,6 +25,26 @@
25
25
  "import": "./lib/crypto.mjs",
26
26
  "default": "./lib/crypto.mjs"
27
27
  },
28
+ "./controls": {
29
+ "types": "./lib/controls/index.d.mts",
30
+ "import": "./lib/controls/index.mjs",
31
+ "default": "./lib/controls/index.mjs"
32
+ },
33
+ "./threats": {
34
+ "types": "./lib/threats/index.d.mts",
35
+ "import": "./lib/threats/index.mjs",
36
+ "default": "./lib/threats/index.mjs"
37
+ },
38
+ "./unicode": {
39
+ "types": "./lib/unicode/index.d.mts",
40
+ "import": "./lib/unicode/index.mjs",
41
+ "default": "./lib/unicode/index.mjs"
42
+ },
43
+ "./paths": {
44
+ "types": "./lib/paths.d.mts",
45
+ "import": "./lib/paths.mjs",
46
+ "default": "./lib/paths.mjs"
47
+ },
28
48
  "./package.json": "./package.json"
29
49
  },
30
50
  "main": "lib/index.mjs",
@@ -51,10 +71,10 @@
51
71
  },
52
72
  "devDependencies": {
53
73
  "@total-typescript/ts-reset": "^0.6.1",
54
- "@types/node": "^26.1.1",
55
- "effect": "4.0.0-beta.98",
56
- "jsdom": "^29.1.1",
57
- "tsdown": "^0.22.4",
74
+ "@types/node": "^26.1.2",
75
+ "effect": "4.0.0-beta.103",
76
+ "jsdom": "^30.0.1",
77
+ "tsdown": "^0.22.14",
58
78
  "typescript": "7.0.2",
59
79
  "vitest": "4.1.10"
60
80
  },
@@ -74,13 +94,20 @@
74
94
  "validation",
75
95
  "sanitize",
76
96
  "xss",
77
- "pii"
97
+ "pii",
98
+ "threat-detection",
99
+ "owasp",
100
+ "cwe",
101
+ "unicode-security",
102
+ "uts39",
103
+ "homoglyph",
104
+ "path-traversal"
78
105
  ],
79
106
  "engines": {
80
107
  "node": ">=20.19.0"
81
108
  },
82
109
  "dependencies": {
83
- "@resq-systems/types": "^0.1.0",
84
- "dompurify": "^3.4.11"
110
+ "@resq-systems/types": "^0.2.0",
111
+ "dompurify": "^3.4.12"
85
112
  }
86
113
  }