@resq-systems/security 2.1.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 +8 -8
  17. package/lib/controls/query.d.mts.map +1 -1
  18. package/lib/controls/query.mjs +1 -0
  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":"types.mjs","names":[],"sources":["../../src/threats/types.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 Vocabulary for the threat rule engine — weakness categories, sink\n * contexts, severity/confidence grades, and the {@link ThreatRule} /\n * {@link ThreatFinding} shapes that carry them. Everything here is data-only so the\n * rule catalog, the scanner, and the scoring policy can be reasoned about (and\n * unit-tested) independently.\n *\n * Design note: rules are *telemetry and defense-in-depth*, not the primary control.\n * The primary control for each category is the sink-appropriate one — parameterized\n * queries for SQL, a parser-based sanitizer for HTML, canonicalize-and-contain for\n * filesystem paths, argv-array process spawning for shells. Each rule names its own\n * in {@link ThreatRule.primaryControl}.\n *\n * @module @resq-systems/security/threats/types\n */\n\n//#region Categories\n\n/**\n * The closed set of weakness categories the engine recognizes.\n *\n * Serves as the discriminant of {@link ThreatFinding} and drives the exhaustive\n * `switch` in `getThreatErrorMessage` — adding a variant here without a matching\n * `case` there is a compile error via `assertNever`.\n *\n * Categories follow the OWASP Web Security Testing Guide's input-validation chapter\n * so findings map onto an established taxonomy rather than package-local names.\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| \"prototype_pollution\"\n\t| \"homoglyph\"\n\t| \"header_injection\"\n\t| \"ldap_injection\"\n\t| \"xpath_injection\"\n\t| \"xml_injection\"\n\t| \"template_injection\"\n\t| \"file_inclusion\"\n\t| \"ssrf\"\n\t| \"formula_injection\"\n\t| \"log_injection\"\n\t| \"prompt_injection\"\n\t| \"parameter_pollution\"\n\t| \"credential_exposure\"\n\t| \"jwt_tampering\"\n\t| \"double_encoding\"\n\t| \"resource_abuse\";\n\n/**\n * The sink a value is destined for. Rules declare which contexts they apply to, and\n * the scanner only evaluates rules matching the caller's declared contexts.\n *\n * This is the single most important false-positive control in the package: a\n * biography containing `C:\\Windows`, a support ticket containing `1=1`, and a code\n * snippet containing `eval(` are all perfectly legitimate `general_text`. They are\n * only suspicious when the value actually reaches a filesystem, SQL, or HTML sink.\n *\n * - `general_text` — free-form prose. Only near-universally hostile signals apply\n * (invisible/bidirectional control characters, null bytes).\n * - `html` — rendered as HTML or interpolated into markup.\n * - `sql` / `nosql` — reaches a database query builder or driver.\n * - `shell` — reaches a child process, especially with `shell: true`.\n * - `filesystem` — becomes part of a path passed to `fs`.\n * - `url` — fetched server-side, or used to build such a request.\n * - `url_parameter` — a *single value* about to be concatenated into a query string.\n * Distinct from `url`, where `&name=` is the normal grammar rather than evidence of\n * an injected parameter — the same distinction `http_header` draws against a whole\n * request.\n * - `http_header` — written into an HTTP or email header value.\n * - `jwt` — a JSON Web Token, or an already-decoded JWT header. Scoped narrowly on\n * purpose: re-scanning a whole opaque token with every detector is the\n * run-everything-against-everything anti-pattern this package exists to avoid. To\n * check a `kid` or `jku` claim, extract it and declare *its* real sink\n * (`filesystem`, `sql`, `url`).\n * - `identifier` — a username, domain, org name, package name, or brand-adjacent\n * label where confusable-glyph collisions matter.\n * - `object_merge` — parsed into an object graph that is then merged, cloned, or\n * assigned property-by-property (`Object.assign`, `lodash.merge`, query-string\n * expansion). This is prototype pollution's actual sink.\n * - `template` — concatenated into template *source* (as opposed to passed as data).\n * - `xml` — parsed by an XML/SOAP/SVG parser.\n * - `ldap` / `xpath` — becomes part of an LDAP filter/DN or an XPath expression.\n * - `spreadsheet` — exported to CSV/XLSX and opened by a spreadsheet application.\n * - `log` — written to a line-based or structured log sink.\n * - `llm_prompt` — concatenated into an LLM prompt or reachable by a tool-using agent.\n */\nexport type ThreatContext =\n\t| \"general_text\"\n\t| \"html\"\n\t| \"sql\"\n\t| \"nosql\"\n\t| \"shell\"\n\t| \"filesystem\"\n\t| \"url\"\n\t| \"url_parameter\"\n\t| \"http_header\"\n\t| \"jwt\"\n\t| \"identifier\"\n\t| \"object_merge\"\n\t| \"template\"\n\t| \"xml\"\n\t| \"ldap\"\n\t| \"xpath\"\n\t| \"spreadsheet\"\n\t| \"log\"\n\t| \"llm_prompt\";\n\n/** Every {@link ThreatContext} value, for iteration and \"scan every sink\" callers. */\nexport const ALL_THREAT_CONTEXTS = [\n\t\"general_text\",\n\t\"html\",\n\t\"sql\",\n\t\"nosql\",\n\t\"shell\",\n\t\"filesystem\",\n\t\"url\",\n\t\"url_parameter\",\n\t\"http_header\",\n\t\"jwt\",\n\t\"identifier\",\n\t\"object_merge\",\n\t\"template\",\n\t\"xml\",\n\t\"ldap\",\n\t\"xpath\",\n\t\"spreadsheet\",\n\t\"log\",\n\t\"llm_prompt\",\n] as const satisfies readonly ThreatContext[];\n\n//#endregion\n\n//#region Grading\n\n/**\n * How bad the weakness is if the match is a true positive. Independent of how\n * likely the match is to *be* a true positive — that is {@link ThreatConfidence}.\n */\nexport type ThreatSeverity = \"low\" | \"medium\" | \"high\" | \"critical\";\n\n/**\n * How likely a match is to be a real attack rather than benign content that\n * happens to look like one.\n *\n * - `low` — fires on ordinary content regularly (e.g. the substring `1=1`).\n * - `medium` — unusual in ordinary content but not impossible.\n * - `high` — has essentially no benign explanation in the declared context.\n */\nexport type ThreatConfidence = \"low\" | \"medium\" | \"high\";\n\n/** Base anomaly points contributed by a finding, before the confidence multiplier. */\nexport const SEVERITY_WEIGHTS: Readonly<Record<ThreatSeverity, number>> = {\n\tlow: 1,\n\tmedium: 2,\n\thigh: 4,\n\tcritical: 8,\n};\n\n/** Scales {@link SEVERITY_WEIGHTS} by how trustworthy the signature is. */\nexport const CONFIDENCE_MULTIPLIERS: Readonly<Record<ThreatConfidence, number>> = {\n\tlow: 0.5,\n\tmedium: 1,\n\thigh: 1.5,\n};\n\n/** Ordering helper for `minSeverity` filtering. Higher is worse. */\nexport const SEVERITY_ORDER: Readonly<Record<ThreatSeverity, number>> = {\n\tlow: 0,\n\tmedium: 1,\n\thigh: 2,\n\tcritical: 3,\n};\n\n//#endregion\n\n//#region Variants\n\n/**\n * Which representation of the input a rule matched against.\n *\n * Attack signatures are routinely bypassed by encoding, so the scanner evaluates a\n * small, bounded set of representations rather than the raw bytes alone. Findings\n * record which one matched, so operators can tell \"the request literally contained\n * `../`\" apart from \"the request contained `%2e%2e%2f`, which only becomes `../` if\n * a downstream component decodes it\".\n */\nexport type InputVariantKind = \"raw\" | \"nfc\" | \"nfkc\" | \"percent_decoded\" | \"html_decoded\";\n\n/** One representation of the scanned input. */\nexport interface InputVariant {\n\t/** Which transformation produced {@link InputVariant.value}. */\n\treadonly kind: InputVariantKind;\n\t/** The transformed string. */\n\treadonly value: string;\n}\n\n//#endregion\n\n//#region Rules and findings\n\n/**\n * A single signature in the catalog.\n *\n * @remarks\n * `pattern` **must not** carry the global (`g`) or sticky (`y`) flag. Those flags\n * make `RegExp` objects stateful via `lastIndex`, and the catalog is module-scoped\n * and shared across every call — a stateful rule would silently skip matches on\n * alternating invocations. `assertRuleCatalogIsValid` enforces this at load time.\n */\nexport interface ThreatRule {\n\t/** Stable identifier (e.g. `PATH-TRAVERSAL-ENCODED-001`). Never reused once retired. */\n\treadonly id: string;\n\t/** Weakness category this rule evidences. */\n\treadonly type: ThreatType;\n\t/** Sinks for which this rule is meaningful. Never empty. */\n\treadonly contexts: readonly ThreatContext[];\n\t/** Impact if the match is real. */\n\treadonly severity: ThreatSeverity;\n\t/** Likelihood the match is real rather than benign lookalike content. */\n\treadonly confidence: ThreatConfidence;\n\t/** One-line description for operators and log lines. Not user-facing. */\n\treadonly description: string;\n\t/** MITRE CWE identifier for the weakness, when one applies cleanly. */\n\treadonly cwe?: number;\n\t/**\n\t * The control that actually prevents this weakness. Signatures detect; they do\n\t * not prevent. Surfaced on findings so a reviewer reading an alert is pointed at\n\t * the fix rather than at a tighter regex.\n\t */\n\treadonly primaryControl: string;\n\t/** The signature. Must be non-global and non-sticky — see the remark above. */\n\treadonly pattern: RegExp;\n\t/**\n\t * Restrict this rule to specific input representations. Defaults to every\n\t * variant. Used by rules whose whole purpose is detecting an encoding (e.g. the\n\t * percent-encoded traversal rule, which is only meaningful against `raw`).\n\t */\n\treadonly variants?: readonly InputVariantKind[];\n}\n\n/** A rule that matched, plus where and in which representation. */\nexport interface ThreatFinding {\n\t/** {@link ThreatRule.id} of the rule that fired. */\n\treadonly ruleId: string;\n\t/** Copied from the rule — the weakness category. */\n\treadonly type: ThreatType;\n\t/** Copied from the rule. */\n\treadonly severity: ThreatSeverity;\n\t/** Copied from the rule. */\n\treadonly confidence: ThreatConfidence;\n\t/** Copied from the rule. Operator-facing; never render to end users. */\n\treadonly description: string;\n\t/** Copied from the rule, when present. */\n\treadonly cwe?: number;\n\t/** Copied from the rule — the control that actually fixes this. */\n\treadonly primaryControl: string;\n\t/** Which representation matched. */\n\treadonly variant: InputVariantKind;\n\t/** Matched substring, truncated to 50 characters so logs cannot be flooded. */\n\treadonly matchedPattern?: string;\n\t/** Match start offset *within the matched variant*, not within the raw input. */\n\treadonly start?: number;\n\t/** Match end offset (exclusive) within the matched variant. */\n\treadonly end?: number;\n}\n\n//#endregion\n\n//#region Event correlation\n\n/**\n * Where an untrusted value entered the application.\n *\n * The counterpart to {@link ThreatContext}, which names the *sink*. A context says where\n * a value is going and decides which rules run; a source says where it came from and\n * decides nothing — it is carried through so a finding can be attributed later.\n *\n * Both halves together are what makes a finding actionable: \"SQL keywords in a value\n * bound to a SQL query\" is a bug report, while \"SQL keywords arriving in a query\n * parameter from one account, forty times in a minute\" is an incident.\n */\nexport type InputSource =\n\t| \"http.query\"\n\t| \"http.body\"\n\t| \"http.header\"\n\t| \"http.path\"\n\t| \"http.cookie\"\n\t| \"websocket.message\"\n\t| \"cli.argument\"\n\t| \"file.upload\"\n\t| \"database\"\n\t| \"external.api\"\n\t| \"internal\";\n\n/**\n * Who and what a scan belongs to, carried through so findings can be correlated.\n *\n * Modelled on OWASP AppSensor, whose argument is that application-layer intrusion\n * detection is about *sequences* attributed to an actor, not about individual strings. A\n * single `review` verdict is ordinarily noise; thirty of them from one account inside a\n * minute is an attack in progress, and nothing in a per-string API can express the\n * difference.\n *\n * Every field is optional and none affects detection. The scanner does not resolve,\n * validate or store any of them — it echoes them onto the result so a logging or SIEM\n * pipeline can join findings to a request and an actor without threading a correlation id\n * through its own call stack.\n *\n * Treat `actorId` as personal data: it reaches whatever sink the findings reach, so pass\n * an opaque identifier rather than an email address, or redact downstream with\n * `sanitizeForLogging`.\n */\nexport interface EventContext {\n\t/** Correlates every finding raised while handling one request. */\n\treadonly requestId?: string;\n\t/** The authenticated principal, if any. Opaque — never an email address. */\n\treadonly actorId?: string;\n\t/** The session the request belongs to, for sequence detection across requests. */\n\treadonly sessionId?: string;\n\t/** Where the value entered the application. */\n\treadonly source?: InputSource;\n}\n\n//#endregion\n\n//#region Verdicts\n\n/**\n * Policy outcome derived from the anomaly score.\n *\n * Scoring rather than first-match rejection is deliberate: any individual signature\n * produces false positives, so a single low-confidence hit should raise a signal,\n * not reject a form submission. This mirrors how OWASP CRS uses anomaly scoring with\n * tunable thresholds instead of one-rule-one-block.\n */\nexport type ThreatVerdict = \"allow\" | \"review\" | \"block\";\n\n/** Score thresholds separating {@link ThreatVerdict} bands. */\nexport interface ThreatPolicy {\n\t/** Score at or above which the verdict becomes `review`. Default `4`. */\n\treadonly reviewAt: number;\n\t/** Score at or above which the verdict becomes `block`. Default `8`. */\n\treadonly blockAt: number;\n}\n\n/** Default thresholds: one high/high finding reviews, one critical finding blocks. */\nexport const DEFAULT_THREAT_POLICY: ThreatPolicy = {\n\treviewAt: 4,\n\tblockAt: 8,\n};\n\n//#endregion\n"],"mappings":";;AAgIA,MAAa,sBAAsB;CAClC;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACD;;AAuBA,MAAa,mBAA6D;CACzE,KAAK;CACL,QAAQ;CACR,MAAM;CACN,UAAU;AACX;;AAGA,MAAa,yBAAqE;CACjF,KAAK;CACL,QAAQ;CACR,MAAM;AACP;;AAGA,MAAa,iBAA2D;CACvE,KAAK;CACL,QAAQ;CACR,MAAM;CACN,UAAU;AACX;;AA+KA,MAAa,wBAAsC;CAClD,UAAU;CACV,SAAS;AACV"}
1
+ {"version":3,"file":"types.mjs","names":[],"sources":["../../src/threats/types.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 Vocabulary for the threat rule engine — weakness categories, sink\n * contexts, severity/confidence grades, and the {@link ThreatRule} /\n * {@link ThreatFinding} shapes that carry them. Everything here is data-only so the\n * rule catalog, the scanner, and the scoring policy can be reasoned about (and\n * unit-tested) independently.\n *\n * Design note: rules are *telemetry and defense-in-depth*, not the primary control.\n * The primary control for each category is the sink-appropriate one — parameterized\n * queries for SQL, a parser-based sanitizer for HTML, canonicalize-and-contain for\n * filesystem paths, argv-array process spawning for shells. Each rule names its own\n * in {@link ThreatRule.primaryControl}.\n *\n * @module @resq-systems/security/threats/types\n */\n\n//#region Categories\n\n/**\n * The closed set of weakness categories the engine recognizes.\n *\n * Serves as the discriminant of {@link ThreatFinding} and drives the exhaustive\n * `switch` in `getThreatErrorMessage` — adding a variant here without a matching\n * `case` there is a compile error via `assertNever`.\n *\n * Categories follow the OWASP Web Security Testing Guide's input-validation chapter\n * so findings map onto an established taxonomy rather than package-local names.\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| \"prototype_pollution\"\n\t| \"homoglyph\"\n\t| \"header_injection\"\n\t| \"ldap_injection\"\n\t| \"xpath_injection\"\n\t| \"xml_injection\"\n\t| \"template_injection\"\n\t| \"file_inclusion\"\n\t| \"ssrf\"\n\t| \"formula_injection\"\n\t| \"log_injection\"\n\t| \"prompt_injection\"\n\t| \"parameter_pollution\"\n\t| \"credential_exposure\"\n\t| \"jwt_tampering\"\n\t| \"double_encoding\"\n\t| \"resource_abuse\";\n\n/**\n * The sink a value is destined for. Rules declare which contexts they apply to, and\n * the scanner only evaluates rules matching the caller's declared contexts.\n *\n * This is the single most important false-positive control in the package: a\n * biography containing `C:\\Windows`, a support ticket containing `1=1`, and a code\n * snippet containing `eval(` are all perfectly legitimate `general_text`. They are\n * only suspicious when the value actually reaches a filesystem, SQL, or HTML sink.\n *\n * - `general_text` — free-form prose. Only near-universally hostile signals apply\n * (invisible/bidirectional control characters, null bytes).\n * - `html` — rendered as HTML or interpolated into markup.\n * - `sql` / `nosql` — reaches a database query builder or driver.\n * - `shell` — reaches a child process, especially with `shell: true`.\n * - `filesystem` — becomes part of a path passed to `fs`.\n * - `url` — fetched server-side, or used to build such a request.\n * - `url_parameter` — a *single value* about to be concatenated into a query string.\n * Distinct from `url`, where `&name=` is the normal grammar rather than evidence of\n * an injected parameter — the same distinction `http_header` draws against a whole\n * request.\n * - `http_header` — written into an HTTP or email header value.\n * - `jwt` — a JSON Web Token, or an already-decoded JWT header. Scoped narrowly on\n * purpose: re-scanning a whole opaque token with every detector is the\n * run-everything-against-everything anti-pattern this package exists to avoid. To\n * check a `kid` or `jku` claim, extract it and declare *its* real sink\n * (`filesystem`, `sql`, `url`).\n * - `identifier` — a username, domain, org name, package name, or brand-adjacent\n * label where confusable-glyph collisions matter.\n * - `object_merge` — parsed into an object graph that is then merged, cloned, or\n * assigned property-by-property (`Object.assign`, `lodash.merge`, query-string\n * expansion). This is prototype pollution's actual sink.\n * - `template` — concatenated into template *source* (as opposed to passed as data).\n * - `xml` — parsed by an XML/SOAP/SVG parser.\n * - `ldap` / `xpath` — becomes part of an LDAP filter/DN or an XPath expression.\n * - `spreadsheet` — exported to CSV/XLSX and opened by a spreadsheet application.\n * - `log` — written to a line-based or structured log sink.\n * - `llm_prompt` — concatenated into an LLM prompt or reachable by a tool-using agent.\n */\nexport type ThreatContext =\n\t| \"general_text\"\n\t| \"html\"\n\t| \"sql\"\n\t| \"nosql\"\n\t| \"shell\"\n\t| \"filesystem\"\n\t| \"url\"\n\t| \"url_parameter\"\n\t| \"http_header\"\n\t| \"jwt\"\n\t| \"identifier\"\n\t| \"object_merge\"\n\t| \"template\"\n\t| \"xml\"\n\t| \"ldap\"\n\t| \"xpath\"\n\t| \"spreadsheet\"\n\t| \"log\"\n\t| \"llm_prompt\";\n\n/** Every {@link ThreatContext} value, for iteration and \"scan every sink\" callers. */\nexport const ALL_THREAT_CONTEXTS = [\n\t\"general_text\",\n\t\"html\",\n\t\"sql\",\n\t\"nosql\",\n\t\"shell\",\n\t\"filesystem\",\n\t\"url\",\n\t\"url_parameter\",\n\t\"http_header\",\n\t\"jwt\",\n\t\"identifier\",\n\t\"object_merge\",\n\t\"template\",\n\t\"xml\",\n\t\"ldap\",\n\t\"xpath\",\n\t\"spreadsheet\",\n\t\"log\",\n\t\"llm_prompt\",\n] as const satisfies readonly ThreatContext[];\n\n//#endregion\n\n//#region Grading\n\n/**\n * How bad the weakness is if the match is a true positive. Independent of how\n * likely the match is to *be* a true positive — that is {@link ThreatConfidence}.\n */\nexport type ThreatSeverity = \"low\" | \"medium\" | \"high\" | \"critical\";\n\n/**\n * How likely a match is to be a real attack rather than benign content that\n * happens to look like one.\n *\n * - `low` — fires on ordinary content regularly (e.g. the substring `1=1`).\n * - `medium` — unusual in ordinary content but not impossible.\n * - `high` — has essentially no benign explanation in the declared context.\n */\nexport type ThreatConfidence = \"low\" | \"medium\" | \"high\";\n\n/** Base anomaly points contributed by a finding, before the confidence multiplier. */\nexport const SEVERITY_WEIGHTS: Readonly<Record<ThreatSeverity, number>> = {\n\tlow: 1,\n\tmedium: 2,\n\thigh: 4,\n\tcritical: 8,\n};\n\n/** Scales {@link SEVERITY_WEIGHTS} by how trustworthy the signature is. */\nexport const CONFIDENCE_MULTIPLIERS: Readonly<Record<ThreatConfidence, number>> = {\n\tlow: 0.5,\n\tmedium: 1,\n\thigh: 1.5,\n};\n\n/** Ordering helper for `minSeverity` filtering. Higher is worse. */\nexport const SEVERITY_ORDER: Readonly<Record<ThreatSeverity, number>> = {\n\tlow: 0,\n\tmedium: 1,\n\thigh: 2,\n\tcritical: 3,\n};\n\n//#endregion\n\n//#region Variants\n\n/**\n * Which representation of the input a rule matched against.\n *\n * Attack signatures are routinely bypassed by encoding, so the scanner evaluates a\n * small, bounded set of representations rather than the raw bytes alone. Findings\n * record which one matched, so operators can tell \"the request literally contained\n * `../`\" apart from \"the request contained `%2e%2e%2f`, which only becomes `../` if\n * a downstream component decodes it\".\n */\nexport type InputVariantKind = \"raw\" | \"nfc\" | \"nfkc\" | \"percent_decoded\" | \"html_decoded\";\n\n/** One representation of the scanned input. */\nexport interface InputVariant {\n\t/** Which transformation produced {@link InputVariant.value}. */\n\treadonly kind: InputVariantKind;\n\t/** The transformed string. */\n\treadonly value: string;\n}\n\n//#endregion\n\n//#region Rules and findings\n\n/**\n * A single signature in the catalog.\n *\n * @remarks\n * `pattern` **must not** carry the global (`g`) or sticky (`y`) flag. Those flags\n * make `RegExp` objects stateful via `lastIndex`, and the catalog is module-scoped\n * and shared across every call — a stateful rule would silently skip matches on\n * alternating invocations. `assertRuleCatalogIsValid` enforces this at load time.\n */\nexport interface ThreatRule {\n\t/** Stable identifier (e.g. `PATH-TRAVERSAL-ENCODED-001`). Never reused once retired. */\n\treadonly id: string;\n\t/** Weakness category this rule evidences. */\n\treadonly type: ThreatType;\n\t/** Sinks for which this rule is meaningful. Never empty. */\n\treadonly contexts: readonly ThreatContext[];\n\t/** Impact if the match is real. */\n\treadonly severity: ThreatSeverity;\n\t/** Likelihood the match is real rather than benign lookalike content. */\n\treadonly confidence: ThreatConfidence;\n\t/** One-line description for operators and log lines. Not user-facing. */\n\treadonly description: string;\n\t/** MITRE CWE identifier for the weakness, when one applies cleanly. */\n\treadonly cwe?: number;\n\t/**\n\t * The control that actually prevents this weakness. Signatures detect; they do\n\t * not prevent. Surfaced on findings so a reviewer reading an alert is pointed at\n\t * the fix rather than at a tighter regex.\n\t */\n\treadonly primaryControl: string;\n\t/** The signature. Must be non-global and non-sticky — see the remark above. */\n\treadonly pattern: RegExp;\n\t/**\n\t * Restrict this rule to specific input representations. Defaults to every\n\t * variant. Used by rules whose whole purpose is detecting an encoding (e.g. the\n\t * percent-encoded traversal rule, which is only meaningful against `raw`).\n\t */\n\treadonly variants?: readonly InputVariantKind[];\n}\n\n/** A rule that matched, plus where and in which representation. */\nexport interface ThreatFinding {\n\t/** {@link ThreatRule.id} of the rule that fired. */\n\treadonly ruleId: string;\n\t/** Copied from the rule — the weakness category. */\n\treadonly type: ThreatType;\n\t/** Copied from the rule. */\n\treadonly severity: ThreatSeverity;\n\t/** Copied from the rule. */\n\treadonly confidence: ThreatConfidence;\n\t/** Copied from the rule. Operator-facing; never render to end users. */\n\treadonly description: string;\n\t/** Copied from the rule, when present. */\n\treadonly cwe?: number;\n\t/** Copied from the rule — the control that actually fixes this. */\n\treadonly primaryControl: string;\n\t/** Which representation matched. */\n\treadonly variant: InputVariantKind;\n\t/** Matched substring, truncated to 50 characters so logs cannot be flooded. */\n\treadonly matchedPattern?: string;\n\t/** Match start offset *within the matched variant*, not within the raw input. */\n\treadonly start?: number;\n\t/** Match end offset (exclusive) within the matched variant. */\n\treadonly end?: number;\n}\n\n//#endregion\n\n//#region Event correlation\n\n/**\n * Where an untrusted value entered the application.\n *\n * The counterpart to {@link ThreatContext}, which names the *sink*. A context says where\n * a value is going and decides which rules run; a source says where it came from and\n * decides nothing — it is carried through so a finding can be attributed later.\n *\n * Both halves together are what makes a finding actionable: \"SQL keywords in a value\n * bound to a SQL query\" is a bug report, while \"SQL keywords arriving in a query\n * parameter from one account, forty times in a minute\" is an incident.\n */\nexport type InputSource =\n\t| \"http.query\"\n\t| \"http.body\"\n\t| \"http.header\"\n\t| \"http.path\"\n\t| \"http.cookie\"\n\t| \"websocket.message\"\n\t| \"cli.argument\"\n\t| \"file.upload\"\n\t| \"database\"\n\t| \"external.api\"\n\t| \"internal\";\n\n/**\n * Who and what a scan belongs to, carried through so findings can be correlated.\n *\n * Modelled on OWASP AppSensor, whose argument is that application-layer intrusion\n * detection is about *sequences* attributed to an actor, not about individual strings. A\n * single `review` verdict is ordinarily noise; thirty of them from one account inside a\n * minute is an attack in progress, and nothing in a per-string API can express the\n * difference.\n *\n * Every field is optional and none affects detection. The scanner does not resolve,\n * validate or store any of them — it echoes them onto the result so a logging or SIEM\n * pipeline can join findings to a request and an actor without threading a correlation id\n * through its own call stack.\n *\n * Treat `actorId` as personal data: it reaches whatever sink the findings reach, so pass\n * an opaque identifier rather than an email address, or redact downstream with\n * `sanitizeForLogging`.\n */\nexport interface EventContext {\n\t/** Correlates every finding raised while handling one request. */\n\treadonly requestId?: string;\n\t/** The authenticated principal, if any. Opaque — never an email address. */\n\treadonly actorId?: string;\n\t/** The session the request belongs to, for sequence detection across requests. */\n\treadonly sessionId?: string;\n\t/** Where the value entered the application. */\n\treadonly source?: InputSource;\n}\n\n//#endregion\n\n//#region Verdicts\n\n/**\n * Policy outcome derived from the anomaly score.\n *\n * Scoring rather than first-match rejection is deliberate: any individual signature\n * produces false positives, so a single low-confidence hit should raise a signal,\n * not reject a form submission. This mirrors how OWASP CRS uses anomaly scoring with\n * tunable thresholds instead of one-rule-one-block.\n */\nexport type ThreatVerdict = \"allow\" | \"review\" | \"block\";\n\n/** Score thresholds separating {@link ThreatVerdict} bands. */\nexport interface ThreatPolicy {\n\t/** Score at or above which the verdict becomes `review`. Default `4`. */\n\treadonly reviewAt: number;\n\t/** Score at or above which the verdict becomes `block`. Default `8`. */\n\treadonly blockAt: number;\n}\n\n/** Default thresholds: one high/high finding reviews, one critical finding blocks. */\nexport const DEFAULT_THREAT_POLICY: ThreatPolicy = {\n\treviewAt: 4,\n\tblockAt: 8,\n};\n\n//#endregion\n"],"mappings":";;AAiIA,MAAa,sBAAsB;CAClC;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACD;;AAuBA,MAAa,mBAA6D;CACzE,KAAK;CACL,QAAQ;CACR,MAAM;CACN,UAAU;AACX;;AAGA,MAAa,yBAAqE;CACjF,KAAK;CACL,QAAQ;CACR,MAAM;AACP;;AAGA,MAAa,iBAA2D;CACvE,KAAK;CACL,QAAQ;CACR,MAAM;CACN,UAAU;AACX;;AA+KA,MAAa,wBAAsC;CAClD,UAAU;CACV,SAAS;AACV"}
@@ -17,7 +17,7 @@ import { InputVariant } from "./types.mjs";
17
17
  * decodeHtmlEntities("&unknownref;"); // "&unknownref;"
18
18
  * ```
19
19
  */
20
- declare function decodeHtmlEntities(input: string): string;
20
+ export declare function decodeHtmlEntities(input: string): string;
21
21
  /**
22
22
  * Percent-decode a string, tolerating malformed sequences.
23
23
  *
@@ -30,7 +30,7 @@ declare function decodeHtmlEntities(input: string): string;
30
30
  * @returns The decoded string, or `null` when the input contains no `%` or is not
31
31
  * validly encoded.
32
32
  */
33
- declare function tryPercentDecode(input: string): string | null;
33
+ export declare function tryPercentDecode(input: string): string | null;
34
34
  /**
35
35
  * Build the representations to scan.
36
36
  *
@@ -51,7 +51,6 @@ declare function tryPercentDecode(input: string): string | null;
51
51
  * // [{ kind: "raw", value: "%2e%2e%2f" }, { kind: "percent_decoded", value: "../" }]
52
52
  * ```
53
53
  */
54
- declare function buildInputVariants(input: string): readonly InputVariant[];
54
+ export declare function buildInputVariants(input: string): readonly InputVariant[];
55
55
  //#endregion
56
- export { buildInputVariants, decodeHtmlEntities, tryPercentDecode };
57
56
  //# sourceMappingURL=variants.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"variants.d.mts","names":[],"sources":["../../src/threats/variants.ts"],"mappings":";;;;;;;;;;;;;;;;;;;iBAqGgB,mBAAmB;;;;;;;;;;;;;iBA4CnB,iBAAiB;;;;;;;;;;;;;;;;;;;;;iBAiCjB,mBAAmB,yBAAyB"}
1
+ {"version":3,"file":"variants.d.mts","names":[],"sources":["../../src/threats/variants.ts"],"mappings":";;;;;;;;;;;;;;;;;;;wBAsGgB,mBAAmB;;;;;;;;;;;;;wBA4CnB,iBAAiB;;;;;;;;;;;;;;;;;;;;;wBAiCjB,mBAAmB,yBAAyB"}
@@ -1 +1 @@
1
- {"version":3,"file":"variants.mjs","names":[],"sources":["../../src/threats/variants.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 Canonicalization variants — the small, bounded set of representations\n * the scanner evaluates so signatures are not trivially bypassed by encoding.\n *\n * The set is deliberately small and non-recursive. Decoding everything repeatedly\n * until it stops changing is its own bug class: it makes the scanner disagree with\n * the downstream component about what the value actually is, and it lets an attacker\n * pick whichever of several disagreeing interpretations suits them. Instead, each\n * variant is one transformation applied once, findings record which variant matched,\n * and payloads that only surface after multiple decodes get explicit rules such as\n * `PATH-TRAVERSAL-DOUBLE-ENCODED-001`.\n *\n * @module @resq-systems/security/threats/variants\n */\n\nimport type { InputVariant, InputVariantKind } from \"./types.js\";\n\n//#region HTML entity decoding\n\n/**\n * Named HTML entities worth decoding for detection purposes.\n *\n * Not a complete HTML5 entity table — there are roughly 2 200 of those — and not\n * trying to be. It covers the characters that carry syntactic meaning in the sinks\n * this package guards. Every other reference decodes to a character no rule looks\n * for, so leaving it encoded cannot hide an attack.\n */\nconst NAMED_ENTITIES: Readonly<Record<string, string>> = {\n\tamp: \"&\",\n\tlt: \"<\",\n\tgt: \">\",\n\tquot: '\"',\n\tapos: \"'\",\n\tnbsp: \" \",\n\tsol: \"/\",\n\tbsol: \"\\\\\",\n\tcolon: \":\",\n\tsemi: \";\",\n\tlpar: \"(\",\n\trpar: \")\",\n\tlbrack: \"[\",\n\trbrack: \"]\",\n\tlcub: \"{\",\n\trcub: \"}\",\n\texcl: \"!\",\n\tequals: \"=\",\n\tdollar: \"$\",\n\tcommat: \"@\",\n\tnum: \"#\",\n\tpercnt: \"%\",\n\tperiod: \".\",\n\tcomma: \",\",\n\tast: \"*\",\n\tverbar: \"|\",\n\tgrave: \"`\",\n\tTab: \"\\t\",\n\tNewLine: \"\\n\",\n};\n\n/** Matches a numeric (decimal or hex) or named reference, with an optional semicolon. */\nconst ENTITY_PATTERN = /&(?:#[xX]([0-9a-fA-F]{1,6})|#(\\d{1,7})|([a-zA-Z][a-zA-Z0-9]{1,31}));?/g;\n\n/**\n * Highest code point `String.fromCodePoint` accepts. Numeric references above this\n * are left as written rather than throwing.\n */\nconst MAX_CODE_POINT = 0x10ffff;\n\n/**\n * Decode HTML character references in a single pass.\n *\n * Handles `&#60;`, `&#x3c;`, and the named subset in {@link NAMED_ENTITIES}, with or\n * without the trailing semicolon — browsers accept several named references\n * unterminated and attackers rely on it. Unrecognized references are left verbatim;\n * this is best-effort detection support, not a rendering path.\n *\n * @param input - Raw string.\n * @returns The string with recognized references replaced.\n *\n * @example\n * ```ts\n * decodeHtmlEntities(\"&#60;script&#62;\"); // \"<script>\"\n * decodeHtmlEntities(\"&unknownref;\"); // \"&unknownref;\"\n * ```\n */\nexport function decodeHtmlEntities(input: string): string {\n\tif (!input.includes(\"&\")) return input;\n\n\t// `String.prototype.replace` resets a global pattern's lastIndex on entry, so the\n\t// module-scoped ENTITY_PATTERN carries no state between calls.\n\treturn input.replace(ENTITY_PATTERN, (match, hex?: string, dec?: string, name?: string) => {\n\t\tif (hex !== undefined) {\n\t\t\tconst code = Number.parseInt(hex, 16);\n\t\t\treturn code <= MAX_CODE_POINT ? String.fromCodePoint(code) : match;\n\t\t}\n\t\tif (dec !== undefined) {\n\t\t\tconst code = Number.parseInt(dec, 10);\n\t\t\treturn code <= MAX_CODE_POINT ? String.fromCodePoint(code) : match;\n\t\t}\n\t\tif (name !== undefined) {\n\t\t\t// Own-property check, not `?? match`: the name group matches `constructor`,\n\t\t\t// `toString`, `valueOf` and friends, which resolve up the prototype chain to a\n\t\t\t// function. `??` never fires for those, and `replace` then coerces the function\n\t\t\t// to its source text — `&constructor;` decoded to\n\t\t\t// `function Object() { [native code] }`, injecting braces and parens that trip\n\t\t\t// unrelated rules and corrupting the `html_decoded` variant for any input\n\t\t\t// carrying such a reference.\n\t\t\treturn Object.hasOwn(NAMED_ENTITIES, name) ? NAMED_ENTITIES[name] : match;\n\t\t}\n\t\treturn match;\n\t});\n}\n\n//#endregion\n\n//#region Percent decoding\n\n/**\n * Percent-decode a string, tolerating malformed sequences.\n *\n * `decodeURIComponent` throws `URIError` on an invalid escape (`%zz`, a lone `%`, a\n * truncated surrogate pair). That failure is itself telemetry — well-formed clients\n * do not emit it — so the caller gets `null` and the `raw` variant stays scannable\n * instead of the whole scan aborting.\n *\n * @param input - Possibly percent-encoded string.\n * @returns The decoded string, or `null` when the input contains no `%` or is not\n * validly encoded.\n */\nexport function tryPercentDecode(input: string): string | null {\n\tif (!input.includes(\"%\")) return null;\n\ttry {\n\t\treturn decodeURIComponent(input);\n\t} catch {\n\t\treturn null;\n\t}\n}\n\n//#endregion\n\n//#region Variant construction\n\n/**\n * Build the representations to scan.\n *\n * Always includes `raw`. Adds `nfc`, `percent_decoded`, and `html_decoded` only when\n * that transformation actually changes the string, so a plain ASCII value costs one\n * pass rather than four.\n *\n * The original input is never mutated or replaced — callers keep the raw value for\n * storage and display, and the variants exist solely so a rule can observe what the\n * value would become at a decoding sink.\n *\n * @param input - Raw untrusted string.\n * @returns One to four distinct representations, `raw` first.\n *\n * @example\n * ```ts\n * buildInputVariants(\"%2e%2e%2f\");\n * // [{ kind: \"raw\", value: \"%2e%2e%2f\" }, { kind: \"percent_decoded\", value: \"../\" }]\n * ```\n */\nexport function buildInputVariants(input: string): readonly InputVariant[] {\n\tconst variants: InputVariant[] = [{ kind: \"raw\", value: input }];\n\tconst seen = new Set<string>([input]);\n\n\tconst add = (kind: InputVariantKind, value: string): void => {\n\t\tif (seen.has(value)) return;\n\t\tseen.add(value);\n\t\tvariants.push({ kind, value });\n\t};\n\n\t// `normalize` throws only on an invalid form argument, never on content.\n\tadd(\"nfc\", input.normalize(\"NFC\"));\n\n\t// NFC is a documented no-op on compatibility characters, so fullwidth forms slipped\n\t// past every signature: `../../etc/passwd` and `<script>` both scanned clean.\n\t// NFKC folds U+FF01–FF5E onto ASCII, which is exactly the mapping a downstream\n\t// component performs when it normalizes before parsing. Kept as its own variant\n\t// rather than replacing `nfc`, because NFKC is lossy and must never be what the\n\t// caller stores.\n\tadd(\"nfkc\", input.normalize(\"NFKC\"));\n\n\tconst percentDecoded = tryPercentDecode(input);\n\tif (percentDecoded !== null) {\n\t\tadd(\"percent_decoded\", percentDecoded);\n\t}\n\n\tadd(\"html_decoded\", decodeHtmlEntities(input));\n\n\treturn variants;\n}\n\n//#endregion\n"],"mappings":";;;;;;;;;AA2CA,MAAM,iBAAmD;CACxD,KAAK;CACL,IAAI;CACJ,IAAI;CACJ,MAAM;CACN,MAAM;CACN,MAAM;CACN,KAAK;CACL,MAAM;CACN,OAAO;CACP,MAAM;CACN,MAAM;CACN,MAAM;CACN,QAAQ;CACR,QAAQ;CACR,MAAM;CACN,MAAM;CACN,MAAM;CACN,QAAQ;CACR,QAAQ;CACR,QAAQ;CACR,KAAK;CACL,QAAQ;CACR,QAAQ;CACR,OAAO;CACP,KAAK;CACL,QAAQ;CACR,OAAO;CACP,KAAK;CACL,SAAS;AACV;;AAGA,MAAM,iBAAiB;;;;;AAMvB,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;AAmBvB,SAAgB,mBAAmB,OAAuB;CACzD,IAAI,CAAC,MAAM,SAAS,GAAG,GAAG,OAAO;CAIjC,OAAO,MAAM,QAAQ,iBAAiB,OAAO,KAAc,KAAc,SAAkB;EAC1F,IAAI,QAAQ,KAAA,GAAW;GACtB,MAAM,OAAO,OAAO,SAAS,KAAK,EAAE;GACpC,OAAO,QAAQ,iBAAiB,OAAO,cAAc,IAAI,IAAI;EAC9D;EACA,IAAI,QAAQ,KAAA,GAAW;GACtB,MAAM,OAAO,OAAO,SAAS,KAAK,EAAE;GACpC,OAAO,QAAQ,iBAAiB,OAAO,cAAc,IAAI,IAAI;EAC9D;EACA,IAAI,SAAS,KAAA,GAQZ,OAAO,OAAO,OAAO,gBAAgB,IAAI,IAAI,eAAe,QAAQ;EAErE,OAAO;CACR,CAAC;AACF;;;;;;;;;;;;;AAkBA,SAAgB,iBAAiB,OAA8B;CAC9D,IAAI,CAAC,MAAM,SAAS,GAAG,GAAG,OAAO;CACjC,IAAI;EACH,OAAO,mBAAmB,KAAK;CAChC,QAAQ;EACP,OAAO;CACR;AACD;;;;;;;;;;;;;;;;;;;;;AA0BA,SAAgB,mBAAmB,OAAwC;CAC1E,MAAM,WAA2B,CAAC;EAAE,MAAM;EAAO,OAAO;CAAM,CAAC;CAC/D,MAAM,uBAAO,IAAI,IAAY,CAAC,KAAK,CAAC;CAEpC,MAAM,OAAO,MAAwB,UAAwB;EAC5D,IAAI,KAAK,IAAI,KAAK,GAAG;EACrB,KAAK,IAAI,KAAK;EACd,SAAS,KAAK;GAAE;GAAM;EAAM,CAAC;CAC9B;CAGA,IAAI,OAAO,MAAM,UAAU,KAAK,CAAC;CAQjC,IAAI,QAAQ,MAAM,UAAU,MAAM,CAAC;CAEnC,MAAM,iBAAiB,iBAAiB,KAAK;CAC7C,IAAI,mBAAmB,MACtB,IAAI,mBAAmB,cAAc;CAGtC,IAAI,gBAAgB,mBAAmB,KAAK,CAAC;CAE7C,OAAO;AACR"}
1
+ {"version":3,"file":"variants.mjs","names":[],"sources":["../../src/threats/variants.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 Canonicalization variants — the small, bounded set of representations\n * the scanner evaluates so signatures are not trivially bypassed by encoding.\n *\n * The set is deliberately small and non-recursive. Decoding everything repeatedly\n * until it stops changing is its own bug class: it makes the scanner disagree with\n * the downstream component about what the value actually is, and it lets an attacker\n * pick whichever of several disagreeing interpretations suits them. Instead, each\n * variant is one transformation applied once, findings record which variant matched,\n * and payloads that only surface after multiple decodes get explicit rules such as\n * `PATH-TRAVERSAL-DOUBLE-ENCODED-001`.\n *\n * @module @resq-systems/security/threats/variants\n */\n\nimport type { InputVariant, InputVariantKind } from \"./types.js\";\n\n//#region HTML entity decoding\n\n/**\n * Named HTML entities worth decoding for detection purposes.\n *\n * Not a complete HTML5 entity table — there are roughly 2 200 of those — and not\n * trying to be. It covers the characters that carry syntactic meaning in the sinks\n * this package guards. Every other reference decodes to a character no rule looks\n * for, so leaving it encoded cannot hide an attack.\n */\nconst NAMED_ENTITIES: Readonly<Record<string, string>> = {\n\tamp: \"&\",\n\tlt: \"<\",\n\tgt: \">\",\n\tquot: '\"',\n\tapos: \"'\",\n\tnbsp: \" \",\n\tsol: \"/\",\n\tbsol: \"\\\\\",\n\tcolon: \":\",\n\tsemi: \";\",\n\tlpar: \"(\",\n\trpar: \")\",\n\tlbrack: \"[\",\n\trbrack: \"]\",\n\tlcub: \"{\",\n\trcub: \"}\",\n\texcl: \"!\",\n\tequals: \"=\",\n\tdollar: \"$\",\n\tcommat: \"@\",\n\tnum: \"#\",\n\tpercnt: \"%\",\n\tperiod: \".\",\n\tcomma: \",\",\n\tast: \"*\",\n\tverbar: \"|\",\n\tgrave: \"`\",\n\tTab: \"\\t\",\n\tNewLine: \"\\n\",\n};\n\n/** Matches a numeric (decimal or hex) or named reference, with an optional semicolon. */\nconst ENTITY_PATTERN = /&(?:#[xX]([0-9a-fA-F]{1,6})|#(\\d{1,7})|([a-zA-Z][a-zA-Z0-9]{1,31}));?/g;\n\n/**\n * Highest code point `String.fromCodePoint` accepts. Numeric references above this\n * are left as written rather than throwing.\n */\nconst MAX_CODE_POINT = 0x10ffff;\n\n/**\n * Decode HTML character references in a single pass.\n *\n * Handles `&#60;`, `&#x3c;`, and the named subset in {@link NAMED_ENTITIES}, with or\n * without the trailing semicolon — browsers accept several named references\n * unterminated and attackers rely on it. Unrecognized references are left verbatim;\n * this is best-effort detection support, not a rendering path.\n *\n * @param input - Raw string.\n * @returns The string with recognized references replaced.\n *\n * @example\n * ```ts\n * decodeHtmlEntities(\"&#60;script&#62;\"); // \"<script>\"\n * decodeHtmlEntities(\"&unknownref;\"); // \"&unknownref;\"\n * ```\n */\nexport function decodeHtmlEntities(input: string): string {\n\tif (!input.includes(\"&\")) return input;\n\n\t// `String.prototype.replace` resets a global pattern's lastIndex on entry, so the\n\t// module-scoped ENTITY_PATTERN carries no state between calls.\n\treturn input.replace(ENTITY_PATTERN, (match, hex?: string, dec?: string, name?: string) => {\n\t\tif (hex !== undefined) {\n\t\t\tconst code = Number.parseInt(hex, 16);\n\t\t\treturn code <= MAX_CODE_POINT ? String.fromCodePoint(code) : match;\n\t\t}\n\t\tif (dec !== undefined) {\n\t\t\tconst code = Number.parseInt(dec, 10);\n\t\t\treturn code <= MAX_CODE_POINT ? String.fromCodePoint(code) : match;\n\t\t}\n\t\tif (name !== undefined) {\n\t\t\t// Own-property check, not `?? match`: the name group matches `constructor`,\n\t\t\t// `toString`, `valueOf` and friends, which resolve up the prototype chain to a\n\t\t\t// function. `??` never fires for those, and `replace` then coerces the function\n\t\t\t// to its source text — `&constructor;` decoded to\n\t\t\t// `function Object() { [native code] }`, injecting braces and parens that trip\n\t\t\t// unrelated rules and corrupting the `html_decoded` variant for any input\n\t\t\t// carrying such a reference.\n\t\t\treturn Object.hasOwn(NAMED_ENTITIES, name) ? NAMED_ENTITIES[name] : match;\n\t\t}\n\t\treturn match;\n\t});\n}\n\n//#endregion\n\n//#region Percent decoding\n\n/**\n * Percent-decode a string, tolerating malformed sequences.\n *\n * `decodeURIComponent` throws `URIError` on an invalid escape (`%zz`, a lone `%`, a\n * truncated surrogate pair). That failure is itself telemetry — well-formed clients\n * do not emit it — so the caller gets `null` and the `raw` variant stays scannable\n * instead of the whole scan aborting.\n *\n * @param input - Possibly percent-encoded string.\n * @returns The decoded string, or `null` when the input contains no `%` or is not\n * validly encoded.\n */\nexport function tryPercentDecode(input: string): string | null {\n\tif (!input.includes(\"%\")) return null;\n\ttry {\n\t\treturn decodeURIComponent(input);\n\t} catch {\n\t\treturn null;\n\t}\n}\n\n//#endregion\n\n//#region Variant construction\n\n/**\n * Build the representations to scan.\n *\n * Always includes `raw`. Adds `nfc`, `percent_decoded`, and `html_decoded` only when\n * that transformation actually changes the string, so a plain ASCII value costs one\n * pass rather than four.\n *\n * The original input is never mutated or replaced — callers keep the raw value for\n * storage and display, and the variants exist solely so a rule can observe what the\n * value would become at a decoding sink.\n *\n * @param input - Raw untrusted string.\n * @returns One to four distinct representations, `raw` first.\n *\n * @example\n * ```ts\n * buildInputVariants(\"%2e%2e%2f\");\n * // [{ kind: \"raw\", value: \"%2e%2e%2f\" }, { kind: \"percent_decoded\", value: \"../\" }]\n * ```\n */\nexport function buildInputVariants(input: string): readonly InputVariant[] {\n\tconst variants: InputVariant[] = [{ kind: \"raw\", value: input }];\n\tconst seen = new Set<string>([input]);\n\n\tconst add = (kind: InputVariantKind, value: string): void => {\n\t\tif (seen.has(value)) return;\n\t\tseen.add(value);\n\t\tvariants.push({ kind, value });\n\t};\n\n\t// `normalize` throws only on an invalid form argument, never on content.\n\tadd(\"nfc\", input.normalize(\"NFC\"));\n\n\t// NFC is a documented no-op on compatibility characters, so fullwidth forms slipped\n\t// past every signature: `../../etc/passwd` and `<script>` both scanned clean.\n\t// NFKC folds U+FF01–FF5E onto ASCII, which is exactly the mapping a downstream\n\t// component performs when it normalizes before parsing. Kept as its own variant\n\t// rather than replacing `nfc`, because NFKC is lossy and must never be what the\n\t// caller stores.\n\tadd(\"nfkc\", input.normalize(\"NFKC\"));\n\n\tconst percentDecoded = tryPercentDecode(input);\n\tif (percentDecoded !== null) {\n\t\tadd(\"percent_decoded\", percentDecoded);\n\t}\n\n\tadd(\"html_decoded\", decodeHtmlEntities(input));\n\n\treturn variants;\n}\n\n//#endregion\n"],"mappings":";;;;;;;;;AA4CA,MAAM,iBAAmD;CACxD,KAAK;CACL,IAAI;CACJ,IAAI;CACJ,MAAM;CACN,MAAM;CACN,MAAM;CACN,KAAK;CACL,MAAM;CACN,OAAO;CACP,MAAM;CACN,MAAM;CACN,MAAM;CACN,QAAQ;CACR,QAAQ;CACR,MAAM;CACN,MAAM;CACN,MAAM;CACN,QAAQ;CACR,QAAQ;CACR,QAAQ;CACR,KAAK;CACL,QAAQ;CACR,QAAQ;CACR,OAAO;CACP,KAAK;CACL,QAAQ;CACR,OAAO;CACP,KAAK;CACL,SAAS;AACV;;AAGA,MAAM,iBAAiB;;;;;AAMvB,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;AAmBvB,SAAgB,mBAAmB,OAAuB;CACzD,IAAI,CAAC,MAAM,SAAS,GAAG,GAAG,OAAO;CAIjC,OAAO,MAAM,QAAQ,iBAAiB,OAAO,KAAc,KAAc,SAAkB;EAC1F,IAAI,QAAQ,KAAA,GAAW;GACtB,MAAM,OAAO,OAAO,SAAS,KAAK,EAAE;GACpC,OAAO,QAAQ,iBAAiB,OAAO,cAAc,IAAI,IAAI;EAC9D;EACA,IAAI,QAAQ,KAAA,GAAW;GACtB,MAAM,OAAO,OAAO,SAAS,KAAK,EAAE;GACpC,OAAO,QAAQ,iBAAiB,OAAO,cAAc,IAAI,IAAI;EAC9D;EACA,IAAI,SAAS,KAAA,GAQZ,OAAO,OAAO,OAAO,gBAAgB,IAAI,IAAI,eAAe,QAAQ;EAErE,OAAO;CACR,CAAC;AACF;;;;;;;;;;;;;AAkBA,SAAgB,iBAAiB,OAA8B;CAC9D,IAAI,CAAC,MAAM,SAAS,GAAG,GAAG,OAAO;CACjC,IAAI;EACH,OAAO,mBAAmB,KAAK;CAChC,QAAQ;EACP,OAAO;CACR;AACD;;;;;;;;;;;;;;;;;;;;;AA0BA,SAAgB,mBAAmB,OAAwC;CAC1E,MAAM,WAA2B,CAAC;EAAE,MAAM;EAAO,OAAO;CAAM,CAAC;CAC/D,MAAM,uBAAO,IAAI,IAAY,CAAC,KAAK,CAAC;CAEpC,MAAM,OAAO,MAAwB,UAAwB;EAC5D,IAAI,KAAK,IAAI,KAAK,GAAG;EACrB,KAAK,IAAI,KAAK;EACd,SAAS,KAAK;GAAE;GAAM;EAAM,CAAC;CAC9B;CAGA,IAAI,OAAO,MAAM,UAAU,KAAK,CAAC;CAQjC,IAAI,QAAQ,MAAM,UAAU,MAAM,CAAC;CAEnC,MAAM,iBAAiB,iBAAiB,KAAK;CAC7C,IAAI,mBAAmB,MACtB,IAAI,mBAAmB,cAAc;CAGtC,IAAI,gBAAgB,mBAAmB,KAAK,CAAC;CAE7C,OAAO;AACR"}
@@ -1,6 +1,7 @@
1
1
  //#region src/unicode/confusables.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.
@@ -21,7 +22,7 @@
21
22
  * under two prototypes is confusable with both, so either answer yields the same
22
23
  * collision behavior provided the choice is deterministic, which it is.
23
24
  */
24
- declare const CONFUSABLE_MAP: ReadonlyMap<number, string>;
25
+ export declare const CONFUSABLE_MAP: ReadonlyMap<number, string>;
25
26
  /**
26
27
  * Generate a UTS #39-style confusable skeleton.
27
28
  *
@@ -44,7 +45,7 @@ declare const CONFUSABLE_MAP: ReadonlyMap<number, string>;
44
45
  * getSkeleton("paypaI") === getSkeleton("paypal"); // true — I folds to l
45
46
  * ```
46
47
  */
47
- declare function getSkeleton(input: string): string;
48
+ export declare function getSkeleton(input: string): string;
48
49
  /**
49
50
  * Map non-ASCII lookalike characters onto their ASCII prototypes, preserving
50
51
  * everything else.
@@ -68,7 +69,7 @@ declare function getSkeleton(input: string): string;
68
69
  * foldConfusables("HELLO"); // "HELLO" — ASCII untouched
69
70
  * ```
70
71
  */
71
- declare function foldConfusables(input: string): string;
72
+ export declare function foldConfusables(input: string): string;
72
73
  /**
73
74
  * Test whether two distinct strings are visually confusable.
74
75
  *
@@ -76,7 +77,6 @@ declare function foldConfusables(input: string): string;
76
77
  * @param right - Second string.
77
78
  * @returns `true` when the strings differ but their skeletons match.
78
79
  */
79
- declare function areConfusable(left: string, right: string): boolean;
80
+ export declare function areConfusable(left: string, right: string): boolean;
80
81
  //#endregion
81
- export { CONFUSABLE_MAP, areConfusable, foldConfusables, getSkeleton };
82
82
  //# sourceMappingURL=confusables.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"confusables.d.mts","names":[],"sources":["../../src/unicode/confusables.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;cA6Ka,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;iBAsKb,YAAY;;;;;;;;;;;;;;;;;;;;;;;;iBAyCZ,gBAAgB;;;;;;;;iBAsBhB,cAAc,cAAc"}
1
+ {"version":3,"file":"confusables.d.mts","names":[],"sources":["../../src/unicode/confusables.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;qBA8Ka,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;wBAsKb,YAAY;;;;;;;;;;;;;;;;;;;;;;;;wBAyCZ,gBAAgB;;;;;;;;wBAsBhB,cAAc,cAAc"}
@@ -1,6 +1,7 @@
1
1
  //#region src/unicode/confusables.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.
@@ -1 +1 @@
1
- {"version":3,"file":"confusables.mjs","names":[],"sources":["../../src/unicode/confusables.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 Confusable-character data and UTS #39 skeleton generation.\n *\n * A *skeleton* is an opaque comparison key. Two strings whose skeletons are equal are\n * visually confusable; that is the only thing a skeleton means. It is not a normalized\n * identifier, it is not safe to display, and it must never be stored in place of what\n * the user actually typed — the point is to keep the original for display and compare\n * skeletons for collisions.\n *\n * Coverage is split in two:\n *\n * - **Algorithmic ranges** — fullwidth forms, mathematical alphanumerics, and\n * enclosed/parenthesized letters are contiguous ranges at a fixed offset from\n * ASCII, so they fold by computation rather than by table. That covers several\n * thousand code points in a few lines.\n * - **A curated table** — the Cyrillic, Greek, Armenian, Cherokee, Coptic, and\n * Canadian-Aboriginal glyphs that render like Latin letters, written as numeric\n * code points so the source stays ASCII and every entry is reviewable in a diff.\n *\n * This is not the complete `confusables.txt` dataset (~6 000 mappings). It covers the\n * Latin-target subset, which is what matters for the identifiers this package guards:\n * usernames, domains, org names, package names. Callers needing full UTS #39 coverage\n * should layer a dedicated dataset on top and track Unicode's release cadence.\n *\n * @module @resq-systems/security/unicode/confusables\n */\n\n//#region Curated table\n\n/**\n * Confusable code points grouped by the ASCII prototype they fold to.\n *\n * Written as numeric code points on purpose. A table of raw lookalike glyphs is by\n * construction unreadable in review — the entries are supposed to be\n * indistinguishable from their targets — and one stray copy-paste produces exactly\n * the silent duplicate the previous `HOMOGLYPH_MAP` carried on its `a` row.\n *\n * The `l` and `o` rows fold across case (`I`, `l`, `1` all become `l`; `O`, `o`, `0`\n * all become `o`) because those glyph families are genuinely indistinguishable in\n * most typefaces. Every other row preserves case.\n *\n * Rows are keyed by the letter a code point **renders as**, not by its Unicode general\n * category, and the two disagree often enough to matter. A Greek, Cyrillic, Coptic or\n * Roman-numeral capital whose glyph is the Latin capital belongs in the uppercase row:\n * filing `Ρ` (U+03A1) under `p` folds it to `p` while `P` folds to `P`, so the spoof\n * pair an identifier check exists to catch compares as *not* confusable. The reverse\n * also happens — `Ƅ` (U+0184), `Ь` (U+042C), `Ꮟ` (U+13CF), `Ꮒ` (U+13C2), `Ꮷ` (U+13E7)\n * and `Ꭹ` (U+13A9) carry general category `Lu` but draw as lowercase shapes, so they\n * stay in the lowercase rows. An audit keyed on general category flags those six; they\n * are correct as written.\n */\nconst CONFUSABLES_BY_PROTOTYPE: Readonly<Record<string, readonly number[]>> = {\n\ta: [0x0430, 0x03b1, 0x0251, 0x0252, 0x1d00, 0x237a, 0xab7a],\n\tb: [0x0184, 0x042c, 0x044c, 0x13cf, 0x15af, 0x0432, 0x1d03, 0x0180],\n\tc: [0x0441, 0x03f2, 0x2ca5, 0x1d04, 0x217d, 0x0188],\n\td: [0x0501, 0x13e7, 0x146f, 0x2146, 0x217e, 0x0257, 0x1d05],\n\te: [0x0435, 0x04bd, 0x212e, 0x1d07, 0xab32, 0x0454, 0x2107],\n\tf: [0x017f, 0x0192, 0x03dd, 0x1e9d, 0xa799],\n\tg: [0x0261, 0x01e5, 0x0581, 0x13fb, 0x1d83, 0x210a],\n\th: [0x04bb, 0x0570, 0x13c2, 0x210e, 0x048b],\n\ti: [0x0456, 0x03b9, 0x0269, 0x2170, 0x2139, 0x0131, 0x1fbe, 0x2373],\n\tj: [0x0458, 0x03f3, 0x0575, 0x2149],\n\tk: [0x043a, 0x03ba, 0x1d0b, 0x2c95, 0x0138],\n\tl: [\n\t\t0x0049, 0x0031, 0x007c, 0x04cf, 0x2113, 0x217c, 0x0196, 0x01c0, 0x05c0, 0x2160, 0x216c, 0x2223,\n\t\t0x0399, 0x0406, 0x2110, 0x0142,\n\t],\n\tm: [0x043c, 0x217f, 0x1d0d, 0x028d],\n\tn: [0x0578, 0x1d0e, 0x0273, 0x057c],\n\to: [\n\t\t0x004f, 0x0030, 0x043e, 0x041e, 0x03bf, 0x039f, 0x0585, 0x0555, 0x0665, 0x06f5, 0x0966, 0x0ae6,\n\t\t0x0be6, 0x0c66, 0x1d0f, 0x2c9e, 0x2c9f, 0x0b66, 0x101d, 0x1d11,\n\t],\n\tp: [0x0440, 0x03c1, 0x2374, 0x1d18, 0x2ca3],\n\tq: [0x051b, 0x0563, 0xa757],\n\tr: [0x0433, 0x1d26, 0x2c85, 0x0453, 0x027e],\n\ts: [0x0455, 0xa731, 0x01bd, 0x0282],\n\tt: [0x0442, 0x03c4, 0x1d1b, 0x0163],\n\tu: [0x03c5, 0x057d, 0x1d1c, 0x0446, 0x028b],\n\tv: [0x03bd, 0x0475, 0x2174, 0x2228, 0x1d20],\n\tw: [0x051d, 0x0561, 0x1d21, 0x0448, 0xab83],\n\tx: [0x0445, 0x03c7, 0x2179, 0x166d, 0x00d7],\n\ty: [0x0443, 0x03b3, 0x028f, 0x04af, 0x13a9, 0x1eff, 0x03d2],\n\tz: [0x0290, 0x1d22, 0xa763, 0x01b6],\n\tA: [0x0410, 0x0391, 0x13aa, 0x2c6d],\n\tB: [0x0412, 0x0392, 0x13f4, 0x15f7, 0x2c82, 0x212c],\n\tC: [0x0421, 0x03f9, 0x216d, 0x13df, 0x2102, 0x212d, 0x2ca4],\n\tD: [0x13a0, 0x216e, 0x15ea, 0x2145],\n\tE: [0x0415, 0x0395, 0x13ac, 0x2d39, 0x2130, 0x04bc],\n\tF: [0x03dc, 0x15b4, 0xa798, 0x2131],\n\tG: [0x050c, 0x13c0, 0xa7a0, 0x050d, 0x2ca0],\n\tH: [0x041d, 0x0397, 0x13bb, 0x210b, 0x2c8e, 0x210c, 0x210d],\n\tJ: [0x0408, 0x13ab, 0x148d, 0x1d0a],\n\tK: [0x041a, 0x039a, 0x212a, 0x13e6, 0x2c94],\n\tL: [0x13de, 0x1d0c, 0x2cd0, 0x2112, 0x14aa],\n\tM: [0x041c, 0x039c, 0x13b7, 0x2c98, 0x2133, 0x216f],\n\tN: [0x039d, 0x2c9a, 0x2115, 0x0274],\n\tP: [0x13e2, 0x2119, 0x146d, 0x0420, 0x03a1, 0x2ca2],\n\tQ: [0x051a, 0x2d55, 0xa756, 0x211a, 0x213a],\n\tR: [0x13a1, 0x13d2, 0x1d19, 0x01a6, 0x211b, 0x211d],\n\tS: [0x2ca1, 0x0218, 0x0405, 0x13da],\n\tT: [0x0422, 0x03a4, 0x13a2, 0x22a4, 0x2ca6, 0x03ee],\n\tU: [0x054d, 0x222a, 0x054f],\n\tV: [0x2164, 0x0474, 0x13d9],\n\tW: [0x13b3, 0x051e, 0x051c],\n\tX: [0x2573, 0x2cad, 0x0425, 0x03a7, 0x2169, 0x2cac],\n\tY: [0x04ae, 0x213f, 0x0423, 0x03a5, 0x2ca8],\n\tZ: [0x2124, 0x13c4, 0x0396, 0x13c3, 0x2c7f],\n\t\"2\": [0x01a7, 0x03e8, 0xa644, 0x14bf],\n\t\"3\": [0x0417, 0x04e0, 0x01b7, 0x2ccc, 0x0499],\n\t\"4\": [0x13ce, 0x146c],\n\t\"5\": [0x01bc, 0x1d7d],\n\t\"6\": [0x0431, 0x13ee, 0x0411],\n\t\"7\": [0x04c0, 0x1d7c],\n\t\"8\": [0x0222, 0x0b03, 0x09ea],\n\t\"9\": [0x0669, 0x06f9, 0x13ed, 0x0967],\n\t\"-\": [0x2010, 0x2011, 0x2012, 0x2013, 0x2014, 0x2015, 0x2212, 0x2043, 0xfe58, 0x058a, 0x1806],\n\t\".\": [0x0660, 0x06f0, 0x2024, 0xa4f8, 0x0701, 0x0702],\n\t\"/\": [0x2044, 0x2215, 0x2571, 0x29f8, 0x1735, 0x3033],\n\t\"\\\\\": [0x2216, 0x29f5, 0x2572, 0x244a, 0xfe68],\n\t\"'\": [0x2018, 0x2019, 0x02b9, 0x02bc, 0x02c8, 0x055a, 0x05f3, 0x2032],\n\t'\"': [0x201c, 0x201d, 0x02ba, 0x05f4, 0x2033],\n\t\":\": [0x0589, 0x05c3, 0x2236, 0xa789, 0xfe30, 0x2982],\n\t\";\": [0x037e, 0xfe14],\n\t\"!\": [0x01c3, 0x2d51, 0xfe15],\n\t\"?\": [0x0294, 0x0241, 0x097d, 0x13ae],\n\t\"(\": [0x2768, 0x239b],\n\t\")\": [0x2769, 0x239e],\n\t\"[\": [0x2772, 0x3010],\n\t\"]\": [0x2773, 0x3011],\n\t\"{\": [0x2774],\n\t\"}\": [0x2775],\n\t\"@\": [0x0e4f],\n\t\"#\": [0x2d5e],\n\t$: [0x1e9f],\n\t\"%\": [0x066a, 0x2052],\n\t\"&\": [0xa778],\n\t\"*\": [0x066d, 0x204e, 0x2217, 0x1031f],\n\t\"+\": [0x16ed, 0x2795, 0x271a],\n\t\",\": [0x060d, 0x066b, 0x201a, 0x3001],\n\t\"<\": [0x02c2, 0x1438, 0x2039, 0x276e],\n\t\">\": [0x02c3, 0x1433, 0x203a, 0x276f],\n\t\"=\": [0x2e40, 0x30a0, 0xa4ff],\n\t\"~\": [0x02dc, 0x1fc0, 0x2053, 0x223c],\n\t\"^\": [0x02c4, 0x02c6],\n\t_: [0x02cd, 0xff3f],\n\t\"`\": [0x02cb],\n\t\"|\": [0x2758, 0xffe8, 0x0964],\n};\n\n/**\n * Flattened lookup: confusable code point to its ASCII prototype.\n *\n * Later rows win on collision. That is intentional and harmless — a code point listed\n * under two prototypes is confusable with both, so either answer yields the same\n * collision behavior provided the choice is deterministic, which it is.\n */\nexport const CONFUSABLE_MAP: ReadonlyMap<number, string> = (() => {\n\tconst map = new Map<number, string>();\n\tfor (const [prototype, codePoints] of Object.entries(CONFUSABLES_BY_PROTOTYPE)) {\n\t\tfor (const codePoint of codePoints) {\n\t\t\tmap.set(codePoint, prototype);\n\t\t}\n\t}\n\treturn map;\n})();\n\n//#endregion\n\n//#region Algorithmic ranges\n\n/** Fullwidth `U+FF01`–`U+FF5E` map to `!`–`~` by subtracting this offset. */\nconst FULLWIDTH_OFFSET = 0xfee0;\n\n/** Lowest fullwidth form that folds to ASCII. */\nconst FULLWIDTH_START = 0xff01;\n\n/** Highest fullwidth form that folds to ASCII. */\nconst FULLWIDTH_END = 0xff5e;\n\n/**\n * Contiguous ranges that fold to ASCII at a fixed offset from the range start.\n *\n * Each entry is `[rangeStart, rangeEnd, asciiStart]`. Handling these by computation\n * rather than by table folds several thousand code points — the mathematical\n * alphanumeric block alone is over 1 000 — without a single table row.\n */\nconst ALGORITHMIC_RANGES: readonly (readonly [number, number, number])[] = [\n\t// Mathematical alphanumeric symbols: styled copies of A-Z then a-z.\n\t[0x1d400, 0x1d419, 0x41],\n\t[0x1d41a, 0x1d433, 0x61],\n\t[0x1d434, 0x1d44d, 0x41],\n\t[0x1d44e, 0x1d467, 0x61],\n\t[0x1d468, 0x1d481, 0x41],\n\t[0x1d482, 0x1d49b, 0x61],\n\t[0x1d49c, 0x1d4b5, 0x41],\n\t[0x1d4b6, 0x1d4cf, 0x61],\n\t[0x1d4d0, 0x1d4e9, 0x41],\n\t[0x1d4ea, 0x1d503, 0x61],\n\t[0x1d504, 0x1d51d, 0x41],\n\t[0x1d51e, 0x1d537, 0x61],\n\t[0x1d538, 0x1d551, 0x41],\n\t[0x1d552, 0x1d56b, 0x61],\n\t[0x1d56c, 0x1d585, 0x41],\n\t[0x1d586, 0x1d59f, 0x61],\n\t[0x1d5a0, 0x1d5b9, 0x41],\n\t[0x1d5ba, 0x1d5d3, 0x61],\n\t[0x1d5d4, 0x1d5ed, 0x41],\n\t[0x1d5ee, 0x1d607, 0x61],\n\t[0x1d608, 0x1d621, 0x41],\n\t[0x1d622, 0x1d63b, 0x61],\n\t[0x1d63c, 0x1d655, 0x41],\n\t[0x1d656, 0x1d66f, 0x61],\n\t[0x1d670, 0x1d689, 0x41],\n\t[0x1d68a, 0x1d6a3, 0x61],\n\t// Mathematical digits: five styled copies of 0-9.\n\t[0x1d7ce, 0x1d7d7, 0x30],\n\t[0x1d7d8, 0x1d7e1, 0x30],\n\t[0x1d7e2, 0x1d7eb, 0x30],\n\t[0x1d7ec, 0x1d7f5, 0x30],\n\t[0x1d7f6, 0x1d7ff, 0x30],\n\t// Enclosed alphanumerics and parenthesized letters.\n\t[0x24b6, 0x24cf, 0x41],\n\t[0x24d0, 0x24e9, 0x61],\n\t[0x249c, 0x24b5, 0x61],\n\t// Regional indicator and squared Latin letters render as boxed capitals.\n\t[0x1f130, 0x1f149, 0x41],\n\t[0x1f150, 0x1f169, 0x41],\n\t[0x1f170, 0x1f189, 0x41],\n];\n\n/**\n * {@link ALGORITHMIC_RANGES} as start-sorted parallel arrays, built once at module load.\n *\n * The literal above stays the source of truth because it is grouped for reading — maths\n * alphanumerics, then digits, then enclosed forms — which leaves it unsorted, so it can\n * only be searched linearly. Sorting a derived copy allows a binary search without\n * reordering the table a reviewer reads.\n */\nconst [RANGE_STARTS, RANGE_ENDS, RANGE_ASCII_STARTS] = (() => {\n\tconst sorted = [...ALGORITHMIC_RANGES].sort((a, b) => a[0] - b[0]);\n\treturn [\n\t\tInt32Array.from(sorted, (range) => range[0]),\n\t\tInt32Array.from(sorted, (range) => range[1]),\n\t\tInt32Array.from(sorted, (range) => range[2]),\n\t] as const;\n})();\n\n/** Lowest code point covered by any algorithmic range. */\nconst MIN_RANGE_START = RANGE_STARTS[0] as number;\n\n/** Highest code point covered by any algorithmic range. */\nconst MAX_RANGE_END = RANGE_ENDS.reduce((max, end) => (end > max ? end : max), 0);\n\n/**\n * Fold one code point to its ASCII prototype.\n *\n * @param codePoint - Code point to fold.\n * @returns The ASCII prototype, or `null` when the code point is not confusable with\n * anything in the ASCII range.\n */\nfunction foldCodePoint(codePoint: number): string | null {\n\tif (codePoint >= FULLWIDTH_START && codePoint <= FULLWIDTH_END) {\n\t\treturn String.fromCodePoint(codePoint - FULLWIDTH_OFFSET);\n\t}\n\n\t// One comparison rejects the entire algorithmic block for ASCII, Cyrillic, Greek,\n\t// Hebrew, Arabic, Devanagari and CJK — which is very nearly all real input. The\n\t// previous linear walk charged each of those characters all 37 range comparisons\n\t// before reaching the map, making the fold O(n * 37) rather than O(n).\n\tif (codePoint >= MIN_RANGE_START && codePoint <= MAX_RANGE_END) {\n\t\t// The ranges do not overlap, so a start-ordered bisection is exact: a code\n\t\t// point in a gap between two ranges exits the loop and falls through.\n\t\tlet low = 0;\n\t\tlet high = RANGE_STARTS.length - 1;\n\t\twhile (low <= high) {\n\t\t\tconst mid = (low + high) >> 1;\n\t\t\tif (codePoint < (RANGE_STARTS[mid] as number)) {\n\t\t\t\thigh = mid - 1;\n\t\t\t} else if (codePoint > (RANGE_ENDS[mid] as number)) {\n\t\t\t\tlow = mid + 1;\n\t\t\t} else {\n\t\t\t\tconst offset = codePoint - (RANGE_STARTS[mid] as number);\n\t\t\t\treturn String.fromCodePoint((RANGE_ASCII_STARTS[mid] as number) + offset);\n\t\t\t}\n\t\t}\n\t}\n\n\treturn CONFUSABLE_MAP.get(codePoint) ?? null;\n}\n\n//#endregion\n\n//#region Skeleton\n\n/** Combining marks, dropped during skeleton generation. */\nconst COMBINING_MARKS = /\\p{M}/gu;\n\n/** Default-ignorable and zero-width code points, which contribute nothing visually. */\nconst INVISIBLE_CODE_POINTS = /[\\u00ad\\u200b-\\u200f\\u2060-\\u2064\\ufeff\\u202a-\\u202e\\u2066-\\u2069]/g;\n\n/**\n * Generate a UTS #39-style confusable skeleton.\n *\n * Pipeline: decompose to NFD, drop invisible and combining code points, fold each\n * remaining code point through the confusable tables, recompose to NFC.\n *\n * Two strings with equal skeletons are visually confusable. That is *all* an equal\n * skeleton means — in particular it does not mean the strings are equivalent, and the\n * skeleton is neither a display nor a storage form. Accents are dropped and the\n * `I/l/1` and `O/o/0` families collapse across case, so `José`, `Jose`, and `J0sé`\n * share a skeleton by design.\n *\n * @param input - Raw string. Non-string or empty input yields `\"\"`.\n * @returns The comparison key.\n *\n * @example\n * ```ts\n * // Cyrillic а in an otherwise Latin string.\n * getSkeleton(\"pаypal\") === getSkeleton(\"paypal\"); // true\n * getSkeleton(\"paypaI\") === getSkeleton(\"paypal\"); // true — I folds to l\n * ```\n */\nexport function getSkeleton(input: string): string {\n\tif (!input || typeof input !== \"string\") return \"\";\n\n\tconst decomposed = input\n\t\t.normalize(\"NFD\")\n\t\t.replace(INVISIBLE_CODE_POINTS, \"\")\n\t\t.replace(COMBINING_MARKS, \"\");\n\n\tlet skeleton = \"\";\n\tfor (const character of decomposed) {\n\t\tconst codePoint = character.codePointAt(0);\n\t\tif (codePoint === undefined) continue;\n\t\tskeleton += foldCodePoint(codePoint) ?? character;\n\t}\n\n\treturn skeleton.normalize(\"NFC\");\n}\n\n/**\n * Map non-ASCII lookalike characters onto their ASCII prototypes, preserving\n * everything else.\n *\n * Unlike {@link getSkeleton} this is a *conservative* transform: accents and other\n * combining marks survive, ASCII characters are never rewritten, and the result stays\n * readable. `Ολγα` keeps its Greek letters only insofar as they are not Latin\n * lookalikes; `café` stays `café`.\n *\n * Even so, prefer {@link getSkeleton} for collision checks and keep the original for\n * display. Rewriting a user's identifier into a different string is a lossy operation\n * that this function can only make *look* safe.\n *\n * @param input - Raw string. Non-string or empty input yields `\"\"`.\n * @returns NFC-composed string with non-ASCII confusables folded to ASCII.\n *\n * @example\n * ```ts\n * foldConfusables(\"pаypal\"); // \"paypal\" — Cyrillic а folded\n * foldConfusables(\"café\"); // \"café\" — accent preserved\n * foldConfusables(\"HELLO\"); // \"HELLO\" — ASCII untouched\n * ```\n */\nexport function foldConfusables(input: string): string {\n\tif (!input || typeof input !== \"string\") return \"\";\n\n\tlet folded = \"\";\n\tfor (const character of input.normalize(\"NFC\")) {\n\t\tconst codePoint = character.codePointAt(0);\n\t\tif (codePoint === undefined) continue;\n\t\t// ASCII is left alone: folding `O` to `o` or `I` to `l` is right for an opaque\n\t\t// comparison key and wrong for a value anyone will read.\n\t\tfolded += codePoint <= 0x7f ? character : (foldCodePoint(codePoint) ?? character);\n\t}\n\n\treturn folded.normalize(\"NFC\");\n}\n\n/**\n * Test whether two distinct strings are visually confusable.\n *\n * @param left - First string.\n * @param right - Second string.\n * @returns `true` when the strings differ but their skeletons match.\n */\nexport function areConfusable(left: string, right: string): boolean {\n\tif (left === right) return false;\n\treturn getSkeleton(left) === getSkeleton(right);\n}\n\n//#endregion\n"],"mappings":"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"}
1
+ {"version":3,"file":"confusables.mjs","names":[],"sources":["../../src/unicode/confusables.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 Confusable-character data and UTS #39 skeleton generation.\n *\n * A *skeleton* is an opaque comparison key. Two strings whose skeletons are equal are\n * visually confusable; that is the only thing a skeleton means. It is not a normalized\n * identifier, it is not safe to display, and it must never be stored in place of what\n * the user actually typed — the point is to keep the original for display and compare\n * skeletons for collisions.\n *\n * Coverage is split in two:\n *\n * - **Algorithmic ranges** — fullwidth forms, mathematical alphanumerics, and\n * enclosed/parenthesized letters are contiguous ranges at a fixed offset from\n * ASCII, so they fold by computation rather than by table. That covers several\n * thousand code points in a few lines.\n * - **A curated table** — the Cyrillic, Greek, Armenian, Cherokee, Coptic, and\n * Canadian-Aboriginal glyphs that render like Latin letters, written as numeric\n * code points so the source stays ASCII and every entry is reviewable in a diff.\n *\n * This is not the complete `confusables.txt` dataset (~6 000 mappings). It covers the\n * Latin-target subset, which is what matters for the identifiers this package guards:\n * usernames, domains, org names, package names. Callers needing full UTS #39 coverage\n * should layer a dedicated dataset on top and track Unicode's release cadence.\n *\n * @module @resq-systems/security/unicode/confusables\n */\n\n//#region Curated table\n\n/**\n * Confusable code points grouped by the ASCII prototype they fold to.\n *\n * Written as numeric code points on purpose. A table of raw lookalike glyphs is by\n * construction unreadable in review — the entries are supposed to be\n * indistinguishable from their targets — and one stray copy-paste produces exactly\n * the silent duplicate the previous `HOMOGLYPH_MAP` carried on its `a` row.\n *\n * The `l` and `o` rows fold across case (`I`, `l`, `1` all become `l`; `O`, `o`, `0`\n * all become `o`) because those glyph families are genuinely indistinguishable in\n * most typefaces. Every other row preserves case.\n *\n * Rows are keyed by the letter a code point **renders as**, not by its Unicode general\n * category, and the two disagree often enough to matter. A Greek, Cyrillic, Coptic or\n * Roman-numeral capital whose glyph is the Latin capital belongs in the uppercase row:\n * filing `Ρ` (U+03A1) under `p` folds it to `p` while `P` folds to `P`, so the spoof\n * pair an identifier check exists to catch compares as *not* confusable. The reverse\n * also happens — `Ƅ` (U+0184), `Ь` (U+042C), `Ꮟ` (U+13CF), `Ꮒ` (U+13C2), `Ꮷ` (U+13E7)\n * and `Ꭹ` (U+13A9) carry general category `Lu` but draw as lowercase shapes, so they\n * stay in the lowercase rows. An audit keyed on general category flags those six; they\n * are correct as written.\n */\nconst CONFUSABLES_BY_PROTOTYPE: Readonly<Record<string, readonly number[]>> = {\n\ta: [0x0430, 0x03b1, 0x0251, 0x0252, 0x1d00, 0x237a, 0xab7a],\n\tb: [0x0184, 0x042c, 0x044c, 0x13cf, 0x15af, 0x0432, 0x1d03, 0x0180],\n\tc: [0x0441, 0x03f2, 0x2ca5, 0x1d04, 0x217d, 0x0188],\n\td: [0x0501, 0x13e7, 0x146f, 0x2146, 0x217e, 0x0257, 0x1d05],\n\te: [0x0435, 0x04bd, 0x212e, 0x1d07, 0xab32, 0x0454, 0x2107],\n\tf: [0x017f, 0x0192, 0x03dd, 0x1e9d, 0xa799],\n\tg: [0x0261, 0x01e5, 0x0581, 0x13fb, 0x1d83, 0x210a],\n\th: [0x04bb, 0x0570, 0x13c2, 0x210e, 0x048b],\n\ti: [0x0456, 0x03b9, 0x0269, 0x2170, 0x2139, 0x0131, 0x1fbe, 0x2373],\n\tj: [0x0458, 0x03f3, 0x0575, 0x2149],\n\tk: [0x043a, 0x03ba, 0x1d0b, 0x2c95, 0x0138],\n\tl: [\n\t\t0x0049, 0x0031, 0x007c, 0x04cf, 0x2113, 0x217c, 0x0196, 0x01c0, 0x05c0, 0x2160, 0x216c, 0x2223,\n\t\t0x0399, 0x0406, 0x2110, 0x0142,\n\t],\n\tm: [0x043c, 0x217f, 0x1d0d, 0x028d],\n\tn: [0x0578, 0x1d0e, 0x0273, 0x057c],\n\to: [\n\t\t0x004f, 0x0030, 0x043e, 0x041e, 0x03bf, 0x039f, 0x0585, 0x0555, 0x0665, 0x06f5, 0x0966, 0x0ae6,\n\t\t0x0be6, 0x0c66, 0x1d0f, 0x2c9e, 0x2c9f, 0x0b66, 0x101d, 0x1d11,\n\t],\n\tp: [0x0440, 0x03c1, 0x2374, 0x1d18, 0x2ca3],\n\tq: [0x051b, 0x0563, 0xa757],\n\tr: [0x0433, 0x1d26, 0x2c85, 0x0453, 0x027e],\n\ts: [0x0455, 0xa731, 0x01bd, 0x0282],\n\tt: [0x0442, 0x03c4, 0x1d1b, 0x0163],\n\tu: [0x03c5, 0x057d, 0x1d1c, 0x0446, 0x028b],\n\tv: [0x03bd, 0x0475, 0x2174, 0x2228, 0x1d20],\n\tw: [0x051d, 0x0561, 0x1d21, 0x0448, 0xab83],\n\tx: [0x0445, 0x03c7, 0x2179, 0x166d, 0x00d7],\n\ty: [0x0443, 0x03b3, 0x028f, 0x04af, 0x13a9, 0x1eff, 0x03d2],\n\tz: [0x0290, 0x1d22, 0xa763, 0x01b6],\n\tA: [0x0410, 0x0391, 0x13aa, 0x2c6d],\n\tB: [0x0412, 0x0392, 0x13f4, 0x15f7, 0x2c82, 0x212c],\n\tC: [0x0421, 0x03f9, 0x216d, 0x13df, 0x2102, 0x212d, 0x2ca4],\n\tD: [0x13a0, 0x216e, 0x15ea, 0x2145],\n\tE: [0x0415, 0x0395, 0x13ac, 0x2d39, 0x2130, 0x04bc],\n\tF: [0x03dc, 0x15b4, 0xa798, 0x2131],\n\tG: [0x050c, 0x13c0, 0xa7a0, 0x050d, 0x2ca0],\n\tH: [0x041d, 0x0397, 0x13bb, 0x210b, 0x2c8e, 0x210c, 0x210d],\n\tJ: [0x0408, 0x13ab, 0x148d, 0x1d0a],\n\tK: [0x041a, 0x039a, 0x212a, 0x13e6, 0x2c94],\n\tL: [0x13de, 0x1d0c, 0x2cd0, 0x2112, 0x14aa],\n\tM: [0x041c, 0x039c, 0x13b7, 0x2c98, 0x2133, 0x216f],\n\tN: [0x039d, 0x2c9a, 0x2115, 0x0274],\n\tP: [0x13e2, 0x2119, 0x146d, 0x0420, 0x03a1, 0x2ca2],\n\tQ: [0x051a, 0x2d55, 0xa756, 0x211a, 0x213a],\n\tR: [0x13a1, 0x13d2, 0x1d19, 0x01a6, 0x211b, 0x211d],\n\tS: [0x2ca1, 0x0218, 0x0405, 0x13da],\n\tT: [0x0422, 0x03a4, 0x13a2, 0x22a4, 0x2ca6, 0x03ee],\n\tU: [0x054d, 0x222a, 0x054f],\n\tV: [0x2164, 0x0474, 0x13d9],\n\tW: [0x13b3, 0x051e, 0x051c],\n\tX: [0x2573, 0x2cad, 0x0425, 0x03a7, 0x2169, 0x2cac],\n\tY: [0x04ae, 0x213f, 0x0423, 0x03a5, 0x2ca8],\n\tZ: [0x2124, 0x13c4, 0x0396, 0x13c3, 0x2c7f],\n\t\"2\": [0x01a7, 0x03e8, 0xa644, 0x14bf],\n\t\"3\": [0x0417, 0x04e0, 0x01b7, 0x2ccc, 0x0499],\n\t\"4\": [0x13ce, 0x146c],\n\t\"5\": [0x01bc, 0x1d7d],\n\t\"6\": [0x0431, 0x13ee, 0x0411],\n\t\"7\": [0x04c0, 0x1d7c],\n\t\"8\": [0x0222, 0x0b03, 0x09ea],\n\t\"9\": [0x0669, 0x06f9, 0x13ed, 0x0967],\n\t\"-\": [0x2010, 0x2011, 0x2012, 0x2013, 0x2014, 0x2015, 0x2212, 0x2043, 0xfe58, 0x058a, 0x1806],\n\t\".\": [0x0660, 0x06f0, 0x2024, 0xa4f8, 0x0701, 0x0702],\n\t\"/\": [0x2044, 0x2215, 0x2571, 0x29f8, 0x1735, 0x3033],\n\t\"\\\\\": [0x2216, 0x29f5, 0x2572, 0x244a, 0xfe68],\n\t\"'\": [0x2018, 0x2019, 0x02b9, 0x02bc, 0x02c8, 0x055a, 0x05f3, 0x2032],\n\t'\"': [0x201c, 0x201d, 0x02ba, 0x05f4, 0x2033],\n\t\":\": [0x0589, 0x05c3, 0x2236, 0xa789, 0xfe30, 0x2982],\n\t\";\": [0x037e, 0xfe14],\n\t\"!\": [0x01c3, 0x2d51, 0xfe15],\n\t\"?\": [0x0294, 0x0241, 0x097d, 0x13ae],\n\t\"(\": [0x2768, 0x239b],\n\t\")\": [0x2769, 0x239e],\n\t\"[\": [0x2772, 0x3010],\n\t\"]\": [0x2773, 0x3011],\n\t\"{\": [0x2774],\n\t\"}\": [0x2775],\n\t\"@\": [0x0e4f],\n\t\"#\": [0x2d5e],\n\t$: [0x1e9f],\n\t\"%\": [0x066a, 0x2052],\n\t\"&\": [0xa778],\n\t\"*\": [0x066d, 0x204e, 0x2217, 0x1031f],\n\t\"+\": [0x16ed, 0x2795, 0x271a],\n\t\",\": [0x060d, 0x066b, 0x201a, 0x3001],\n\t\"<\": [0x02c2, 0x1438, 0x2039, 0x276e],\n\t\">\": [0x02c3, 0x1433, 0x203a, 0x276f],\n\t\"=\": [0x2e40, 0x30a0, 0xa4ff],\n\t\"~\": [0x02dc, 0x1fc0, 0x2053, 0x223c],\n\t\"^\": [0x02c4, 0x02c6],\n\t_: [0x02cd, 0xff3f],\n\t\"`\": [0x02cb],\n\t\"|\": [0x2758, 0xffe8, 0x0964],\n};\n\n/**\n * Flattened lookup: confusable code point to its ASCII prototype.\n *\n * Later rows win on collision. That is intentional and harmless — a code point listed\n * under two prototypes is confusable with both, so either answer yields the same\n * collision behavior provided the choice is deterministic, which it is.\n */\nexport const CONFUSABLE_MAP: ReadonlyMap<number, string> = (() => {\n\tconst map = new Map<number, string>();\n\tfor (const [prototype, codePoints] of Object.entries(CONFUSABLES_BY_PROTOTYPE)) {\n\t\tfor (const codePoint of codePoints) {\n\t\t\tmap.set(codePoint, prototype);\n\t\t}\n\t}\n\treturn map;\n})();\n\n//#endregion\n\n//#region Algorithmic ranges\n\n/** Fullwidth `U+FF01`–`U+FF5E` map to `!`–`~` by subtracting this offset. */\nconst FULLWIDTH_OFFSET = 0xfee0;\n\n/** Lowest fullwidth form that folds to ASCII. */\nconst FULLWIDTH_START = 0xff01;\n\n/** Highest fullwidth form that folds to ASCII. */\nconst FULLWIDTH_END = 0xff5e;\n\n/**\n * Contiguous ranges that fold to ASCII at a fixed offset from the range start.\n *\n * Each entry is `[rangeStart, rangeEnd, asciiStart]`. Handling these by computation\n * rather than by table folds several thousand code points — the mathematical\n * alphanumeric block alone is over 1 000 — without a single table row.\n */\nconst ALGORITHMIC_RANGES: readonly (readonly [number, number, number])[] = [\n\t// Mathematical alphanumeric symbols: styled copies of A-Z then a-z.\n\t[0x1d400, 0x1d419, 0x41],\n\t[0x1d41a, 0x1d433, 0x61],\n\t[0x1d434, 0x1d44d, 0x41],\n\t[0x1d44e, 0x1d467, 0x61],\n\t[0x1d468, 0x1d481, 0x41],\n\t[0x1d482, 0x1d49b, 0x61],\n\t[0x1d49c, 0x1d4b5, 0x41],\n\t[0x1d4b6, 0x1d4cf, 0x61],\n\t[0x1d4d0, 0x1d4e9, 0x41],\n\t[0x1d4ea, 0x1d503, 0x61],\n\t[0x1d504, 0x1d51d, 0x41],\n\t[0x1d51e, 0x1d537, 0x61],\n\t[0x1d538, 0x1d551, 0x41],\n\t[0x1d552, 0x1d56b, 0x61],\n\t[0x1d56c, 0x1d585, 0x41],\n\t[0x1d586, 0x1d59f, 0x61],\n\t[0x1d5a0, 0x1d5b9, 0x41],\n\t[0x1d5ba, 0x1d5d3, 0x61],\n\t[0x1d5d4, 0x1d5ed, 0x41],\n\t[0x1d5ee, 0x1d607, 0x61],\n\t[0x1d608, 0x1d621, 0x41],\n\t[0x1d622, 0x1d63b, 0x61],\n\t[0x1d63c, 0x1d655, 0x41],\n\t[0x1d656, 0x1d66f, 0x61],\n\t[0x1d670, 0x1d689, 0x41],\n\t[0x1d68a, 0x1d6a3, 0x61],\n\t// Mathematical digits: five styled copies of 0-9.\n\t[0x1d7ce, 0x1d7d7, 0x30],\n\t[0x1d7d8, 0x1d7e1, 0x30],\n\t[0x1d7e2, 0x1d7eb, 0x30],\n\t[0x1d7ec, 0x1d7f5, 0x30],\n\t[0x1d7f6, 0x1d7ff, 0x30],\n\t// Enclosed alphanumerics and parenthesized letters.\n\t[0x24b6, 0x24cf, 0x41],\n\t[0x24d0, 0x24e9, 0x61],\n\t[0x249c, 0x24b5, 0x61],\n\t// Regional indicator and squared Latin letters render as boxed capitals.\n\t[0x1f130, 0x1f149, 0x41],\n\t[0x1f150, 0x1f169, 0x41],\n\t[0x1f170, 0x1f189, 0x41],\n];\n\n/**\n * {@link ALGORITHMIC_RANGES} as start-sorted parallel arrays, built once at module load.\n *\n * The literal above stays the source of truth because it is grouped for reading — maths\n * alphanumerics, then digits, then enclosed forms — which leaves it unsorted, so it can\n * only be searched linearly. Sorting a derived copy allows a binary search without\n * reordering the table a reviewer reads.\n */\nconst [RANGE_STARTS, RANGE_ENDS, RANGE_ASCII_STARTS] = (() => {\n\tconst sorted = [...ALGORITHMIC_RANGES].sort((a, b) => a[0] - b[0]);\n\treturn [\n\t\tInt32Array.from(sorted, (range) => range[0]),\n\t\tInt32Array.from(sorted, (range) => range[1]),\n\t\tInt32Array.from(sorted, (range) => range[2]),\n\t] as const;\n})();\n\n/** Lowest code point covered by any algorithmic range. */\nconst MIN_RANGE_START = RANGE_STARTS[0] as number;\n\n/** Highest code point covered by any algorithmic range. */\nconst MAX_RANGE_END = RANGE_ENDS.reduce((max, end) => (end > max ? end : max), 0);\n\n/**\n * Fold one code point to its ASCII prototype.\n *\n * @param codePoint - Code point to fold.\n * @returns The ASCII prototype, or `null` when the code point is not confusable with\n * anything in the ASCII range.\n */\nfunction foldCodePoint(codePoint: number): string | null {\n\tif (codePoint >= FULLWIDTH_START && codePoint <= FULLWIDTH_END) {\n\t\treturn String.fromCodePoint(codePoint - FULLWIDTH_OFFSET);\n\t}\n\n\t// One comparison rejects the entire algorithmic block for ASCII, Cyrillic, Greek,\n\t// Hebrew, Arabic, Devanagari and CJK — which is very nearly all real input. The\n\t// previous linear walk charged each of those characters all 37 range comparisons\n\t// before reaching the map, making the fold O(n * 37) rather than O(n).\n\tif (codePoint >= MIN_RANGE_START && codePoint <= MAX_RANGE_END) {\n\t\t// The ranges do not overlap, so a start-ordered bisection is exact: a code\n\t\t// point in a gap between two ranges exits the loop and falls through.\n\t\tlet low = 0;\n\t\tlet high = RANGE_STARTS.length - 1;\n\t\twhile (low <= high) {\n\t\t\tconst mid = (low + high) >> 1;\n\t\t\tif (codePoint < (RANGE_STARTS[mid] as number)) {\n\t\t\t\thigh = mid - 1;\n\t\t\t} else if (codePoint > (RANGE_ENDS[mid] as number)) {\n\t\t\t\tlow = mid + 1;\n\t\t\t} else {\n\t\t\t\tconst offset = codePoint - (RANGE_STARTS[mid] as number);\n\t\t\t\treturn String.fromCodePoint((RANGE_ASCII_STARTS[mid] as number) + offset);\n\t\t\t}\n\t\t}\n\t}\n\n\treturn CONFUSABLE_MAP.get(codePoint) ?? null;\n}\n\n//#endregion\n\n//#region Skeleton\n\n/** Combining marks, dropped during skeleton generation. */\nconst COMBINING_MARKS = /\\p{M}/gu;\n\n/** Default-ignorable and zero-width code points, which contribute nothing visually. */\nconst INVISIBLE_CODE_POINTS = /[\\u00ad\\u200b-\\u200f\\u2060-\\u2064\\ufeff\\u202a-\\u202e\\u2066-\\u2069]/g;\n\n/**\n * Generate a UTS #39-style confusable skeleton.\n *\n * Pipeline: decompose to NFD, drop invisible and combining code points, fold each\n * remaining code point through the confusable tables, recompose to NFC.\n *\n * Two strings with equal skeletons are visually confusable. That is *all* an equal\n * skeleton means — in particular it does not mean the strings are equivalent, and the\n * skeleton is neither a display nor a storage form. Accents are dropped and the\n * `I/l/1` and `O/o/0` families collapse across case, so `José`, `Jose`, and `J0sé`\n * share a skeleton by design.\n *\n * @param input - Raw string. Non-string or empty input yields `\"\"`.\n * @returns The comparison key.\n *\n * @example\n * ```ts\n * // Cyrillic а in an otherwise Latin string.\n * getSkeleton(\"pаypal\") === getSkeleton(\"paypal\"); // true\n * getSkeleton(\"paypaI\") === getSkeleton(\"paypal\"); // true — I folds to l\n * ```\n */\nexport function getSkeleton(input: string): string {\n\tif (!input || typeof input !== \"string\") return \"\";\n\n\tconst decomposed = input\n\t\t.normalize(\"NFD\")\n\t\t.replace(INVISIBLE_CODE_POINTS, \"\")\n\t\t.replace(COMBINING_MARKS, \"\");\n\n\tlet skeleton = \"\";\n\tfor (const character of decomposed) {\n\t\tconst codePoint = character.codePointAt(0);\n\t\tif (codePoint === undefined) continue;\n\t\tskeleton += foldCodePoint(codePoint) ?? character;\n\t}\n\n\treturn skeleton.normalize(\"NFC\");\n}\n\n/**\n * Map non-ASCII lookalike characters onto their ASCII prototypes, preserving\n * everything else.\n *\n * Unlike {@link getSkeleton} this is a *conservative* transform: accents and other\n * combining marks survive, ASCII characters are never rewritten, and the result stays\n * readable. `Ολγα` keeps its Greek letters only insofar as they are not Latin\n * lookalikes; `café` stays `café`.\n *\n * Even so, prefer {@link getSkeleton} for collision checks and keep the original for\n * display. Rewriting a user's identifier into a different string is a lossy operation\n * that this function can only make *look* safe.\n *\n * @param input - Raw string. Non-string or empty input yields `\"\"`.\n * @returns NFC-composed string with non-ASCII confusables folded to ASCII.\n *\n * @example\n * ```ts\n * foldConfusables(\"pаypal\"); // \"paypal\" — Cyrillic а folded\n * foldConfusables(\"café\"); // \"café\" — accent preserved\n * foldConfusables(\"HELLO\"); // \"HELLO\" — ASCII untouched\n * ```\n */\nexport function foldConfusables(input: string): string {\n\tif (!input || typeof input !== \"string\") return \"\";\n\n\tlet folded = \"\";\n\tfor (const character of input.normalize(\"NFC\")) {\n\t\tconst codePoint = character.codePointAt(0);\n\t\tif (codePoint === undefined) continue;\n\t\t// ASCII is left alone: folding `O` to `o` or `I` to `l` is right for an opaque\n\t\t// comparison key and wrong for a value anyone will read.\n\t\tfolded += codePoint <= 0x7f ? character : (foldCodePoint(codePoint) ?? character);\n\t}\n\n\treturn folded.normalize(\"NFC\");\n}\n\n/**\n * Test whether two distinct strings are visually confusable.\n *\n * @param left - First string.\n * @param right - Second string.\n * @returns `true` when the strings differ but their skeletons match.\n */\nexport function areConfusable(left: string, right: string): boolean {\n\tif (left === right) return false;\n\treturn getSkeleton(left) === getSkeleton(right);\n}\n\n//#endregion\n"],"mappings":"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"}
@@ -7,7 +7,7 @@ import { CONFUSABLE_MAP, areConfusable, foldConfusables, getSkeleton } from "./c
7
7
  * identifier-spoofing work, plus the CJK set needed to model UTS #39's Highly
8
8
  * Restrictive level correctly.
9
9
  */
10
- type UnicodeScript = "Latin" | "Greek" | "Cyrillic" | "Armenian" | "Hebrew" | "Arabic" | "Devanagari" | "Bengali" | "Tamil" | "Thai" | "Georgian" | "Han" | "Hiragana" | "Katakana" | "Hangul" | "Bopomofo" | "Cherokee" | "Other";
10
+ export type UnicodeScript = "Latin" | "Greek" | "Cyrillic" | "Armenian" | "Hebrew" | "Arabic" | "Devanagari" | "Bengali" | "Tamil" | "Thai" | "Georgian" | "Han" | "Hiragana" | "Katakana" | "Hangul" | "Bopomofo" | "Cherokee" | "Other";
11
11
  /**
12
12
  * Identify the scripts present in a string.
13
13
  *
@@ -26,7 +26,7 @@ type UnicodeScript = "Latin" | "Greek" | "Cyrillic" | "Armenian" | "Hebrew" | "A
26
26
  * getScripts("東京タワー"); // ["Han", "Katakana"] ← ordinary Japanese
27
27
  * ```
28
28
  */
29
- declare function getScripts(input: string): readonly UnicodeScript[];
29
+ export declare function getScripts(input: string): readonly UnicodeScript[];
30
30
  /**
31
31
  * UTS #39 §5.2 identifier restriction levels, ordered most to least restrictive.
32
32
  *
@@ -34,14 +34,14 @@ declare function getScripts(input: string): readonly UnicodeScript[];
34
34
  * `highly_restrictive` for merchant display names; a global social product might
35
35
  * accept `moderately_restrictive` and merely alert on the rest.
36
36
  */
37
- type IdentifierRestrictionLevel = "ascii_only" | "single_script" | "highly_restrictive" | "moderately_restrictive" | "minimally_restrictive" | "unrestricted";
37
+ export type IdentifierRestrictionLevel = "ascii_only" | "single_script" | "highly_restrictive" | "moderately_restrictive" | "minimally_restrictive" | "unrestricted";
38
38
  /**
39
39
  * Classify a string against the UTS #39 restriction levels.
40
40
  *
41
41
  * @param input - String to classify.
42
42
  * @returns The most restrictive level the string satisfies.
43
43
  */
44
- declare function getRestrictionLevel(input: string): IdentifierRestrictionLevel;
44
+ export declare function getRestrictionLevel(input: string): IdentifierRestrictionLevel;
45
45
  /**
46
46
  * Detect bidirectional override characters.
47
47
  *
@@ -52,7 +52,7 @@ declare function getRestrictionLevel(input: string): IdentifierRestrictionLevel;
52
52
  * @param input - String to test.
53
53
  * @returns `true` when a bidi embedding, override, or isolate control is present.
54
54
  */
55
- declare function containsBidiControls(input: string): boolean;
55
+ export declare function containsBidiControls(input: string): boolean;
56
56
  /**
57
57
  * Detect zero-width and other invisible formatting characters.
58
58
  *
@@ -63,16 +63,16 @@ declare function containsBidiControls(input: string): boolean;
63
63
  * @param input - String to test.
64
64
  * @returns `true` when an invisible formatting character is present.
65
65
  */
66
- declare function containsInvisibleCharacters(input: string): boolean;
66
+ export declare function containsInvisibleCharacters(input: string): boolean;
67
67
  /**
68
68
  * Remove invisible formatting and bidirectional control characters.
69
69
  *
70
70
  * @param input - String to clean.
71
71
  * @returns The string without those code points. Non-string input yields `""`.
72
72
  */
73
- declare function stripInvisibleCharacters(input: string): string;
73
+ export declare function stripInvisibleCharacters(input: string): string;
74
74
  /** Everything the analyzer can say about one identifier. */
75
- interface IdentifierSecurityResult {
75
+ export interface IdentifierSecurityResult {
76
76
  /** The input, unchanged. Keep displaying this — never the skeleton. */
77
77
  readonly original: string;
78
78
  /** NFC-composed form. Safe to store and display. */
@@ -109,7 +109,7 @@ interface IdentifierSecurityResult {
109
109
  * await accounts.create({ display: candidate.original, skeleton: candidate.skeleton });
110
110
  * ```
111
111
  */
112
- declare function analyzeIdentifier(input: string): IdentifierSecurityResult;
112
+ export declare function analyzeIdentifier(input: string): IdentifierSecurityResult;
113
113
  /**
114
114
  * Policy check over {@link analyzeIdentifier}.
115
115
  *
@@ -120,7 +120,7 @@ declare function analyzeIdentifier(input: string): IdentifierSecurityResult;
120
120
  * @returns `true` when the identifier sits at or below `maximumLevel` and carries no
121
121
  * bidirectional controls.
122
122
  */
123
- declare function isSafeIdentifier(input: string, maximumLevel?: IdentifierRestrictionLevel): boolean;
123
+ export declare function isSafeIdentifier(input: string, maximumLevel?: IdentifierRestrictionLevel): boolean;
124
124
  //#endregion
125
- export { CONFUSABLE_MAP, IdentifierRestrictionLevel, IdentifierSecurityResult, UnicodeScript, analyzeIdentifier, areConfusable, containsBidiControls, containsInvisibleCharacters, foldConfusables, getRestrictionLevel, getScripts, getSkeleton, isSafeIdentifier, stripInvisibleCharacters };
125
+ export { CONFUSABLE_MAP, areConfusable, foldConfusables, getSkeleton };
126
126
  //# sourceMappingURL=index.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/unicode/index.ts"],"mappings":";;;;;;;;;KAqDY;;;;;;;;;;;;;;;;;;;iBAyEI,WAAW,yBAAyB;;;;;;;;KA+BxC;;;;;;;iBAqDI,oBAAoB,gBAAgB;;;;;;;;;;;iBA0EpC,qBAAqB;;;;;;;;;;;iBAerB,4BAA4B;;;;;;;iBAW5B,yBAAyB;;UAUxB;;WAEP;;WAEA;;WAEA;;WAEA,kBAAkB;;WAElB;;WAEA,kBAAkB;;WAElB;;WAEA;;;;;;;;;;;;;;;;;;;;;iBAsBM,kBAAkB,gBAAgB;;;;;;;;;;;iBA0ClC,iBACf,eACA,eAAc"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/unicode/index.ts"],"mappings":";;;;;;;;;YAsDY;;;;;;;;;;;;;;;;;;;wBAyEI,WAAW,yBAAyB;;;;;;;;YA+BxC;;;;;;;wBAqDI,oBAAoB,gBAAgB;;;;;;;;;;;wBA0EpC,qBAAqB;;;;;;;;;;;wBAerB,4BAA4B;;;;;;;wBAW5B,yBAAyB;;iBAUxB;;WAEP;;WAEA;;WAEA;;WAEA,kBAAkB;;WAElB;;WAEA,kBAAkB;;WAElB;;WAEA;;;;;;;;;;;;;;;;;;;;;wBAsBM,kBAAkB,gBAAgB;;;;;;;;;;;wBA0ClC,iBACf,eACA,eAAc"}
@@ -2,6 +2,7 @@ import { CONFUSABLE_MAP, areConfusable, foldConfusables, getSkeleton } from "./c
2
2
  //#region src/unicode/index.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.
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../../src/unicode/index.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 UTS #39 identifier security: script analysis, mixed-script detection,\n * restriction levels, and invisible/bidirectional control detection.\n *\n * **Scope matters more here than anywhere else in this package.** UTS #39 itself warns\n * that broad confusable detection flags a great many legitimate strings. These\n * functions belong on *protected identifiers* — usernames, domain names, organization\n * names, package names, brand-adjacent labels — where a visual collision is the whole\n * attack. They do not belong on prose, on postal addresses, and emphatically not on\n * people's names: \"Ольга Иванова\" is single-script Cyrillic, and rejecting it is not a\n * security control, it is a bug. Use `validatePersonName` for names.\n *\n * The one exception is {@link containsBidiControls}, which is safe to apply anywhere:\n * a bidirectional override in user input is the \"Trojan Source\" class (CVE-2021-42574)\n * and has no legitimate use in a stored field.\n *\n * @module @resq-systems/security/unicode\n */\n\nimport { getSkeleton } from \"./confusables.js\";\n\nexport {\n\tareConfusable,\n\tCONFUSABLE_MAP,\n\tfoldConfusables,\n\tgetSkeleton,\n} from \"./confusables.js\";\n\n//#region Scripts\n\n/**\n * Scripts the analyzer distinguishes.\n *\n * Not the full ISO 15924 list — it covers the scripts that actually appear in\n * identifier-spoofing work, plus the CJK set needed to model UTS #39's Highly\n * Restrictive level correctly.\n */\nexport type UnicodeScript =\n\t| \"Latin\"\n\t| \"Greek\"\n\t| \"Cyrillic\"\n\t| \"Armenian\"\n\t| \"Hebrew\"\n\t| \"Arabic\"\n\t| \"Devanagari\"\n\t| \"Bengali\"\n\t| \"Tamil\"\n\t| \"Thai\"\n\t| \"Georgian\"\n\t| \"Han\"\n\t| \"Hiragana\"\n\t| \"Katakana\"\n\t| \"Hangul\"\n\t| \"Bopomofo\"\n\t| \"Cherokee\"\n\t| \"Other\";\n\n/**\n * Detectors keyed by script.\n *\n * `Script_Extensions` rather than `Script`: a character shared between scripts — the\n * Japanese iteration mark, say — should count for every script that uses it rather\n * than being forced into one. That is precisely what keeps ordinary Japanese from\n * scoring as mixed-script.\n */\nconst SCRIPT_PATTERNS: readonly (readonly [UnicodeScript, RegExp])[] = [\n\t[\"Latin\", /\\p{Script_Extensions=Latin}/u],\n\t[\"Greek\", /\\p{Script_Extensions=Greek}/u],\n\t[\"Cyrillic\", /\\p{Script_Extensions=Cyrillic}/u],\n\t[\"Armenian\", /\\p{Script_Extensions=Armenian}/u],\n\t[\"Hebrew\", /\\p{Script_Extensions=Hebrew}/u],\n\t[\"Arabic\", /\\p{Script_Extensions=Arabic}/u],\n\t[\"Devanagari\", /\\p{Script_Extensions=Devanagari}/u],\n\t[\"Bengali\", /\\p{Script_Extensions=Bengali}/u],\n\t[\"Tamil\", /\\p{Script_Extensions=Tamil}/u],\n\t[\"Thai\", /\\p{Script_Extensions=Thai}/u],\n\t[\"Georgian\", /\\p{Script_Extensions=Georgian}/u],\n\t[\"Han\", /\\p{Script_Extensions=Han}/u],\n\t[\"Hiragana\", /\\p{Script_Extensions=Hiragana}/u],\n\t[\"Katakana\", /\\p{Script_Extensions=Katakana}/u],\n\t[\"Hangul\", /\\p{Script_Extensions=Hangul}/u],\n\t[\"Bopomofo\", /\\p{Script_Extensions=Bopomofo}/u],\n\t[\"Cherokee\", /\\p{Script_Extensions=Cherokee}/u],\n];\n\n/**\n * Characters belonging to no specific script — digits, punctuation, spaces, symbols,\n * format and control codes. Removed before analysis, since `user.name-1` is not\n * mixed-script.\n */\nconst SCRIPT_NEUTRAL = /[\\p{White_Space}\\p{P}\\p{S}\\p{N}\\p{Cf}\\p{Cc}]/gu;\n\n/**\n * Identify the scripts present in a string.\n *\n * Script-neutral characters are ignored, so `alice-99` reports `[\"Latin\"]` rather\n * than mixing in a phantom numeric script.\n *\n * @param input - String to analyze.\n * @returns Scripts present, in {@link SCRIPT_PATTERNS} order. Includes `\"Other\"` when\n * characters remain that match no known detector; `[]` for an entirely\n * script-neutral string.\n *\n * @example\n * ```ts\n * getScripts(\"paypal\"); // [\"Latin\"]\n * getScripts(\"pаypal\"); // [\"Latin\", \"Cyrillic\"] ← the spoof\n * getScripts(\"東京タワー\"); // [\"Han\", \"Katakana\"] ← ordinary Japanese\n * ```\n */\nexport function getScripts(input: string): readonly UnicodeScript[] {\n\tif (!input || typeof input !== \"string\") return [];\n\n\tconst meaningful = input.normalize(\"NFC\").replace(SCRIPT_NEUTRAL, \"\");\n\tif (meaningful.length === 0) return [];\n\n\tconst found: UnicodeScript[] = [];\n\tlet uncovered = meaningful;\n\n\tfor (const [script, pattern] of SCRIPT_PATTERNS) {\n\t\tif (pattern.test(meaningful)) {\n\t\t\tfound.push(script);\n\t\t\tuncovered = uncovered.replace(new RegExp(pattern.source, \"gu\"), \"\");\n\t\t}\n\t}\n\n\tif (uncovered.length > 0) found.push(\"Other\");\n\treturn found;\n}\n\n//#endregion\n\n//#region Restriction levels\n\n/**\n * UTS #39 §5.2 identifier restriction levels, ordered most to least restrictive.\n *\n * Use as a policy dial rather than a boolean. A payments product might require\n * `highly_restrictive` for merchant display names; a global social product might\n * accept `moderately_restrictive` and merely alert on the rest.\n */\nexport type IdentifierRestrictionLevel =\n\t| \"ascii_only\"\n\t| \"single_script\"\n\t| \"highly_restrictive\"\n\t| \"moderately_restrictive\"\n\t| \"minimally_restrictive\"\n\t| \"unrestricted\";\n\n/** Ordering for policy comparison. Lower is more restrictive. */\nconst RESTRICTION_ORDER: Readonly<Record<IdentifierRestrictionLevel, number>> = {\n\tascii_only: 0,\n\tsingle_script: 1,\n\thighly_restrictive: 2,\n\tmoderately_restrictive: 3,\n\tminimally_restrictive: 4,\n\tunrestricted: 5,\n};\n\n/**\n * Script combinations UTS #39 treats as Highly Restrictive despite spanning several\n * scripts, because each is simply how a major writing system is written.\n */\nconst HIGHLY_RESTRICTIVE_COMBINATIONS: readonly ReadonlySet<UnicodeScript>[] = [\n\tnew Set<UnicodeScript>([\"Latin\", \"Han\", \"Hiragana\", \"Katakana\"]),\n\tnew Set<UnicodeScript>([\"Latin\", \"Han\", \"Bopomofo\"]),\n\tnew Set<UnicodeScript>([\"Latin\", \"Han\", \"Hangul\"]),\n];\n\n/**\n * Scripts whose Latin lookalikes are numerous enough that pairing them with Latin\n * reads as suspicious rather than merely multilingual — the basis of UTS #39's\n * Moderately Restrictive exclusion list.\n */\nconst HIGH_CONFUSION_SCRIPTS: ReadonlySet<UnicodeScript> = new Set<UnicodeScript>([\n\t\"Cyrillic\",\n\t\"Greek\",\n\t\"Cherokee\",\n]);\n\n/** True when every code unit is ASCII. */\nfunction isAsciiOnly(input: string): boolean {\n\tfor (let i = 0; i < input.length; i++) {\n\t\tif (input.charCodeAt(i) > 0x7f) return false;\n\t}\n\treturn true;\n}\n\n/**\n * Classify a string against the UTS #39 restriction levels.\n *\n * @param input - String to classify.\n * @returns The most restrictive level the string satisfies.\n */\nexport function getRestrictionLevel(input: string): IdentifierRestrictionLevel {\n\tif (!input || typeof input !== \"string\") return \"ascii_only\";\n\n\tif (containsBidiControls(input) || HOSTILE_INVISIBLES.test(input)) {\n\t\treturn \"unrestricted\";\n\t}\n\n\tif (isAsciiOnly(input)) return \"ascii_only\";\n\n\tconst scripts = getScripts(input);\n\tif (scripts.includes(\"Other\")) return \"minimally_restrictive\";\n\tif (scripts.length <= 1) return \"single_script\";\n\n\tconst present = new Set(scripts);\n\tfor (const combination of HIGHLY_RESTRICTIVE_COMBINATIONS) {\n\t\tlet contained = true;\n\t\tfor (const script of present) {\n\t\t\tif (!combination.has(script)) {\n\t\t\t\tcontained = false;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\tif (contained) return \"highly_restrictive\";\n\t}\n\n\t// Latin plus exactly one other script, where that script is not one whose Latin\n\t// lookalikes make the pairing a common spoofing vehicle.\n\tif (present.has(\"Latin\") && present.size === 2) {\n\t\tconst other = scripts.find((script) => script !== \"Latin\");\n\t\tif (other !== undefined && !HIGH_CONFUSION_SCRIPTS.has(other)) {\n\t\t\treturn \"moderately_restrictive\";\n\t\t}\n\t}\n\n\treturn \"minimally_restrictive\";\n}\n\n//#endregion\n\n//#region Invisible and bidirectional controls\n\n/** Bidirectional embedding, override, and isolate controls. */\nconst BIDI_CONTROLS = /[\\u202a-\\u202e\\u2066-\\u2069]/;\n\n/** Zero-width, soft-hyphen, and word-joiner code points. */\nconst INVISIBLE_CHARACTERS = /[\\u00ad\\u200b-\\u200f\\u2060-\\u2064\\ufeff]/;\n\n/**\n * The subset of {@link INVISIBLE_CHARACTERS} with no legitimate use in an identifier.\n *\n * U+200C (ZWNJ) and U+200D (ZWJ) are deliberately absent. They are not decoration:\n * Persian, Hindi, and other scripts need them to write ordinary words and names\n * correctly \\u2014 `\\u0645\\u06cc\\u200c\\u0631\\u0648\\u0645` is spelled with a ZWNJ \\u2014 and they appear in everyday emoji\n * sequences. Demoting on their presence rejects a correctly spelled Persian identifier\n * at the default level, which is a worse outcome than the spoofing risk they carry,\n * and that risk is already covered: {@link getSkeleton} strips them before comparison,\n * so a joiner cannot hide a confusable. {@link containsInvisibleCharacters} still\n * reports them, because a caller may reasonably want to know they are there.\n */\nconst HOSTILE_INVISIBLES = /[\\u00ad\\u200b\\u200e\\u200f\\u2060-\\u2064\\ufeff]/;\n\n/** Global variant used for stripping both families at once. */\nconst REMOVABLE_FORMATTING = /[\\u00ad\\u200b-\\u200f\\u2060-\\u2064\\ufeff\\u202a-\\u202e\\u2066-\\u2069]/g;\n\n/**\n * Detect bidirectional override characters.\n *\n * Unlike the confusable checks, this is safe to run on **any** field. A bidi control\n * makes rendered text differ from its logical order — the \"Trojan Source\" technique\n * (CVE-2021-42574) — and no legitimate stored value needs one.\n *\n * @param input - String to test.\n * @returns `true` when a bidi embedding, override, or isolate control is present.\n */\nexport function containsBidiControls(input: string): boolean {\n\tif (!input || typeof input !== \"string\") return false;\n\treturn BIDI_CONTROLS.test(input);\n}\n\n/**\n * Detect zero-width and other invisible formatting characters.\n *\n * Note that U+200C/U+200D (ZWNJ/ZWJ) are required for correct rendering of Persian,\n * Hindi, and several other scripts, and appear in ordinary emoji sequences. Treat a\n * hit as a reason to compare skeletons, not as grounds for rejection.\n *\n * @param input - String to test.\n * @returns `true` when an invisible formatting character is present.\n */\nexport function containsInvisibleCharacters(input: string): boolean {\n\tif (!input || typeof input !== \"string\") return false;\n\treturn INVISIBLE_CHARACTERS.test(input);\n}\n\n/**\n * Remove invisible formatting and bidirectional control characters.\n *\n * @param input - String to clean.\n * @returns The string without those code points. Non-string input yields `\"\"`.\n */\nexport function stripInvisibleCharacters(input: string): string {\n\tif (!input || typeof input !== \"string\") return \"\";\n\treturn input.replace(REMOVABLE_FORMATTING, \"\");\n}\n\n//#endregion\n\n//#region Identifier analysis\n\n/** Everything the analyzer can say about one identifier. */\nexport interface IdentifierSecurityResult {\n\t/** The input, unchanged. Keep displaying this — never the skeleton. */\n\treadonly original: string;\n\t/** NFC-composed form. Safe to store and display. */\n\treadonly normalized: string;\n\t/** Opaque confusable comparison key. Compare it, never render it. */\n\treadonly skeleton: string;\n\t/** Scripts present, script-neutral characters excluded. */\n\treadonly scripts: readonly UnicodeScript[];\n\t/** `true` when several scripts are present and the mix is not an ordinary CJK one. */\n\treadonly isMixedScript: boolean;\n\t/** UTS #39 restriction level. */\n\treadonly restrictionLevel: IdentifierRestrictionLevel;\n\t/** `true` when a zero-width or invisible formatting character is present. */\n\treadonly hasInvisibleCharacters: boolean;\n\t/** `true` when a bidirectional control is present. Hostile in any field. */\n\treadonly hasBidiControls: boolean;\n}\n\n/**\n * Analyze a protected identifier.\n *\n * Read the fields; do not read a verdict — the caller's policy decides what to do.\n * The canonical use is: store `original` for display, index `skeleton`, and refuse a\n * registration whose skeleton already exists under a different `original`.\n *\n * @param input - Candidate identifier (username, domain, org name, package name).\n * @returns An {@link IdentifierSecurityResult}. Never throws.\n *\n * @example Collision check at registration time\n * ```ts\n * const candidate = analyzeIdentifier(requestedUsername);\n * if (await skeletonIndex.has(candidate.skeleton)) {\n * return { error: \"That name is too similar to an existing account\" };\n * }\n * await accounts.create({ display: candidate.original, skeleton: candidate.skeleton });\n * ```\n */\nexport function analyzeIdentifier(input: string): IdentifierSecurityResult {\n\tconst original = typeof input === \"string\" ? input : \"\";\n\tconst normalized = original.normalize(\"NFC\");\n\tconst scripts = getScripts(normalized);\n\tconst restrictionLevel = getRestrictionLevel(normalized);\n\n\t// `skeleton` is computed on first read rather than eagerly. It is the single most\n\t// expensive field — an NFD normalize, two regex passes, a per-code-point fold, then\n\t// an NFC normalize — and the callers that dominate volume never read it:\n\t// `containsHomoglyphs`, reached by default from `detectThreatPatterns`/`isSafeInput`,\n\t// uses only `isMixedScript`, `hasBidiControls` and `scripts`. Memoized, so the\n\t// registration-time collision check that does read it pays exactly once.\n\tlet memoizedSkeleton: string | undefined;\n\n\treturn {\n\t\toriginal,\n\t\tnormalized,\n\t\tget skeleton(): string {\n\t\t\tmemoizedSkeleton ??= getSkeleton(normalized);\n\t\t\treturn memoizedSkeleton;\n\t\t},\n\t\tscripts,\n\t\tisMixedScript:\n\t\t\tscripts.length > 1 &&\n\t\t\trestrictionLevel !== \"highly_restrictive\" &&\n\t\t\trestrictionLevel !== \"single_script\",\n\t\trestrictionLevel,\n\t\thasInvisibleCharacters: containsInvisibleCharacters(original),\n\t\thasBidiControls: containsBidiControls(original),\n\t};\n}\n\n/**\n * Policy check over {@link analyzeIdentifier}.\n *\n * @param input - Candidate identifier.\n * @param maximumLevel - Least restrictive level to accept. Defaults to\n * `\"moderately_restrictive\"`, which admits ordinary multilingual identifiers while\n * rejecting Latin/Cyrillic and Latin/Greek mixes.\n * @returns `true` when the identifier sits at or below `maximumLevel` and carries no\n * bidirectional controls.\n */\nexport function isSafeIdentifier(\n\tinput: string,\n\tmaximumLevel: IdentifierRestrictionLevel = \"moderately_restrictive\",\n): boolean {\n\tconst analysis = analyzeIdentifier(input);\n\tif (analysis.hasBidiControls) return false;\n\treturn RESTRICTION_ORDER[analysis.restrictionLevel] <= RESTRICTION_ORDER[maximumLevel];\n}\n\n//#endregion\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../src/unicode/index.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 UTS #39 identifier security: script analysis, mixed-script detection,\n * restriction levels, and invisible/bidirectional control detection.\n *\n * **Scope matters more here than anywhere else in this package.** UTS #39 itself warns\n * that broad confusable detection flags a great many legitimate strings. These\n * functions belong on *protected identifiers* — usernames, domain names, organization\n * names, package names, brand-adjacent labels — where a visual collision is the whole\n * attack. They do not belong on prose, on postal addresses, and emphatically not on\n * people's names: \"Ольга Иванова\" is single-script Cyrillic, and rejecting it is not a\n * security control, it is a bug. Use `validatePersonName` for names.\n *\n * The one exception is {@link containsBidiControls}, which is safe to apply anywhere:\n * a bidirectional override in user input is the \"Trojan Source\" class (CVE-2021-42574)\n * and has no legitimate use in a stored field.\n *\n * @module @resq-systems/security/unicode\n */\n\nimport { getSkeleton } from \"./confusables.js\";\n\nexport {\n\tareConfusable,\n\tCONFUSABLE_MAP,\n\tfoldConfusables,\n\tgetSkeleton,\n} from \"./confusables.js\";\n\n//#region Scripts\n\n/**\n * Scripts the analyzer distinguishes.\n *\n * Not the full ISO 15924 list — it covers the scripts that actually appear in\n * identifier-spoofing work, plus the CJK set needed to model UTS #39's Highly\n * Restrictive level correctly.\n */\nexport type UnicodeScript =\n\t| \"Latin\"\n\t| \"Greek\"\n\t| \"Cyrillic\"\n\t| \"Armenian\"\n\t| \"Hebrew\"\n\t| \"Arabic\"\n\t| \"Devanagari\"\n\t| \"Bengali\"\n\t| \"Tamil\"\n\t| \"Thai\"\n\t| \"Georgian\"\n\t| \"Han\"\n\t| \"Hiragana\"\n\t| \"Katakana\"\n\t| \"Hangul\"\n\t| \"Bopomofo\"\n\t| \"Cherokee\"\n\t| \"Other\";\n\n/**\n * Detectors keyed by script.\n *\n * `Script_Extensions` rather than `Script`: a character shared between scripts — the\n * Japanese iteration mark, say — should count for every script that uses it rather\n * than being forced into one. That is precisely what keeps ordinary Japanese from\n * scoring as mixed-script.\n */\nconst SCRIPT_PATTERNS: readonly (readonly [UnicodeScript, RegExp])[] = [\n\t[\"Latin\", /\\p{Script_Extensions=Latin}/u],\n\t[\"Greek\", /\\p{Script_Extensions=Greek}/u],\n\t[\"Cyrillic\", /\\p{Script_Extensions=Cyrillic}/u],\n\t[\"Armenian\", /\\p{Script_Extensions=Armenian}/u],\n\t[\"Hebrew\", /\\p{Script_Extensions=Hebrew}/u],\n\t[\"Arabic\", /\\p{Script_Extensions=Arabic}/u],\n\t[\"Devanagari\", /\\p{Script_Extensions=Devanagari}/u],\n\t[\"Bengali\", /\\p{Script_Extensions=Bengali}/u],\n\t[\"Tamil\", /\\p{Script_Extensions=Tamil}/u],\n\t[\"Thai\", /\\p{Script_Extensions=Thai}/u],\n\t[\"Georgian\", /\\p{Script_Extensions=Georgian}/u],\n\t[\"Han\", /\\p{Script_Extensions=Han}/u],\n\t[\"Hiragana\", /\\p{Script_Extensions=Hiragana}/u],\n\t[\"Katakana\", /\\p{Script_Extensions=Katakana}/u],\n\t[\"Hangul\", /\\p{Script_Extensions=Hangul}/u],\n\t[\"Bopomofo\", /\\p{Script_Extensions=Bopomofo}/u],\n\t[\"Cherokee\", /\\p{Script_Extensions=Cherokee}/u],\n];\n\n/**\n * Characters belonging to no specific script — digits, punctuation, spaces, symbols,\n * format and control codes. Removed before analysis, since `user.name-1` is not\n * mixed-script.\n */\nconst SCRIPT_NEUTRAL = /[\\p{White_Space}\\p{P}\\p{S}\\p{N}\\p{Cf}\\p{Cc}]/gu;\n\n/**\n * Identify the scripts present in a string.\n *\n * Script-neutral characters are ignored, so `alice-99` reports `[\"Latin\"]` rather\n * than mixing in a phantom numeric script.\n *\n * @param input - String to analyze.\n * @returns Scripts present, in {@link SCRIPT_PATTERNS} order. Includes `\"Other\"` when\n * characters remain that match no known detector; `[]` for an entirely\n * script-neutral string.\n *\n * @example\n * ```ts\n * getScripts(\"paypal\"); // [\"Latin\"]\n * getScripts(\"pаypal\"); // [\"Latin\", \"Cyrillic\"] ← the spoof\n * getScripts(\"東京タワー\"); // [\"Han\", \"Katakana\"] ← ordinary Japanese\n * ```\n */\nexport function getScripts(input: string): readonly UnicodeScript[] {\n\tif (!input || typeof input !== \"string\") return [];\n\n\tconst meaningful = input.normalize(\"NFC\").replace(SCRIPT_NEUTRAL, \"\");\n\tif (meaningful.length === 0) return [];\n\n\tconst found: UnicodeScript[] = [];\n\tlet uncovered = meaningful;\n\n\tfor (const [script, pattern] of SCRIPT_PATTERNS) {\n\t\tif (pattern.test(meaningful)) {\n\t\t\tfound.push(script);\n\t\t\tuncovered = uncovered.replace(new RegExp(pattern.source, \"gu\"), \"\");\n\t\t}\n\t}\n\n\tif (uncovered.length > 0) found.push(\"Other\");\n\treturn found;\n}\n\n//#endregion\n\n//#region Restriction levels\n\n/**\n * UTS #39 §5.2 identifier restriction levels, ordered most to least restrictive.\n *\n * Use as a policy dial rather than a boolean. A payments product might require\n * `highly_restrictive` for merchant display names; a global social product might\n * accept `moderately_restrictive` and merely alert on the rest.\n */\nexport type IdentifierRestrictionLevel =\n\t| \"ascii_only\"\n\t| \"single_script\"\n\t| \"highly_restrictive\"\n\t| \"moderately_restrictive\"\n\t| \"minimally_restrictive\"\n\t| \"unrestricted\";\n\n/** Ordering for policy comparison. Lower is more restrictive. */\nconst RESTRICTION_ORDER: Readonly<Record<IdentifierRestrictionLevel, number>> = {\n\tascii_only: 0,\n\tsingle_script: 1,\n\thighly_restrictive: 2,\n\tmoderately_restrictive: 3,\n\tminimally_restrictive: 4,\n\tunrestricted: 5,\n};\n\n/**\n * Script combinations UTS #39 treats as Highly Restrictive despite spanning several\n * scripts, because each is simply how a major writing system is written.\n */\nconst HIGHLY_RESTRICTIVE_COMBINATIONS: readonly ReadonlySet<UnicodeScript>[] = [\n\tnew Set<UnicodeScript>([\"Latin\", \"Han\", \"Hiragana\", \"Katakana\"]),\n\tnew Set<UnicodeScript>([\"Latin\", \"Han\", \"Bopomofo\"]),\n\tnew Set<UnicodeScript>([\"Latin\", \"Han\", \"Hangul\"]),\n];\n\n/**\n * Scripts whose Latin lookalikes are numerous enough that pairing them with Latin\n * reads as suspicious rather than merely multilingual — the basis of UTS #39's\n * Moderately Restrictive exclusion list.\n */\nconst HIGH_CONFUSION_SCRIPTS: ReadonlySet<UnicodeScript> = new Set<UnicodeScript>([\n\t\"Cyrillic\",\n\t\"Greek\",\n\t\"Cherokee\",\n]);\n\n/** True when every code unit is ASCII. */\nfunction isAsciiOnly(input: string): boolean {\n\tfor (let i = 0; i < input.length; i++) {\n\t\tif (input.charCodeAt(i) > 0x7f) return false;\n\t}\n\treturn true;\n}\n\n/**\n * Classify a string against the UTS #39 restriction levels.\n *\n * @param input - String to classify.\n * @returns The most restrictive level the string satisfies.\n */\nexport function getRestrictionLevel(input: string): IdentifierRestrictionLevel {\n\tif (!input || typeof input !== \"string\") return \"ascii_only\";\n\n\tif (containsBidiControls(input) || HOSTILE_INVISIBLES.test(input)) {\n\t\treturn \"unrestricted\";\n\t}\n\n\tif (isAsciiOnly(input)) return \"ascii_only\";\n\n\tconst scripts = getScripts(input);\n\tif (scripts.includes(\"Other\")) return \"minimally_restrictive\";\n\tif (scripts.length <= 1) return \"single_script\";\n\n\tconst present = new Set(scripts);\n\tfor (const combination of HIGHLY_RESTRICTIVE_COMBINATIONS) {\n\t\tlet contained = true;\n\t\tfor (const script of present) {\n\t\t\tif (!combination.has(script)) {\n\t\t\t\tcontained = false;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\tif (contained) return \"highly_restrictive\";\n\t}\n\n\t// Latin plus exactly one other script, where that script is not one whose Latin\n\t// lookalikes make the pairing a common spoofing vehicle.\n\tif (present.has(\"Latin\") && present.size === 2) {\n\t\tconst other = scripts.find((script) => script !== \"Latin\");\n\t\tif (other !== undefined && !HIGH_CONFUSION_SCRIPTS.has(other)) {\n\t\t\treturn \"moderately_restrictive\";\n\t\t}\n\t}\n\n\treturn \"minimally_restrictive\";\n}\n\n//#endregion\n\n//#region Invisible and bidirectional controls\n\n/** Bidirectional embedding, override, and isolate controls. */\nconst BIDI_CONTROLS = /[\\u202a-\\u202e\\u2066-\\u2069]/;\n\n/** Zero-width, soft-hyphen, and word-joiner code points. */\nconst INVISIBLE_CHARACTERS = /[\\u00ad\\u200b-\\u200f\\u2060-\\u2064\\ufeff]/;\n\n/**\n * The subset of {@link INVISIBLE_CHARACTERS} with no legitimate use in an identifier.\n *\n * U+200C (ZWNJ) and U+200D (ZWJ) are deliberately absent. They are not decoration:\n * Persian, Hindi, and other scripts need them to write ordinary words and names\n * correctly \\u2014 `\\u0645\\u06cc\\u200c\\u0631\\u0648\\u0645` is spelled with a ZWNJ \\u2014 and they appear in everyday emoji\n * sequences. Demoting on their presence rejects a correctly spelled Persian identifier\n * at the default level, which is a worse outcome than the spoofing risk they carry,\n * and that risk is already covered: {@link getSkeleton} strips them before comparison,\n * so a joiner cannot hide a confusable. {@link containsInvisibleCharacters} still\n * reports them, because a caller may reasonably want to know they are there.\n */\nconst HOSTILE_INVISIBLES = /[\\u00ad\\u200b\\u200e\\u200f\\u2060-\\u2064\\ufeff]/;\n\n/** Global variant used for stripping both families at once. */\nconst REMOVABLE_FORMATTING = /[\\u00ad\\u200b-\\u200f\\u2060-\\u2064\\ufeff\\u202a-\\u202e\\u2066-\\u2069]/g;\n\n/**\n * Detect bidirectional override characters.\n *\n * Unlike the confusable checks, this is safe to run on **any** field. A bidi control\n * makes rendered text differ from its logical order — the \"Trojan Source\" technique\n * (CVE-2021-42574) — and no legitimate stored value needs one.\n *\n * @param input - String to test.\n * @returns `true` when a bidi embedding, override, or isolate control is present.\n */\nexport function containsBidiControls(input: string): boolean {\n\tif (!input || typeof input !== \"string\") return false;\n\treturn BIDI_CONTROLS.test(input);\n}\n\n/**\n * Detect zero-width and other invisible formatting characters.\n *\n * Note that U+200C/U+200D (ZWNJ/ZWJ) are required for correct rendering of Persian,\n * Hindi, and several other scripts, and appear in ordinary emoji sequences. Treat a\n * hit as a reason to compare skeletons, not as grounds for rejection.\n *\n * @param input - String to test.\n * @returns `true` when an invisible formatting character is present.\n */\nexport function containsInvisibleCharacters(input: string): boolean {\n\tif (!input || typeof input !== \"string\") return false;\n\treturn INVISIBLE_CHARACTERS.test(input);\n}\n\n/**\n * Remove invisible formatting and bidirectional control characters.\n *\n * @param input - String to clean.\n * @returns The string without those code points. Non-string input yields `\"\"`.\n */\nexport function stripInvisibleCharacters(input: string): string {\n\tif (!input || typeof input !== \"string\") return \"\";\n\treturn input.replace(REMOVABLE_FORMATTING, \"\");\n}\n\n//#endregion\n\n//#region Identifier analysis\n\n/** Everything the analyzer can say about one identifier. */\nexport interface IdentifierSecurityResult {\n\t/** The input, unchanged. Keep displaying this — never the skeleton. */\n\treadonly original: string;\n\t/** NFC-composed form. Safe to store and display. */\n\treadonly normalized: string;\n\t/** Opaque confusable comparison key. Compare it, never render it. */\n\treadonly skeleton: string;\n\t/** Scripts present, script-neutral characters excluded. */\n\treadonly scripts: readonly UnicodeScript[];\n\t/** `true` when several scripts are present and the mix is not an ordinary CJK one. */\n\treadonly isMixedScript: boolean;\n\t/** UTS #39 restriction level. */\n\treadonly restrictionLevel: IdentifierRestrictionLevel;\n\t/** `true` when a zero-width or invisible formatting character is present. */\n\treadonly hasInvisibleCharacters: boolean;\n\t/** `true` when a bidirectional control is present. Hostile in any field. */\n\treadonly hasBidiControls: boolean;\n}\n\n/**\n * Analyze a protected identifier.\n *\n * Read the fields; do not read a verdict — the caller's policy decides what to do.\n * The canonical use is: store `original` for display, index `skeleton`, and refuse a\n * registration whose skeleton already exists under a different `original`.\n *\n * @param input - Candidate identifier (username, domain, org name, package name).\n * @returns An {@link IdentifierSecurityResult}. Never throws.\n *\n * @example Collision check at registration time\n * ```ts\n * const candidate = analyzeIdentifier(requestedUsername);\n * if (await skeletonIndex.has(candidate.skeleton)) {\n * return { error: \"That name is too similar to an existing account\" };\n * }\n * await accounts.create({ display: candidate.original, skeleton: candidate.skeleton });\n * ```\n */\nexport function analyzeIdentifier(input: string): IdentifierSecurityResult {\n\tconst original = typeof input === \"string\" ? input : \"\";\n\tconst normalized = original.normalize(\"NFC\");\n\tconst scripts = getScripts(normalized);\n\tconst restrictionLevel = getRestrictionLevel(normalized);\n\n\t// `skeleton` is computed on first read rather than eagerly. It is the single most\n\t// expensive field — an NFD normalize, two regex passes, a per-code-point fold, then\n\t// an NFC normalize — and the callers that dominate volume never read it:\n\t// `containsHomoglyphs`, reached by default from `detectThreatPatterns`/`isSafeInput`,\n\t// uses only `isMixedScript`, `hasBidiControls` and `scripts`. Memoized, so the\n\t// registration-time collision check that does read it pays exactly once.\n\tlet memoizedSkeleton: string | undefined;\n\n\treturn {\n\t\toriginal,\n\t\tnormalized,\n\t\tget skeleton(): string {\n\t\t\tmemoizedSkeleton ??= getSkeleton(normalized);\n\t\t\treturn memoizedSkeleton;\n\t\t},\n\t\tscripts,\n\t\tisMixedScript:\n\t\t\tscripts.length > 1 &&\n\t\t\trestrictionLevel !== \"highly_restrictive\" &&\n\t\t\trestrictionLevel !== \"single_script\",\n\t\trestrictionLevel,\n\t\thasInvisibleCharacters: containsInvisibleCharacters(original),\n\t\thasBidiControls: containsBidiControls(original),\n\t};\n}\n\n/**\n * Policy check over {@link analyzeIdentifier}.\n *\n * @param input - Candidate identifier.\n * @param maximumLevel - Least restrictive level to accept. Defaults to\n * `\"moderately_restrictive\"`, which admits ordinary multilingual identifiers while\n * rejecting Latin/Cyrillic and Latin/Greek mixes.\n * @returns `true` when the identifier sits at or below `maximumLevel` and carries no\n * bidirectional controls.\n */\nexport function isSafeIdentifier(\n\tinput: string,\n\tmaximumLevel: IdentifierRestrictionLevel = \"moderately_restrictive\",\n): boolean {\n\tconst analysis = analyzeIdentifier(input);\n\tif (analysis.hasBidiControls) return false;\n\treturn RESTRICTION_ORDER[analysis.restrictionLevel] <= RESTRICTION_ORDER[maximumLevel];\n}\n\n//#endregion\n"],"mappings":"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"}