@resq-systems/security 1.0.5 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +236 -33
- package/lib/controls/address.d.mts +142 -0
- package/lib/controls/address.d.mts.map +1 -0
- package/lib/controls/address.mjs +533 -0
- package/lib/controls/address.mjs.map +1 -0
- package/lib/controls/csrf.d.mts +91 -0
- package/lib/controls/csrf.d.mts.map +1 -0
- package/lib/controls/csrf.mjs +200 -0
- package/lib/controls/csrf.mjs.map +1 -0
- package/lib/controls/index.d.mts +8 -0
- package/lib/controls/index.mjs +8 -0
- package/lib/controls/origin.d.mts +95 -0
- package/lib/controls/origin.d.mts.map +1 -0
- package/lib/controls/origin.mjs +156 -0
- package/lib/controls/origin.mjs.map +1 -0
- package/lib/controls/payload.d.mts +84 -0
- package/lib/controls/payload.d.mts.map +1 -0
- package/lib/controls/payload.mjs +147 -0
- package/lib/controls/payload.mjs.map +1 -0
- package/lib/controls/query.d.mts +169 -0
- package/lib/controls/query.d.mts.map +1 -0
- package/lib/controls/query.mjs +386 -0
- package/lib/controls/query.mjs.map +1 -0
- package/lib/controls/redirect.d.mts +92 -0
- package/lib/controls/redirect.d.mts.map +1 -0
- package/lib/controls/redirect.mjs +110 -0
- package/lib/controls/redirect.mjs.map +1 -0
- package/lib/controls/upload.d.mts +108 -0
- package/lib/controls/upload.d.mts.map +1 -0
- package/lib/controls/upload.mjs +374 -0
- package/lib/controls/upload.mjs.map +1 -0
- package/lib/crypto.d.mts +18 -5
- package/lib/crypto.d.mts.map +1 -1
- package/lib/crypto.mjs +35 -24
- package/lib/crypto.mjs.map +1 -1
- package/lib/hash.d.mts +51 -6
- package/lib/hash.d.mts.map +1 -1
- package/lib/hash.mjs +51 -6
- package/lib/hash.mjs.map +1 -1
- package/lib/index.d.mts +17 -2
- package/lib/index.mjs +19 -2
- package/lib/paths.d.mts +92 -0
- package/lib/paths.d.mts.map +1 -0
- package/lib/paths.mjs +140 -0
- package/lib/paths.mjs.map +1 -0
- package/lib/sanitize.d.mts +137 -35
- package/lib/sanitize.d.mts.map +1 -1
- package/lib/sanitize.mjs +170 -46
- package/lib/sanitize.mjs.map +1 -1
- package/lib/threats/capec.generated.d.mts +59 -0
- package/lib/threats/capec.generated.d.mts.map +1 -0
- package/lib/threats/capec.generated.mjs +644 -0
- package/lib/threats/capec.generated.mjs.map +1 -0
- package/lib/threats/engine.d.mts +94 -0
- package/lib/threats/engine.d.mts.map +1 -0
- package/lib/threats/engine.mjs +167 -0
- package/lib/threats/engine.mjs.map +1 -0
- package/lib/threats/index.d.mts +11 -0
- package/lib/threats/index.mjs +11 -0
- package/lib/threats/rules/datastore.d.mts +13 -0
- package/lib/threats/rules/datastore.d.mts.map +1 -0
- package/lib/threats/rules/datastore.mjs +366 -0
- package/lib/threats/rules/datastore.mjs.map +1 -0
- package/lib/threats/rules/index.d.mts +54 -0
- package/lib/threats/rules/index.d.mts.map +1 -0
- package/lib/threats/rules/index.mjs +121 -0
- package/lib/threats/rules/index.mjs.map +1 -0
- package/lib/threats/rules/markup.d.mts +28 -0
- package/lib/threats/rules/markup.d.mts.map +1 -0
- package/lib/threats/rules/markup.mjs +373 -0
- package/lib/threats/rules/markup.mjs.map +1 -0
- package/lib/threats/rules/protocol.d.mts +49 -0
- package/lib/threats/rules/protocol.d.mts.map +1 -0
- package/lib/threats/rules/protocol.mjs +175 -0
- package/lib/threats/rules/protocol.mjs.map +1 -0
- package/lib/threats/rules/system.d.mts +19 -0
- package/lib/threats/rules/system.d.mts.map +1 -0
- package/lib/threats/rules/system.mjs +455 -0
- package/lib/threats/rules/system.mjs.map +1 -0
- package/lib/threats/rules/web.d.mts +26 -0
- package/lib/threats/rules/web.d.mts.map +1 -0
- package/lib/threats/rules/web.mjs +412 -0
- package/lib/threats/rules/web.mjs.map +1 -0
- package/lib/threats/scoring.d.mts +59 -0
- package/lib/threats/scoring.d.mts.map +1 -0
- package/lib/threats/scoring.mjs +111 -0
- package/lib/threats/scoring.mjs.map +1 -0
- package/lib/threats/types.d.mts +245 -0
- package/lib/threats/types.d.mts.map +1 -0
- package/lib/threats/types.mjs +52 -0
- package/lib/threats/types.mjs.map +1 -0
- package/lib/threats/variants.d.mts +57 -0
- package/lib/threats/variants.d.mts.map +1 -0
- package/lib/threats/variants.mjs +144 -0
- package/lib/threats/variants.mjs.map +1 -0
- package/lib/unicode/confusables.d.mts +82 -0
- package/lib/unicode/confusables.d.mts.map +1 -0
- package/lib/unicode/confusables.mjs +954 -0
- package/lib/unicode/confusables.mjs.map +1 -0
- package/lib/unicode/index.d.mts +126 -0
- package/lib/unicode/index.d.mts.map +1 -0
- package/lib/unicode/index.mjs +288 -0
- package/lib/unicode/index.mjs.map +1 -0
- package/lib/validators.d.mts +341 -164
- package/lib/validators.d.mts.map +1 -1
- package/lib/validators.mjs +519 -338
- package/lib/validators.mjs.map +1 -1
- package/package.json +35 -8
|
@@ -0,0 +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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"}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { CONFUSABLE_MAP, areConfusable, foldConfusables, getSkeleton } from "./confusables.mjs";
|
|
2
|
+
//#region src/unicode/index.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Scripts the analyzer distinguishes.
|
|
5
|
+
*
|
|
6
|
+
* Not the full ISO 15924 list — it covers the scripts that actually appear in
|
|
7
|
+
* identifier-spoofing work, plus the CJK set needed to model UTS #39's Highly
|
|
8
|
+
* Restrictive level correctly.
|
|
9
|
+
*/
|
|
10
|
+
type UnicodeScript = "Latin" | "Greek" | "Cyrillic" | "Armenian" | "Hebrew" | "Arabic" | "Devanagari" | "Bengali" | "Tamil" | "Thai" | "Georgian" | "Han" | "Hiragana" | "Katakana" | "Hangul" | "Bopomofo" | "Cherokee" | "Other";
|
|
11
|
+
/**
|
|
12
|
+
* Identify the scripts present in a string.
|
|
13
|
+
*
|
|
14
|
+
* Script-neutral characters are ignored, so `alice-99` reports `["Latin"]` rather
|
|
15
|
+
* than mixing in a phantom numeric script.
|
|
16
|
+
*
|
|
17
|
+
* @param input - String to analyze.
|
|
18
|
+
* @returns Scripts present, in {@link SCRIPT_PATTERNS} order. Includes `"Other"` when
|
|
19
|
+
* characters remain that match no known detector; `[]` for an entirely
|
|
20
|
+
* script-neutral string.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* getScripts("paypal"); // ["Latin"]
|
|
25
|
+
* getScripts("pаypal"); // ["Latin", "Cyrillic"] ← the spoof
|
|
26
|
+
* getScripts("東京タワー"); // ["Han", "Katakana"] ← ordinary Japanese
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
declare function getScripts(input: string): readonly UnicodeScript[];
|
|
30
|
+
/**
|
|
31
|
+
* UTS #39 §5.2 identifier restriction levels, ordered most to least restrictive.
|
|
32
|
+
*
|
|
33
|
+
* Use as a policy dial rather than a boolean. A payments product might require
|
|
34
|
+
* `highly_restrictive` for merchant display names; a global social product might
|
|
35
|
+
* accept `moderately_restrictive` and merely alert on the rest.
|
|
36
|
+
*/
|
|
37
|
+
type IdentifierRestrictionLevel = "ascii_only" | "single_script" | "highly_restrictive" | "moderately_restrictive" | "minimally_restrictive" | "unrestricted";
|
|
38
|
+
/**
|
|
39
|
+
* Classify a string against the UTS #39 restriction levels.
|
|
40
|
+
*
|
|
41
|
+
* @param input - String to classify.
|
|
42
|
+
* @returns The most restrictive level the string satisfies.
|
|
43
|
+
*/
|
|
44
|
+
declare function getRestrictionLevel(input: string): IdentifierRestrictionLevel;
|
|
45
|
+
/**
|
|
46
|
+
* Detect bidirectional override characters.
|
|
47
|
+
*
|
|
48
|
+
* Unlike the confusable checks, this is safe to run on **any** field. A bidi control
|
|
49
|
+
* makes rendered text differ from its logical order — the "Trojan Source" technique
|
|
50
|
+
* (CVE-2021-42574) — and no legitimate stored value needs one.
|
|
51
|
+
*
|
|
52
|
+
* @param input - String to test.
|
|
53
|
+
* @returns `true` when a bidi embedding, override, or isolate control is present.
|
|
54
|
+
*/
|
|
55
|
+
declare function containsBidiControls(input: string): boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Detect zero-width and other invisible formatting characters.
|
|
58
|
+
*
|
|
59
|
+
* Note that U+200C/U+200D (ZWNJ/ZWJ) are required for correct rendering of Persian,
|
|
60
|
+
* Hindi, and several other scripts, and appear in ordinary emoji sequences. Treat a
|
|
61
|
+
* hit as a reason to compare skeletons, not as grounds for rejection.
|
|
62
|
+
*
|
|
63
|
+
* @param input - String to test.
|
|
64
|
+
* @returns `true` when an invisible formatting character is present.
|
|
65
|
+
*/
|
|
66
|
+
declare function containsInvisibleCharacters(input: string): boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Remove invisible formatting and bidirectional control characters.
|
|
69
|
+
*
|
|
70
|
+
* @param input - String to clean.
|
|
71
|
+
* @returns The string without those code points. Non-string input yields `""`.
|
|
72
|
+
*/
|
|
73
|
+
declare function stripInvisibleCharacters(input: string): string;
|
|
74
|
+
/** Everything the analyzer can say about one identifier. */
|
|
75
|
+
interface IdentifierSecurityResult {
|
|
76
|
+
/** The input, unchanged. Keep displaying this — never the skeleton. */
|
|
77
|
+
readonly original: string;
|
|
78
|
+
/** NFC-composed form. Safe to store and display. */
|
|
79
|
+
readonly normalized: string;
|
|
80
|
+
/** Opaque confusable comparison key. Compare it, never render it. */
|
|
81
|
+
readonly skeleton: string;
|
|
82
|
+
/** Scripts present, script-neutral characters excluded. */
|
|
83
|
+
readonly scripts: readonly UnicodeScript[];
|
|
84
|
+
/** `true` when several scripts are present and the mix is not an ordinary CJK one. */
|
|
85
|
+
readonly isMixedScript: boolean;
|
|
86
|
+
/** UTS #39 restriction level. */
|
|
87
|
+
readonly restrictionLevel: IdentifierRestrictionLevel;
|
|
88
|
+
/** `true` when a zero-width or invisible formatting character is present. */
|
|
89
|
+
readonly hasInvisibleCharacters: boolean;
|
|
90
|
+
/** `true` when a bidirectional control is present. Hostile in any field. */
|
|
91
|
+
readonly hasBidiControls: boolean;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Analyze a protected identifier.
|
|
95
|
+
*
|
|
96
|
+
* Read the fields; do not read a verdict — the caller's policy decides what to do.
|
|
97
|
+
* The canonical use is: store `original` for display, index `skeleton`, and refuse a
|
|
98
|
+
* registration whose skeleton already exists under a different `original`.
|
|
99
|
+
*
|
|
100
|
+
* @param input - Candidate identifier (username, domain, org name, package name).
|
|
101
|
+
* @returns An {@link IdentifierSecurityResult}. Never throws.
|
|
102
|
+
*
|
|
103
|
+
* @example Collision check at registration time
|
|
104
|
+
* ```ts
|
|
105
|
+
* const candidate = analyzeIdentifier(requestedUsername);
|
|
106
|
+
* if (await skeletonIndex.has(candidate.skeleton)) {
|
|
107
|
+
* return { error: "That name is too similar to an existing account" };
|
|
108
|
+
* }
|
|
109
|
+
* await accounts.create({ display: candidate.original, skeleton: candidate.skeleton });
|
|
110
|
+
* ```
|
|
111
|
+
*/
|
|
112
|
+
declare function analyzeIdentifier(input: string): IdentifierSecurityResult;
|
|
113
|
+
/**
|
|
114
|
+
* Policy check over {@link analyzeIdentifier}.
|
|
115
|
+
*
|
|
116
|
+
* @param input - Candidate identifier.
|
|
117
|
+
* @param maximumLevel - Least restrictive level to accept. Defaults to
|
|
118
|
+
* `"moderately_restrictive"`, which admits ordinary multilingual identifiers while
|
|
119
|
+
* rejecting Latin/Cyrillic and Latin/Greek mixes.
|
|
120
|
+
* @returns `true` when the identifier sits at or below `maximumLevel` and carries no
|
|
121
|
+
* bidirectional controls.
|
|
122
|
+
*/
|
|
123
|
+
declare function isSafeIdentifier(input: string, maximumLevel?: IdentifierRestrictionLevel): boolean;
|
|
124
|
+
//#endregion
|
|
125
|
+
export { CONFUSABLE_MAP, IdentifierRestrictionLevel, IdentifierSecurityResult, UnicodeScript, analyzeIdentifier, areConfusable, containsBidiControls, containsInvisibleCharacters, foldConfusables, getRestrictionLevel, getScripts, getSkeleton, isSafeIdentifier, stripInvisibleCharacters };
|
|
126
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { CONFUSABLE_MAP, areConfusable, foldConfusables, getSkeleton } from "./confusables.mjs";
|
|
2
|
+
//#region src/unicode/index.ts
|
|
3
|
+
/**
|
|
4
|
+
* Copyright 2026 ResQ Systems, Inc.
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* @fileoverview UTS #39 identifier security: script analysis, mixed-script detection,
|
|
20
|
+
* restriction levels, and invisible/bidirectional control detection.
|
|
21
|
+
*
|
|
22
|
+
* **Scope matters more here than anywhere else in this package.** UTS #39 itself warns
|
|
23
|
+
* that broad confusable detection flags a great many legitimate strings. These
|
|
24
|
+
* functions belong on *protected identifiers* — usernames, domain names, organization
|
|
25
|
+
* names, package names, brand-adjacent labels — where a visual collision is the whole
|
|
26
|
+
* attack. They do not belong on prose, on postal addresses, and emphatically not on
|
|
27
|
+
* people's names: "Ольга Иванова" is single-script Cyrillic, and rejecting it is not a
|
|
28
|
+
* security control, it is a bug. Use `validatePersonName` for names.
|
|
29
|
+
*
|
|
30
|
+
* The one exception is {@link containsBidiControls}, which is safe to apply anywhere:
|
|
31
|
+
* a bidirectional override in user input is the "Trojan Source" class (CVE-2021-42574)
|
|
32
|
+
* and has no legitimate use in a stored field.
|
|
33
|
+
*
|
|
34
|
+
* @module @resq-systems/security/unicode
|
|
35
|
+
*/
|
|
36
|
+
/**
|
|
37
|
+
* Detectors keyed by script.
|
|
38
|
+
*
|
|
39
|
+
* `Script_Extensions` rather than `Script`: a character shared between scripts — the
|
|
40
|
+
* Japanese iteration mark, say — should count for every script that uses it rather
|
|
41
|
+
* than being forced into one. That is precisely what keeps ordinary Japanese from
|
|
42
|
+
* scoring as mixed-script.
|
|
43
|
+
*/
|
|
44
|
+
const SCRIPT_PATTERNS = [
|
|
45
|
+
["Latin", /\p{Script_Extensions=Latin}/u],
|
|
46
|
+
["Greek", /\p{Script_Extensions=Greek}/u],
|
|
47
|
+
["Cyrillic", /\p{Script_Extensions=Cyrillic}/u],
|
|
48
|
+
["Armenian", /\p{Script_Extensions=Armenian}/u],
|
|
49
|
+
["Hebrew", /\p{Script_Extensions=Hebrew}/u],
|
|
50
|
+
["Arabic", /\p{Script_Extensions=Arabic}/u],
|
|
51
|
+
["Devanagari", /\p{Script_Extensions=Devanagari}/u],
|
|
52
|
+
["Bengali", /\p{Script_Extensions=Bengali}/u],
|
|
53
|
+
["Tamil", /\p{Script_Extensions=Tamil}/u],
|
|
54
|
+
["Thai", /\p{Script_Extensions=Thai}/u],
|
|
55
|
+
["Georgian", /\p{Script_Extensions=Georgian}/u],
|
|
56
|
+
["Han", /\p{Script_Extensions=Han}/u],
|
|
57
|
+
["Hiragana", /\p{Script_Extensions=Hiragana}/u],
|
|
58
|
+
["Katakana", /\p{Script_Extensions=Katakana}/u],
|
|
59
|
+
["Hangul", /\p{Script_Extensions=Hangul}/u],
|
|
60
|
+
["Bopomofo", /\p{Script_Extensions=Bopomofo}/u],
|
|
61
|
+
["Cherokee", /\p{Script_Extensions=Cherokee}/u]
|
|
62
|
+
];
|
|
63
|
+
/**
|
|
64
|
+
* Characters belonging to no specific script — digits, punctuation, spaces, symbols,
|
|
65
|
+
* format and control codes. Removed before analysis, since `user.name-1` is not
|
|
66
|
+
* mixed-script.
|
|
67
|
+
*/
|
|
68
|
+
const SCRIPT_NEUTRAL = /[\p{White_Space}\p{P}\p{S}\p{N}\p{Cf}\p{Cc}]/gu;
|
|
69
|
+
/**
|
|
70
|
+
* Identify the scripts present in a string.
|
|
71
|
+
*
|
|
72
|
+
* Script-neutral characters are ignored, so `alice-99` reports `["Latin"]` rather
|
|
73
|
+
* than mixing in a phantom numeric script.
|
|
74
|
+
*
|
|
75
|
+
* @param input - String to analyze.
|
|
76
|
+
* @returns Scripts present, in {@link SCRIPT_PATTERNS} order. Includes `"Other"` when
|
|
77
|
+
* characters remain that match no known detector; `[]` for an entirely
|
|
78
|
+
* script-neutral string.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* ```ts
|
|
82
|
+
* getScripts("paypal"); // ["Latin"]
|
|
83
|
+
* getScripts("pаypal"); // ["Latin", "Cyrillic"] ← the spoof
|
|
84
|
+
* getScripts("東京タワー"); // ["Han", "Katakana"] ← ordinary Japanese
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
function getScripts(input) {
|
|
88
|
+
if (!input || typeof input !== "string") return [];
|
|
89
|
+
const meaningful = input.normalize("NFC").replace(SCRIPT_NEUTRAL, "");
|
|
90
|
+
if (meaningful.length === 0) return [];
|
|
91
|
+
const found = [];
|
|
92
|
+
let uncovered = meaningful;
|
|
93
|
+
for (const [script, pattern] of SCRIPT_PATTERNS) if (pattern.test(meaningful)) {
|
|
94
|
+
found.push(script);
|
|
95
|
+
uncovered = uncovered.replace(new RegExp(pattern.source, "gu"), "");
|
|
96
|
+
}
|
|
97
|
+
if (uncovered.length > 0) found.push("Other");
|
|
98
|
+
return found;
|
|
99
|
+
}
|
|
100
|
+
/** Ordering for policy comparison. Lower is more restrictive. */
|
|
101
|
+
const RESTRICTION_ORDER = {
|
|
102
|
+
ascii_only: 0,
|
|
103
|
+
single_script: 1,
|
|
104
|
+
highly_restrictive: 2,
|
|
105
|
+
moderately_restrictive: 3,
|
|
106
|
+
minimally_restrictive: 4,
|
|
107
|
+
unrestricted: 5
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* Script combinations UTS #39 treats as Highly Restrictive despite spanning several
|
|
111
|
+
* scripts, because each is simply how a major writing system is written.
|
|
112
|
+
*/
|
|
113
|
+
const HIGHLY_RESTRICTIVE_COMBINATIONS = [
|
|
114
|
+
/* @__PURE__ */ new Set([
|
|
115
|
+
"Latin",
|
|
116
|
+
"Han",
|
|
117
|
+
"Hiragana",
|
|
118
|
+
"Katakana"
|
|
119
|
+
]),
|
|
120
|
+
/* @__PURE__ */ new Set([
|
|
121
|
+
"Latin",
|
|
122
|
+
"Han",
|
|
123
|
+
"Bopomofo"
|
|
124
|
+
]),
|
|
125
|
+
/* @__PURE__ */ new Set([
|
|
126
|
+
"Latin",
|
|
127
|
+
"Han",
|
|
128
|
+
"Hangul"
|
|
129
|
+
])
|
|
130
|
+
];
|
|
131
|
+
/**
|
|
132
|
+
* Scripts whose Latin lookalikes are numerous enough that pairing them with Latin
|
|
133
|
+
* reads as suspicious rather than merely multilingual — the basis of UTS #39's
|
|
134
|
+
* Moderately Restrictive exclusion list.
|
|
135
|
+
*/
|
|
136
|
+
const HIGH_CONFUSION_SCRIPTS = /* @__PURE__ */ new Set([
|
|
137
|
+
"Cyrillic",
|
|
138
|
+
"Greek",
|
|
139
|
+
"Cherokee"
|
|
140
|
+
]);
|
|
141
|
+
/** True when every code unit is ASCII. */
|
|
142
|
+
function isAsciiOnly(input) {
|
|
143
|
+
for (let i = 0; i < input.length; i++) if (input.charCodeAt(i) > 127) return false;
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Classify a string against the UTS #39 restriction levels.
|
|
148
|
+
*
|
|
149
|
+
* @param input - String to classify.
|
|
150
|
+
* @returns The most restrictive level the string satisfies.
|
|
151
|
+
*/
|
|
152
|
+
function getRestrictionLevel(input) {
|
|
153
|
+
if (!input || typeof input !== "string") return "ascii_only";
|
|
154
|
+
if (containsBidiControls(input) || HOSTILE_INVISIBLES.test(input)) return "unrestricted";
|
|
155
|
+
if (isAsciiOnly(input)) return "ascii_only";
|
|
156
|
+
const scripts = getScripts(input);
|
|
157
|
+
if (scripts.includes("Other")) return "minimally_restrictive";
|
|
158
|
+
if (scripts.length <= 1) return "single_script";
|
|
159
|
+
const present = new Set(scripts);
|
|
160
|
+
for (const combination of HIGHLY_RESTRICTIVE_COMBINATIONS) {
|
|
161
|
+
let contained = true;
|
|
162
|
+
for (const script of present) if (!combination.has(script)) {
|
|
163
|
+
contained = false;
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
if (contained) return "highly_restrictive";
|
|
167
|
+
}
|
|
168
|
+
if (present.has("Latin") && present.size === 2) {
|
|
169
|
+
const other = scripts.find((script) => script !== "Latin");
|
|
170
|
+
if (other !== void 0 && !HIGH_CONFUSION_SCRIPTS.has(other)) return "moderately_restrictive";
|
|
171
|
+
}
|
|
172
|
+
return "minimally_restrictive";
|
|
173
|
+
}
|
|
174
|
+
/** Bidirectional embedding, override, and isolate controls. */
|
|
175
|
+
const BIDI_CONTROLS = /[\u202a-\u202e\u2066-\u2069]/;
|
|
176
|
+
/** Zero-width, soft-hyphen, and word-joiner code points. */
|
|
177
|
+
const INVISIBLE_CHARACTERS = /[\u00ad\u200b-\u200f\u2060-\u2064\ufeff]/;
|
|
178
|
+
/**
|
|
179
|
+
* The subset of {@link INVISIBLE_CHARACTERS} with no legitimate use in an identifier.
|
|
180
|
+
*
|
|
181
|
+
* U+200C (ZWNJ) and U+200D (ZWJ) are deliberately absent. They are not decoration:
|
|
182
|
+
* Persian, Hindi, and other scripts need them to write ordinary words and names
|
|
183
|
+
* correctly \u2014 `\u0645\u06cc\u200c\u0631\u0648\u0645` is spelled with a ZWNJ \u2014 and they appear in everyday emoji
|
|
184
|
+
* sequences. Demoting on their presence rejects a correctly spelled Persian identifier
|
|
185
|
+
* at the default level, which is a worse outcome than the spoofing risk they carry,
|
|
186
|
+
* and that risk is already covered: {@link getSkeleton} strips them before comparison,
|
|
187
|
+
* so a joiner cannot hide a confusable. {@link containsInvisibleCharacters} still
|
|
188
|
+
* reports them, because a caller may reasonably want to know they are there.
|
|
189
|
+
*/
|
|
190
|
+
const HOSTILE_INVISIBLES = /[\u00ad\u200b\u200e\u200f\u2060-\u2064\ufeff]/;
|
|
191
|
+
/** Global variant used for stripping both families at once. */
|
|
192
|
+
const REMOVABLE_FORMATTING = /[\u00ad\u200b-\u200f\u2060-\u2064\ufeff\u202a-\u202e\u2066-\u2069]/g;
|
|
193
|
+
/**
|
|
194
|
+
* Detect bidirectional override characters.
|
|
195
|
+
*
|
|
196
|
+
* Unlike the confusable checks, this is safe to run on **any** field. A bidi control
|
|
197
|
+
* makes rendered text differ from its logical order — the "Trojan Source" technique
|
|
198
|
+
* (CVE-2021-42574) — and no legitimate stored value needs one.
|
|
199
|
+
*
|
|
200
|
+
* @param input - String to test.
|
|
201
|
+
* @returns `true` when a bidi embedding, override, or isolate control is present.
|
|
202
|
+
*/
|
|
203
|
+
function containsBidiControls(input) {
|
|
204
|
+
if (!input || typeof input !== "string") return false;
|
|
205
|
+
return BIDI_CONTROLS.test(input);
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Detect zero-width and other invisible formatting characters.
|
|
209
|
+
*
|
|
210
|
+
* Note that U+200C/U+200D (ZWNJ/ZWJ) are required for correct rendering of Persian,
|
|
211
|
+
* Hindi, and several other scripts, and appear in ordinary emoji sequences. Treat a
|
|
212
|
+
* hit as a reason to compare skeletons, not as grounds for rejection.
|
|
213
|
+
*
|
|
214
|
+
* @param input - String to test.
|
|
215
|
+
* @returns `true` when an invisible formatting character is present.
|
|
216
|
+
*/
|
|
217
|
+
function containsInvisibleCharacters(input) {
|
|
218
|
+
if (!input || typeof input !== "string") return false;
|
|
219
|
+
return INVISIBLE_CHARACTERS.test(input);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Remove invisible formatting and bidirectional control characters.
|
|
223
|
+
*
|
|
224
|
+
* @param input - String to clean.
|
|
225
|
+
* @returns The string without those code points. Non-string input yields `""`.
|
|
226
|
+
*/
|
|
227
|
+
function stripInvisibleCharacters(input) {
|
|
228
|
+
if (!input || typeof input !== "string") return "";
|
|
229
|
+
return input.replace(REMOVABLE_FORMATTING, "");
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Analyze a protected identifier.
|
|
233
|
+
*
|
|
234
|
+
* Read the fields; do not read a verdict — the caller's policy decides what to do.
|
|
235
|
+
* The canonical use is: store `original` for display, index `skeleton`, and refuse a
|
|
236
|
+
* registration whose skeleton already exists under a different `original`.
|
|
237
|
+
*
|
|
238
|
+
* @param input - Candidate identifier (username, domain, org name, package name).
|
|
239
|
+
* @returns An {@link IdentifierSecurityResult}. Never throws.
|
|
240
|
+
*
|
|
241
|
+
* @example Collision check at registration time
|
|
242
|
+
* ```ts
|
|
243
|
+
* const candidate = analyzeIdentifier(requestedUsername);
|
|
244
|
+
* if (await skeletonIndex.has(candidate.skeleton)) {
|
|
245
|
+
* return { error: "That name is too similar to an existing account" };
|
|
246
|
+
* }
|
|
247
|
+
* await accounts.create({ display: candidate.original, skeleton: candidate.skeleton });
|
|
248
|
+
* ```
|
|
249
|
+
*/
|
|
250
|
+
function analyzeIdentifier(input) {
|
|
251
|
+
const original = typeof input === "string" ? input : "";
|
|
252
|
+
const normalized = original.normalize("NFC");
|
|
253
|
+
const scripts = getScripts(normalized);
|
|
254
|
+
const restrictionLevel = getRestrictionLevel(normalized);
|
|
255
|
+
let memoizedSkeleton;
|
|
256
|
+
return {
|
|
257
|
+
original,
|
|
258
|
+
normalized,
|
|
259
|
+
get skeleton() {
|
|
260
|
+
memoizedSkeleton ??= getSkeleton(normalized);
|
|
261
|
+
return memoizedSkeleton;
|
|
262
|
+
},
|
|
263
|
+
scripts,
|
|
264
|
+
isMixedScript: scripts.length > 1 && restrictionLevel !== "highly_restrictive" && restrictionLevel !== "single_script",
|
|
265
|
+
restrictionLevel,
|
|
266
|
+
hasInvisibleCharacters: containsInvisibleCharacters(original),
|
|
267
|
+
hasBidiControls: containsBidiControls(original)
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Policy check over {@link analyzeIdentifier}.
|
|
272
|
+
*
|
|
273
|
+
* @param input - Candidate identifier.
|
|
274
|
+
* @param maximumLevel - Least restrictive level to accept. Defaults to
|
|
275
|
+
* `"moderately_restrictive"`, which admits ordinary multilingual identifiers while
|
|
276
|
+
* rejecting Latin/Cyrillic and Latin/Greek mixes.
|
|
277
|
+
* @returns `true` when the identifier sits at or below `maximumLevel` and carries no
|
|
278
|
+
* bidirectional controls.
|
|
279
|
+
*/
|
|
280
|
+
function isSafeIdentifier(input, maximumLevel = "moderately_restrictive") {
|
|
281
|
+
const analysis = analyzeIdentifier(input);
|
|
282
|
+
if (analysis.hasBidiControls) return false;
|
|
283
|
+
return RESTRICTION_ORDER[analysis.restrictionLevel] <= RESTRICTION_ORDER[maximumLevel];
|
|
284
|
+
}
|
|
285
|
+
//#endregion
|
|
286
|
+
export { CONFUSABLE_MAP, analyzeIdentifier, areConfusable, containsBidiControls, containsInvisibleCharacters, foldConfusables, getRestrictionLevel, getScripts, getSkeleton, isSafeIdentifier, stripInvisibleCharacters };
|
|
287
|
+
|
|
288
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +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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"}
|