@crewhaus/tool-secure 0.7.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 +273 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +1006 -0
- package/dist/lib/entropy.d.ts +65 -0
- package/dist/lib/entropy.js +99 -0
- package/dist/lib/evidence.d.ts +55 -0
- package/dist/lib/evidence.js +134 -0
- package/dist/lib/files.d.ts +71 -0
- package/dist/lib/files.js +227 -0
- package/dist/lib/injection.d.ts +38 -0
- package/dist/lib/injection.js +396 -0
- package/dist/lib/pii.d.ts +91 -0
- package/dist/lib/pii.js +757 -0
- package/dist/lib/policy.d.ts +42 -0
- package/dist/lib/policy.js +138 -0
- package/dist/lib/redact.d.ts +41 -0
- package/dist/lib/redact.js +121 -0
- package/dist/lib/secrets.d.ts +111 -0
- package/dist/lib/secrets.js +329 -0
- package/dist/lib/text.d.ts +129 -0
- package/dist/lib/text.js +191 -0
- package/dist/lib/unicode.d.ts +106 -0
- package/dist/lib/unicode.js +431 -0
- package/dist/lib/url.d.ts +35 -0
- package/dist/lib/url.js +363 -0
- package/dist/paths.d.ts +42 -0
- package/dist/paths.js +158 -0
- package/package.json +45 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The characters a human reviewer cannot see, and the ones they see wrong.
|
|
3
|
+
*
|
|
4
|
+
* Two different attacks share this file. Invisible characters hide a payload
|
|
5
|
+
* from the person reviewing a document; confusable characters show that
|
|
6
|
+
* person one thing while the bytes say another (`раypal.com` with a Cyrillic
|
|
7
|
+
* `р`). Both are structural, so both can be detected without judgement —
|
|
8
|
+
* which is the only reason they belong in a deterministic package.
|
|
9
|
+
*
|
|
10
|
+
* ## Confusable folding: the exact pipeline
|
|
11
|
+
*
|
|
12
|
+
* Per code point, in order, with offsets preserved because every step maps
|
|
13
|
+
* ONE source code point to a replacement string:
|
|
14
|
+
*
|
|
15
|
+
* 1. ASCII (U+0000–U+007F) is passed through untouched.
|
|
16
|
+
* 2. An entry in `CONFUSABLES` below wins. That table is hand-curated for
|
|
17
|
+
* the Latin-lookalike code points in Cyrillic, Greek and a few symbol
|
|
18
|
+
* blocks — the ones that actually turn up in domain and identifier
|
|
19
|
+
* spoofing.
|
|
20
|
+
* 3. Otherwise the code point is normalized NFKD and any combining marks
|
|
21
|
+
* (U+0300–U+036F) are dropped; if what remains is pure printable ASCII
|
|
22
|
+
* it is used. This is what folds `é`→`e`, `fi`→`fi`, `A`→`A`, `𝐀`→`A`
|
|
23
|
+
* and `①`→`1`, without a table.
|
|
24
|
+
* 4. Anything left is reported as `unfolded` and passed through unchanged.
|
|
25
|
+
*
|
|
26
|
+
* ## What this is NOT
|
|
27
|
+
*
|
|
28
|
+
* It is not UTS #39. The full Unicode confusables data is thousands of
|
|
29
|
+
* mappings across every script; `CONFUSABLES` is a curated subset and step 3
|
|
30
|
+
* is an approximation of it. A string this function leaves alone is not
|
|
31
|
+
* thereby "safe" — `mixedScriptRuns` exists because whole-script confusables
|
|
32
|
+
* (a domain written entirely in Cyrillic) fold to nothing at all and are
|
|
33
|
+
* only visible as a script mismatch.
|
|
34
|
+
*/
|
|
35
|
+
export type InvisibleClass = "zero-width" | "bidi-control" | "soft-hyphen" | "variation-selector" | "tag" | "format-other" | "control" | "unusual-space" | "private-use";
|
|
36
|
+
export type InvisibleHit = {
|
|
37
|
+
readonly codePoint: number;
|
|
38
|
+
/** `U+200B` form, for grepping and for issue reports. */
|
|
39
|
+
readonly label: string;
|
|
40
|
+
readonly name: string;
|
|
41
|
+
readonly class: InvisibleClass;
|
|
42
|
+
readonly start: number;
|
|
43
|
+
readonly end: number;
|
|
44
|
+
};
|
|
45
|
+
/** `U+200B`-style label. */
|
|
46
|
+
export declare function codePointLabel(cp: number): string;
|
|
47
|
+
/** Every invisible or deceptive-whitespace code point, in document order. */
|
|
48
|
+
export declare function scanInvisible(text: string): InvisibleHit[];
|
|
49
|
+
/**
|
|
50
|
+
* Bidi controls that are opened and never closed within their line.
|
|
51
|
+
*
|
|
52
|
+
* This is the Trojan Source shape (CVE-2021-42574): an override opened in a
|
|
53
|
+
* comment reorders the code after it without any visible trace. Balance is
|
|
54
|
+
* checked per line because that is the unit a reviewer reads.
|
|
55
|
+
*/
|
|
56
|
+
export declare function unbalancedBidi(text: string): Array<{
|
|
57
|
+
line: number;
|
|
58
|
+
open: number;
|
|
59
|
+
close: number;
|
|
60
|
+
}>;
|
|
61
|
+
/**
|
|
62
|
+
* Hand-curated Latin lookalikes with no compatibility decomposition, so
|
|
63
|
+
* step 3 of the pipeline cannot reach them. Cyrillic and Greek dominate
|
|
64
|
+
* because they are what registrars and identifier spoofing actually use.
|
|
65
|
+
*/
|
|
66
|
+
export declare const CONFUSABLES: ReadonlyMap<string, string>;
|
|
67
|
+
export type FoldChange = {
|
|
68
|
+
readonly start: number;
|
|
69
|
+
readonly end: number;
|
|
70
|
+
readonly from: string;
|
|
71
|
+
readonly fromLabel: string;
|
|
72
|
+
readonly to: string;
|
|
73
|
+
readonly step: "confusable-table" | "nfkd-ascii";
|
|
74
|
+
};
|
|
75
|
+
export type FoldResult = {
|
|
76
|
+
readonly text: string;
|
|
77
|
+
readonly changes: ReadonlyArray<FoldChange>;
|
|
78
|
+
/** Non-ASCII code points the pipeline could not fold, with their offsets. */
|
|
79
|
+
readonly unfolded: ReadonlyArray<{
|
|
80
|
+
start: number;
|
|
81
|
+
label: string;
|
|
82
|
+
char: string;
|
|
83
|
+
}>;
|
|
84
|
+
};
|
|
85
|
+
/** Apply the documented four-step pipeline, keeping source offsets exact. */
|
|
86
|
+
export declare function foldConfusables(text: string): FoldResult;
|
|
87
|
+
export type Script = "Latin" | "Cyrillic" | "Greek" | "Armenian" | "Hebrew" | "Arabic" | "Devanagari" | "Thai" | "Georgian" | "Han" | "Hiragana" | "Katakana" | "Hangul" | "Cherokee" | "Common" | "Other";
|
|
88
|
+
/**
|
|
89
|
+
* A deliberately coarse script lookup: the blocks that matter for spoofing,
|
|
90
|
+
* with digits, ASCII punctuation and spaces as `Common` so they never make a
|
|
91
|
+
* token look mixed.
|
|
92
|
+
*/
|
|
93
|
+
export declare function scriptOf(cp: number): Script;
|
|
94
|
+
export type MixedRun = {
|
|
95
|
+
readonly text: string;
|
|
96
|
+
readonly start: number;
|
|
97
|
+
readonly scripts: ReadonlyArray<Script>;
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Runs of non-space characters that draw on more than one script.
|
|
101
|
+
*
|
|
102
|
+
* The classic phish is one Cyrillic letter inside an otherwise Latin word;
|
|
103
|
+
* that shows up here even when it folds cleanly, and it shows up for
|
|
104
|
+
* whole-script spoofs too, which fold to nothing.
|
|
105
|
+
*/
|
|
106
|
+
export declare function mixedScriptRuns(text: string): MixedRun[];
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The characters a human reviewer cannot see, and the ones they see wrong.
|
|
3
|
+
*
|
|
4
|
+
* Two different attacks share this file. Invisible characters hide a payload
|
|
5
|
+
* from the person reviewing a document; confusable characters show that
|
|
6
|
+
* person one thing while the bytes say another (`раypal.com` with a Cyrillic
|
|
7
|
+
* `р`). Both are structural, so both can be detected without judgement —
|
|
8
|
+
* which is the only reason they belong in a deterministic package.
|
|
9
|
+
*
|
|
10
|
+
* ## Confusable folding: the exact pipeline
|
|
11
|
+
*
|
|
12
|
+
* Per code point, in order, with offsets preserved because every step maps
|
|
13
|
+
* ONE source code point to a replacement string:
|
|
14
|
+
*
|
|
15
|
+
* 1. ASCII (U+0000–U+007F) is passed through untouched.
|
|
16
|
+
* 2. An entry in `CONFUSABLES` below wins. That table is hand-curated for
|
|
17
|
+
* the Latin-lookalike code points in Cyrillic, Greek and a few symbol
|
|
18
|
+
* blocks — the ones that actually turn up in domain and identifier
|
|
19
|
+
* spoofing.
|
|
20
|
+
* 3. Otherwise the code point is normalized NFKD and any combining marks
|
|
21
|
+
* (U+0300–U+036F) are dropped; if what remains is pure printable ASCII
|
|
22
|
+
* it is used. This is what folds `é`→`e`, `fi`→`fi`, `A`→`A`, `𝐀`→`A`
|
|
23
|
+
* and `①`→`1`, without a table.
|
|
24
|
+
* 4. Anything left is reported as `unfolded` and passed through unchanged.
|
|
25
|
+
*
|
|
26
|
+
* ## What this is NOT
|
|
27
|
+
*
|
|
28
|
+
* It is not UTS #39. The full Unicode confusables data is thousands of
|
|
29
|
+
* mappings across every script; `CONFUSABLES` is a curated subset and step 3
|
|
30
|
+
* is an approximation of it. A string this function leaves alone is not
|
|
31
|
+
* thereby "safe" — `mixedScriptRuns` exists because whole-script confusables
|
|
32
|
+
* (a domain written entirely in Cyrillic) fold to nothing at all and are
|
|
33
|
+
* only visible as a script mismatch.
|
|
34
|
+
*/
|
|
35
|
+
/** Named code points. Anything outside this table is classified by range. */
|
|
36
|
+
const NAMED = new Map([
|
|
37
|
+
[0x00ad, { name: "SOFT HYPHEN", class: "soft-hyphen" }],
|
|
38
|
+
[0x061c, { name: "ARABIC LETTER MARK", class: "bidi-control" }],
|
|
39
|
+
[0x180e, { name: "MONGOLIAN VOWEL SEPARATOR", class: "zero-width" }],
|
|
40
|
+
[0x200b, { name: "ZERO WIDTH SPACE", class: "zero-width" }],
|
|
41
|
+
[0x200c, { name: "ZERO WIDTH NON-JOINER", class: "zero-width" }],
|
|
42
|
+
[0x200d, { name: "ZERO WIDTH JOINER", class: "zero-width" }],
|
|
43
|
+
[0x200e, { name: "LEFT-TO-RIGHT MARK", class: "bidi-control" }],
|
|
44
|
+
[0x200f, { name: "RIGHT-TO-LEFT MARK", class: "bidi-control" }],
|
|
45
|
+
[0x202a, { name: "LEFT-TO-RIGHT EMBEDDING", class: "bidi-control" }],
|
|
46
|
+
[0x202b, { name: "RIGHT-TO-LEFT EMBEDDING", class: "bidi-control" }],
|
|
47
|
+
[0x202c, { name: "POP DIRECTIONAL FORMATTING", class: "bidi-control" }],
|
|
48
|
+
[0x202d, { name: "LEFT-TO-RIGHT OVERRIDE", class: "bidi-control" }],
|
|
49
|
+
[0x202e, { name: "RIGHT-TO-LEFT OVERRIDE", class: "bidi-control" }],
|
|
50
|
+
[0x2060, { name: "WORD JOINER", class: "zero-width" }],
|
|
51
|
+
[0x2061, { name: "FUNCTION APPLICATION", class: "format-other" }],
|
|
52
|
+
[0x2062, { name: "INVISIBLE TIMES", class: "format-other" }],
|
|
53
|
+
[0x2063, { name: "INVISIBLE SEPARATOR", class: "format-other" }],
|
|
54
|
+
[0x2064, { name: "INVISIBLE PLUS", class: "format-other" }],
|
|
55
|
+
[0x2066, { name: "LEFT-TO-RIGHT ISOLATE", class: "bidi-control" }],
|
|
56
|
+
[0x2067, { name: "RIGHT-TO-LEFT ISOLATE", class: "bidi-control" }],
|
|
57
|
+
[0x2068, { name: "FIRST STRONG ISOLATE", class: "bidi-control" }],
|
|
58
|
+
[0x2069, { name: "POP DIRECTIONAL ISOLATE", class: "bidi-control" }],
|
|
59
|
+
[0xfeff, { name: "ZERO WIDTH NO-BREAK SPACE (BOM)", class: "zero-width" }],
|
|
60
|
+
[0xfffc, { name: "OBJECT REPLACEMENT CHARACTER", class: "format-other" }],
|
|
61
|
+
]);
|
|
62
|
+
/** Spaces that are not U+0020 and read as one. */
|
|
63
|
+
const UNUSUAL_SPACES = new Map([
|
|
64
|
+
[0x00a0, "NO-BREAK SPACE"],
|
|
65
|
+
[0x2000, "EN QUAD"],
|
|
66
|
+
[0x2001, "EM QUAD"],
|
|
67
|
+
[0x2002, "EN SPACE"],
|
|
68
|
+
[0x2003, "EM SPACE"],
|
|
69
|
+
[0x2004, "THREE-PER-EM SPACE"],
|
|
70
|
+
[0x2005, "FOUR-PER-EM SPACE"],
|
|
71
|
+
[0x2006, "SIX-PER-EM SPACE"],
|
|
72
|
+
[0x2007, "FIGURE SPACE"],
|
|
73
|
+
[0x2008, "PUNCTUATION SPACE"],
|
|
74
|
+
[0x2009, "THIN SPACE"],
|
|
75
|
+
[0x200a, "HAIR SPACE"],
|
|
76
|
+
[0x202f, "NARROW NO-BREAK SPACE"],
|
|
77
|
+
[0x205f, "MEDIUM MATHEMATICAL SPACE"],
|
|
78
|
+
[0x3000, "IDEOGRAPHIC SPACE"],
|
|
79
|
+
]);
|
|
80
|
+
function classify(cp) {
|
|
81
|
+
const named = NAMED.get(cp);
|
|
82
|
+
if (named)
|
|
83
|
+
return named;
|
|
84
|
+
const space = UNUSUAL_SPACES.get(cp);
|
|
85
|
+
if (space)
|
|
86
|
+
return { name: space, class: "unusual-space" };
|
|
87
|
+
// C0 and C1 controls, minus tab / newline / carriage return, which are text.
|
|
88
|
+
if ((cp < 0x20 && cp !== 0x09 && cp !== 0x0a && cp !== 0x0d) || (cp >= 0x7f && cp <= 0x9f)) {
|
|
89
|
+
return { name: "CONTROL CHARACTER", class: "control" };
|
|
90
|
+
}
|
|
91
|
+
if (cp >= 0xfe00 && cp <= 0xfe0f)
|
|
92
|
+
return { name: "VARIATION SELECTOR", class: "variation-selector" };
|
|
93
|
+
if (cp >= 0xe0100 && cp <= 0xe01ef) {
|
|
94
|
+
return { name: "VARIATION SELECTOR SUPPLEMENT", class: "variation-selector" };
|
|
95
|
+
}
|
|
96
|
+
// The tag block: a full ASCII alphabet that renders as nothing at all.
|
|
97
|
+
if (cp >= 0xe0000 && cp <= 0xe007f)
|
|
98
|
+
return { name: "TAG CHARACTER", class: "tag" };
|
|
99
|
+
if (cp >= 0xe000 && cp <= 0xf8ff)
|
|
100
|
+
return { name: "PRIVATE USE", class: "private-use" };
|
|
101
|
+
if (cp >= 0xf0000 && cp <= 0x10fffd)
|
|
102
|
+
return { name: "SUPPLEMENTARY PRIVATE USE", class: "private-use" };
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
/** `U+200B`-style label. */
|
|
106
|
+
export function codePointLabel(cp) {
|
|
107
|
+
return `U+${cp.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
108
|
+
}
|
|
109
|
+
/** Every invisible or deceptive-whitespace code point, in document order. */
|
|
110
|
+
export function scanInvisible(text) {
|
|
111
|
+
const hits = [];
|
|
112
|
+
for (let i = 0; i < text.length;) {
|
|
113
|
+
const cp = text.codePointAt(i);
|
|
114
|
+
if (cp === undefined)
|
|
115
|
+
break;
|
|
116
|
+
const width = cp > 0xffff ? 2 : 1;
|
|
117
|
+
const found = classify(cp);
|
|
118
|
+
if (found) {
|
|
119
|
+
hits.push({
|
|
120
|
+
codePoint: cp,
|
|
121
|
+
label: codePointLabel(cp),
|
|
122
|
+
name: found.name,
|
|
123
|
+
class: found.class,
|
|
124
|
+
start: i,
|
|
125
|
+
end: i + width,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
i += width;
|
|
129
|
+
}
|
|
130
|
+
return hits;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Bidi controls that are opened and never closed within their line.
|
|
134
|
+
*
|
|
135
|
+
* This is the Trojan Source shape (CVE-2021-42574): an override opened in a
|
|
136
|
+
* comment reorders the code after it without any visible trace. Balance is
|
|
137
|
+
* checked per line because that is the unit a reviewer reads.
|
|
138
|
+
*/
|
|
139
|
+
export function unbalancedBidi(text) {
|
|
140
|
+
const OPEN = new Set([0x202a, 0x202b, 0x202d, 0x202e, 0x2066, 0x2067, 0x2068]);
|
|
141
|
+
const CLOSE = new Set([0x202c, 0x2069]);
|
|
142
|
+
const out = [];
|
|
143
|
+
const lines = text.split("\n");
|
|
144
|
+
for (let i = 0; i < lines.length; i++) {
|
|
145
|
+
const line = lines[i] ?? "";
|
|
146
|
+
let open = 0;
|
|
147
|
+
let close = 0;
|
|
148
|
+
for (const ch of line) {
|
|
149
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
150
|
+
if (OPEN.has(cp))
|
|
151
|
+
open++;
|
|
152
|
+
else if (CLOSE.has(cp))
|
|
153
|
+
close++;
|
|
154
|
+
}
|
|
155
|
+
if (open !== close)
|
|
156
|
+
out.push({ line: i + 1, open, close });
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Hand-curated Latin lookalikes with no compatibility decomposition, so
|
|
162
|
+
* step 3 of the pipeline cannot reach them. Cyrillic and Greek dominate
|
|
163
|
+
* because they are what registrars and identifier spoofing actually use.
|
|
164
|
+
*/
|
|
165
|
+
export const CONFUSABLES = new Map([
|
|
166
|
+
// Cyrillic
|
|
167
|
+
["А", "A"],
|
|
168
|
+
["В", "B"],
|
|
169
|
+
["Е", "E"],
|
|
170
|
+
["Ѕ", "S"],
|
|
171
|
+
["І", "I"],
|
|
172
|
+
["Ј", "J"],
|
|
173
|
+
["К", "K"],
|
|
174
|
+
["М", "M"],
|
|
175
|
+
["Н", "H"],
|
|
176
|
+
["О", "O"],
|
|
177
|
+
["Р", "P"],
|
|
178
|
+
["С", "C"],
|
|
179
|
+
["Т", "T"],
|
|
180
|
+
["У", "Y"],
|
|
181
|
+
["Х", "X"],
|
|
182
|
+
["Ԛ", "Q"],
|
|
183
|
+
["Ԝ", "W"],
|
|
184
|
+
["Ӏ", "I"],
|
|
185
|
+
["а", "a"],
|
|
186
|
+
["в", "b"],
|
|
187
|
+
["е", "e"],
|
|
188
|
+
["ё", "e"],
|
|
189
|
+
["ѕ", "s"],
|
|
190
|
+
["і", "i"],
|
|
191
|
+
["ј", "j"],
|
|
192
|
+
["к", "k"],
|
|
193
|
+
["м", "m"],
|
|
194
|
+
["н", "h"],
|
|
195
|
+
["о", "o"],
|
|
196
|
+
["р", "p"],
|
|
197
|
+
["с", "c"],
|
|
198
|
+
["т", "t"],
|
|
199
|
+
["у", "y"],
|
|
200
|
+
["х", "x"],
|
|
201
|
+
["ԁ", "d"],
|
|
202
|
+
["һ", "h"],
|
|
203
|
+
["ӏ", "l"],
|
|
204
|
+
["ѡ", "w"],
|
|
205
|
+
["ԍ", "g"],
|
|
206
|
+
// Greek
|
|
207
|
+
["Α", "A"],
|
|
208
|
+
["Β", "B"],
|
|
209
|
+
["Ε", "E"],
|
|
210
|
+
["Ζ", "Z"],
|
|
211
|
+
["Η", "H"],
|
|
212
|
+
["Ι", "I"],
|
|
213
|
+
["Κ", "K"],
|
|
214
|
+
["Μ", "M"],
|
|
215
|
+
["Ν", "N"],
|
|
216
|
+
["Ο", "O"],
|
|
217
|
+
["Ρ", "P"],
|
|
218
|
+
["Τ", "T"],
|
|
219
|
+
["Υ", "Y"],
|
|
220
|
+
["Χ", "X"],
|
|
221
|
+
["Ϲ", "C"],
|
|
222
|
+
["α", "a"],
|
|
223
|
+
["ε", "e"],
|
|
224
|
+
["ι", "i"],
|
|
225
|
+
["κ", "k"],
|
|
226
|
+
["ν", "v"],
|
|
227
|
+
["ο", "o"],
|
|
228
|
+
["ρ", "p"],
|
|
229
|
+
["τ", "t"],
|
|
230
|
+
["υ", "u"],
|
|
231
|
+
["χ", "x"],
|
|
232
|
+
["ϲ", "c"],
|
|
233
|
+
["ϳ", "j"],
|
|
234
|
+
// Armenian, Georgian and Cherokee lookalikes that reach domain names
|
|
235
|
+
["ա", "w"],
|
|
236
|
+
["օ", "o"],
|
|
237
|
+
["ѵ", "v"],
|
|
238
|
+
["Ꭺ", "A"],
|
|
239
|
+
["Ꮯ", "C"],
|
|
240
|
+
["Ꮋ", "H"],
|
|
241
|
+
["Ꮖ", "I"],
|
|
242
|
+
// Punctuation and symbols that read as ASCII
|
|
243
|
+
["‐", "-"],
|
|
244
|
+
["‑", "-"],
|
|
245
|
+
["‒", "-"],
|
|
246
|
+
["–", "-"],
|
|
247
|
+
["—", "-"],
|
|
248
|
+
["―", "-"],
|
|
249
|
+
["−", "-"],
|
|
250
|
+
["’", "'"],
|
|
251
|
+
["‘", "'"],
|
|
252
|
+
["‚", "'"],
|
|
253
|
+
["′", "'"],
|
|
254
|
+
["ʼ", "'"],
|
|
255
|
+
["ˈ", "'"],
|
|
256
|
+
["“", '"'],
|
|
257
|
+
["”", '"'],
|
|
258
|
+
["„", '"'],
|
|
259
|
+
["″", '"'],
|
|
260
|
+
["⁄", "/"],
|
|
261
|
+
["∕", "/"],
|
|
262
|
+
["⧸", "/"],
|
|
263
|
+
["∶", ":"],
|
|
264
|
+
["․", "."],
|
|
265
|
+
["。", "."],
|
|
266
|
+
["。", "."],
|
|
267
|
+
["ǀ", "l"],
|
|
268
|
+
["ł", "l"],
|
|
269
|
+
["ℓ", "l"],
|
|
270
|
+
]);
|
|
271
|
+
/**
|
|
272
|
+
* Drop combining marks (U+0300-U+036F) by code point rather than with a
|
|
273
|
+
* character class: a class whose endpoints are a base character and a
|
|
274
|
+
* combining one is exactly what makes a regex here ambiguous, and the loop
|
|
275
|
+
* says what it means.
|
|
276
|
+
*/
|
|
277
|
+
function stripCombiningMarks(value) {
|
|
278
|
+
let out = "";
|
|
279
|
+
for (const ch of value) {
|
|
280
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
281
|
+
if (cp >= 0x0300 && cp <= 0x036f)
|
|
282
|
+
continue;
|
|
283
|
+
out += ch;
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
/** Fold one non-ASCII code point via NFKD, or undefined when it will not reduce. */
|
|
288
|
+
function nfkdToAscii(char) {
|
|
289
|
+
const decomposed = stripCombiningMarks(char.normalize("NFKD"));
|
|
290
|
+
if (decomposed.length === 0)
|
|
291
|
+
return undefined;
|
|
292
|
+
// Printable ASCII only: a decomposition to a control character is not a fold.
|
|
293
|
+
for (let i = 0; i < decomposed.length; i++) {
|
|
294
|
+
const code = decomposed.charCodeAt(i);
|
|
295
|
+
if (code < 0x20 || code > 0x7e)
|
|
296
|
+
return undefined;
|
|
297
|
+
}
|
|
298
|
+
return decomposed;
|
|
299
|
+
}
|
|
300
|
+
/** Apply the documented four-step pipeline, keeping source offsets exact. */
|
|
301
|
+
export function foldConfusables(text) {
|
|
302
|
+
const changes = [];
|
|
303
|
+
const unfolded = [];
|
|
304
|
+
let out = "";
|
|
305
|
+
for (let i = 0; i < text.length;) {
|
|
306
|
+
const cp = text.codePointAt(i);
|
|
307
|
+
if (cp === undefined)
|
|
308
|
+
break;
|
|
309
|
+
const width = cp > 0xffff ? 2 : 1;
|
|
310
|
+
const char = text.slice(i, i + width);
|
|
311
|
+
if (cp <= 0x7f) {
|
|
312
|
+
out += char;
|
|
313
|
+
i += width;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const mapped = CONFUSABLES.get(char);
|
|
317
|
+
if (mapped !== undefined) {
|
|
318
|
+
changes.push({
|
|
319
|
+
start: i,
|
|
320
|
+
end: i + width,
|
|
321
|
+
from: char,
|
|
322
|
+
fromLabel: codePointLabel(cp),
|
|
323
|
+
to: mapped,
|
|
324
|
+
step: "confusable-table",
|
|
325
|
+
});
|
|
326
|
+
out += mapped;
|
|
327
|
+
i += width;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
const decomposed = nfkdToAscii(char);
|
|
331
|
+
if (decomposed !== undefined) {
|
|
332
|
+
changes.push({
|
|
333
|
+
start: i,
|
|
334
|
+
end: i + width,
|
|
335
|
+
from: char,
|
|
336
|
+
fromLabel: codePointLabel(cp),
|
|
337
|
+
to: decomposed,
|
|
338
|
+
step: "nfkd-ascii",
|
|
339
|
+
});
|
|
340
|
+
out += decomposed;
|
|
341
|
+
i += width;
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
unfolded.push({ start: i, label: codePointLabel(cp), char });
|
|
345
|
+
out += char;
|
|
346
|
+
i += width;
|
|
347
|
+
}
|
|
348
|
+
return { text: out, changes, unfolded };
|
|
349
|
+
}
|
|
350
|
+
const SCRIPT_RANGES = [
|
|
351
|
+
[0x0041, 0x005a, "Latin"],
|
|
352
|
+
[0x0061, 0x007a, "Latin"],
|
|
353
|
+
[0x00c0, 0x024f, "Latin"],
|
|
354
|
+
[0x1e00, 0x1eff, "Latin"],
|
|
355
|
+
[0x0370, 0x03ff, "Greek"],
|
|
356
|
+
[0x1f00, 0x1fff, "Greek"],
|
|
357
|
+
[0x0400, 0x052f, "Cyrillic"],
|
|
358
|
+
[0x2de0, 0x2dff, "Cyrillic"],
|
|
359
|
+
[0x0530, 0x058f, "Armenian"],
|
|
360
|
+
[0x0590, 0x05ff, "Hebrew"],
|
|
361
|
+
[0x0600, 0x06ff, "Arabic"],
|
|
362
|
+
[0x0750, 0x077f, "Arabic"],
|
|
363
|
+
[0x0900, 0x097f, "Devanagari"],
|
|
364
|
+
[0x0e00, 0x0e7f, "Thai"],
|
|
365
|
+
[0x10a0, 0x10ff, "Georgian"],
|
|
366
|
+
[0x13a0, 0x13ff, "Cherokee"],
|
|
367
|
+
[0x3040, 0x309f, "Hiragana"],
|
|
368
|
+
[0x30a0, 0x30ff, "Katakana"],
|
|
369
|
+
[0x3400, 0x4dbf, "Han"],
|
|
370
|
+
[0x4e00, 0x9fff, "Han"],
|
|
371
|
+
[0x1100, 0x11ff, "Hangul"],
|
|
372
|
+
[0xac00, 0xd7af, "Hangul"],
|
|
373
|
+
];
|
|
374
|
+
/**
|
|
375
|
+
* A deliberately coarse script lookup: the blocks that matter for spoofing,
|
|
376
|
+
* with digits, ASCII punctuation and spaces as `Common` so they never make a
|
|
377
|
+
* token look mixed.
|
|
378
|
+
*/
|
|
379
|
+
export function scriptOf(cp) {
|
|
380
|
+
if (cp <= 0x7f) {
|
|
381
|
+
const isAsciiLetter = (cp >= 0x41 && cp <= 0x5a) || (cp >= 0x61 && cp <= 0x7a);
|
|
382
|
+
return isAsciiLetter ? "Latin" : "Common";
|
|
383
|
+
}
|
|
384
|
+
for (const [lo, hi, script] of SCRIPT_RANGES) {
|
|
385
|
+
if (cp >= lo && cp <= hi)
|
|
386
|
+
return script;
|
|
387
|
+
}
|
|
388
|
+
return "Other";
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Runs of non-space characters that draw on more than one script.
|
|
392
|
+
*
|
|
393
|
+
* The classic phish is one Cyrillic letter inside an otherwise Latin word;
|
|
394
|
+
* that shows up here even when it folds cleanly, and it shows up for
|
|
395
|
+
* whole-script spoofs too, which fold to nothing.
|
|
396
|
+
*/
|
|
397
|
+
export function mixedScriptRuns(text) {
|
|
398
|
+
const runs = [];
|
|
399
|
+
let start = -1;
|
|
400
|
+
let buffer = "";
|
|
401
|
+
let scripts = new Set();
|
|
402
|
+
const flush = () => {
|
|
403
|
+
if (start >= 0 && scripts.size > 1) {
|
|
404
|
+
runs.push({ text: buffer, start, scripts: [...scripts].sort() });
|
|
405
|
+
}
|
|
406
|
+
start = -1;
|
|
407
|
+
buffer = "";
|
|
408
|
+
scripts = new Set();
|
|
409
|
+
};
|
|
410
|
+
for (let i = 0; i < text.length;) {
|
|
411
|
+
const cp = text.codePointAt(i);
|
|
412
|
+
if (cp === undefined)
|
|
413
|
+
break;
|
|
414
|
+
const width = cp > 0xffff ? 2 : 1;
|
|
415
|
+
const char = text.slice(i, i + width);
|
|
416
|
+
if (/\s/.test(char)) {
|
|
417
|
+
flush();
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
if (start < 0)
|
|
421
|
+
start = i;
|
|
422
|
+
buffer += char;
|
|
423
|
+
const script = scriptOf(cp);
|
|
424
|
+
if (script !== "Common")
|
|
425
|
+
scripts.add(script);
|
|
426
|
+
}
|
|
427
|
+
i += width;
|
|
428
|
+
}
|
|
429
|
+
flush();
|
|
430
|
+
return runs;
|
|
431
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type UrlSeverity = "high" | "medium" | "low" | "info";
|
|
2
|
+
export type UrlIssue = {
|
|
3
|
+
readonly rule: string;
|
|
4
|
+
readonly severity: UrlSeverity;
|
|
5
|
+
readonly detail: string;
|
|
6
|
+
};
|
|
7
|
+
export type UrlAnalysis = {
|
|
8
|
+
readonly parsed: boolean;
|
|
9
|
+
readonly scheme?: string;
|
|
10
|
+
readonly host?: string;
|
|
11
|
+
readonly port?: string;
|
|
12
|
+
readonly issues: ReadonlyArray<UrlIssue>;
|
|
13
|
+
/** Every check that ran, named, so "no issues" is readable as a scope. */
|
|
14
|
+
readonly checked: ReadonlyArray<string>;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Every rule id `analyzeUrl` can emit, so `checked` and `issues[].rule` are
|
|
18
|
+
* the same vocabulary — a caller can line an empty `issues` list up against
|
|
19
|
+
* this one and see exactly what "no findings" covered. Naming a category here
|
|
20
|
+
* that no issue ever reports under (`scheme` when the issue says
|
|
21
|
+
* `scheme.active`) makes the two lists un-joinable, which is the whole point
|
|
22
|
+
* of publishing the second one.
|
|
23
|
+
*/
|
|
24
|
+
export declare const URL_CHECKS: ReadonlyArray<string>;
|
|
25
|
+
/** Analyse one URL string. Never throws for a caller mistake. */
|
|
26
|
+
export declare function analyzeUrl(raw: string): UrlAnalysis;
|
|
27
|
+
export declare class AllowRuleError extends Error {
|
|
28
|
+
readonly name = "AllowRuleError";
|
|
29
|
+
}
|
|
30
|
+
/** A host matches a host rule exactly, or as a subdomain under `*.`. */
|
|
31
|
+
export declare function hostMatches(host: string, rule: string): boolean;
|
|
32
|
+
/** Does this URL satisfy this allow-list rule? */
|
|
33
|
+
export declare function urlMatchesRule(value: string, rule: string): boolean;
|
|
34
|
+
/** The domain of an email address, lower-cased, or undefined if there is none. */
|
|
35
|
+
export declare function emailDomain(address: string): string | undefined;
|