@slicedearth/whoisleuth-cli 1.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/LICENSE +661 -0
  2. package/LICENSES/Retire.js-Apache-2.0.txt +199 -0
  3. package/NOTICE +76 -0
  4. package/README.md +84 -0
  5. package/TRADEMARKS.md +41 -0
  6. package/bin/whoisleuth.mjs +35 -0
  7. package/cli/archive-inspect.mjs +198 -0
  8. package/cli/arguments.mjs +1022 -0
  9. package/cli/artifact-verify.mjs +210 -0
  10. package/cli/bulk-checkpoint.mjs +218 -0
  11. package/cli/bulk-command-runner.mjs +109 -0
  12. package/cli/bulk-output.mjs +112 -0
  13. package/cli/bulk.mjs +133 -0
  14. package/cli/compare.mjs +238 -0
  15. package/cli/completion.mjs +175 -0
  16. package/cli/csv.mjs +1 -0
  17. package/cli/discover.mjs +50 -0
  18. package/cli/discovery-command-runner.mjs +36 -0
  19. package/cli/discovery-observation-snapshot.mjs +315 -0
  20. package/cli/discovery-scan-command-runner.mjs +141 -0
  21. package/cli/discovery-scan.mjs +325 -0
  22. package/cli/discovery-snapshot.mjs +133 -0
  23. package/cli/discovery-workflow.mjs +73 -0
  24. package/cli/doctor.mjs +137 -0
  25. package/cli/errors.mjs +21 -0
  26. package/cli/evidence-command-arguments.mjs +145 -0
  27. package/cli/evidence-command-runner.mjs +106 -0
  28. package/cli/evidence-signing.mjs +215 -0
  29. package/cli/exit-codes.mjs +9 -0
  30. package/cli/export-evidence.mjs +28 -0
  31. package/cli/formatters/evidence-report.mjs +3 -0
  32. package/cli/formatters/html.mjs +69 -0
  33. package/cli/formatters/json.mjs +135 -0
  34. package/cli/formatters/markdown.mjs +3 -0
  35. package/cli/formatters/terminal.mjs +658 -0
  36. package/cli/http.mjs +27 -0
  37. package/cli/lookup-command-runner.mjs +87 -0
  38. package/cli/lookup-diff.mjs +171 -0
  39. package/cli/mail-review.mjs +204 -0
  40. package/cli/manual.mjs +34 -0
  41. package/cli/network-command-runner.mjs +88 -0
  42. package/cli/output-file.mjs +97 -0
  43. package/cli/page-compare.mjs +123 -0
  44. package/cli/posture.mjs +20 -0
  45. package/cli/progress-events.mjs +53 -0
  46. package/cli/progress.mjs +56 -0
  47. package/cli/registry-access.mjs +3 -0
  48. package/cli/registry-support.mjs +120 -0
  49. package/cli/risk-calibration.mjs +294 -0
  50. package/cli/runner-types.mjs +1 -0
  51. package/cli/runner.mjs +726 -0
  52. package/cli/saved-lookup.mjs +87 -0
  53. package/cli/source-reliability.mjs +727 -0
  54. package/cli/strict-exit.mjs +55 -0
  55. package/cli/terminal-presentation.mjs +147 -0
  56. package/docs/cli.md +827 -0
  57. package/frontend/src/lib/analysis/acquisition-decision-packet.js +93 -0
  58. package/frontend/src/lib/analysis/acquisition-due-diligence.js +391 -0
  59. package/frontend/src/lib/analysis/artifact-integrity.js +21 -0
  60. package/frontend/src/lib/analysis/brand-profile-model.js +421 -0
  61. package/frontend/src/lib/analysis/bulk-domain-comparison.js +246 -0
  62. package/frontend/src/lib/analysis/bulk-mail-exposure.js +192 -0
  63. package/frontend/src/lib/analysis/bulk-retry-plan.js +86 -0
  64. package/frontend/src/lib/analysis/bulk-review-export.js +62 -0
  65. package/frontend/src/lib/analysis/bulk-review-model.js +266 -0
  66. package/frontend/src/lib/analysis/bulk-session-model.js +515 -0
  67. package/frontend/src/lib/analysis/bulk-sort.js +49 -0
  68. package/frontend/src/lib/analysis/campaign-model.js +265 -0
  69. package/frontend/src/lib/analysis/case-evidence-model.js +642 -0
  70. package/frontend/src/lib/analysis/case-migration-model.js +248 -0
  71. package/frontend/src/lib/analysis/case-model.js +5 -0
  72. package/frontend/src/lib/analysis/case-record-contracts.js +44 -0
  73. package/frontend/src/lib/analysis/case-record-core.js +232 -0
  74. package/frontend/src/lib/analysis/case-record-model.js +7 -0
  75. package/frontend/src/lib/analysis/case-record-operations.js +224 -0
  76. package/frontend/src/lib/analysis/case-response-model.js +640 -0
  77. package/frontend/src/lib/analysis/case-response-packet.js +573 -0
  78. package/frontend/src/lib/analysis/case-storage-model.js +93 -0
  79. package/frontend/src/lib/analysis/detection-rule-model.js +306 -0
  80. package/frontend/src/lib/analysis/dns-record-normalization.js +11 -0
  81. package/frontend/src/lib/analysis/http-summary.js +165 -0
  82. package/frontend/src/lib/analysis/investigation-guide.js +500 -0
  83. package/frontend/src/lib/analysis/investigation-template-model.js +144 -0
  84. package/frontend/src/lib/analysis/page-baseline.js +272 -0
  85. package/frontend/src/lib/analysis/page-similarity.js +179 -0
  86. package/frontend/src/lib/analysis/relationship-evidence.js +265 -0
  87. package/frontend/src/lib/analysis/relationship-observation-model.js +423 -0
  88. package/frontend/src/lib/analysis/scoring.js +135 -0
  89. package/frontend/src/lib/analysis/shortlist-model.js +209 -0
  90. package/frontend/src/lib/analysis/utils.js +265 -0
  91. package/frontend/src/lib/analysis/watchlist-history.js +643 -0
  92. package/frontend/src/lib/analysis/watchlist-store.js +127 -0
  93. package/frontend/src/lib/analysis/website-snapshot-model.js +302 -0
  94. package/frontend/src/lib/analysis/workspace-archive-crypto.js +282 -0
  95. package/frontend/src/lib/analysis/workspace-archive.js +472 -0
  96. package/lib/abort.mjs +26 -0
  97. package/lib/abusech-auth.mjs +19 -0
  98. package/lib/availability.mjs +716 -0
  99. package/lib/bounded-contract-normalizers.mjs +61 -0
  100. package/lib/browser-library-profile.mjs +310 -0
  101. package/lib/bulk-comparison-evidence.mjs +83 -0
  102. package/lib/classify.mjs +93 -0
  103. package/lib/client-behavior-profile.mjs +83 -0
  104. package/lib/credential-surface-profile.mjs +66 -0
  105. package/lib/csv.mjs +23 -0
  106. package/lib/ct-query.mjs +27 -0
  107. package/lib/ct-search.mjs +393 -0
  108. package/lib/distributed-operation-budget.mjs +461 -0
  109. package/lib/dns-delegation-health.mjs +344 -0
  110. package/lib/dns-intelligence.mjs +739 -0
  111. package/lib/dns-mx.mjs +24 -0
  112. package/lib/dns-resolver-selection.mjs +33 -0
  113. package/lib/domain-posture-analysis.mjs +254 -0
  114. package/lib/domain-posture-parsers.mjs +358 -0
  115. package/lib/domain-posture.mjs +745 -0
  116. package/lib/error-detail.mjs +10 -0
  117. package/lib/evidence-export.mjs +431 -0
  118. package/lib/evidence-report-markdown.mjs +71 -0
  119. package/lib/evidence-report.mjs +391 -0
  120. package/lib/external-intelligence-risk.mjs +108 -0
  121. package/lib/favicon.mjs +146 -0
  122. package/lib/feature-policy.mjs +70 -0
  123. package/lib/generated/retire-browser-catalog.mjs +8724 -0
  124. package/lib/generated/unicode-confusables-17.mjs +93 -0
  125. package/lib/hostname.mjs +38 -0
  126. package/lib/html-signals.mjs +680 -0
  127. package/lib/http-intelligence.mjs +232 -0
  128. package/lib/idn-confusable-policy.mjs +86 -0
  129. package/lib/idn-confusables.mjs +465 -0
  130. package/lib/lookup-cache.mjs +130 -0
  131. package/lib/lookup-progress.mjs +291 -0
  132. package/lib/lookup-source-progress.mjs +109 -0
  133. package/lib/lookup-threat-provider-inventory.mjs +13 -0
  134. package/lib/lookup.mjs +592 -0
  135. package/lib/observation.mjs +114 -0
  136. package/lib/observed-network-context.mjs +355 -0
  137. package/lib/operation-budget.mjs +489 -0
  138. package/lib/page-fingerprints.mjs +481 -0
  139. package/lib/page-role-profile.mjs +117 -0
  140. package/lib/perceptual-hash.mjs +451 -0
  141. package/lib/rdap-attempts.mjs +34 -0
  142. package/lib/rdap-bootstrap.mjs +173 -0
  143. package/lib/rdap-capabilities.mjs +292 -0
  144. package/lib/rdap-client.mjs +125 -0
  145. package/lib/rdap-normalization.mjs +691 -0
  146. package/lib/rdap-registrar.mjs +196 -0
  147. package/lib/rdap-transport.mjs +39 -0
  148. package/lib/rdap-types.mjs +1 -0
  149. package/lib/rdap-validation.mjs +94 -0
  150. package/lib/rdap.mjs +16 -0
  151. package/lib/registry-access.mjs +32 -0
  152. package/lib/registry-capabilities.mjs +117 -0
  153. package/lib/registry-capability-catalogue.mjs +1206 -0
  154. package/lib/registry-comparison.mjs +318 -0
  155. package/lib/registry-dates.mjs +190 -0
  156. package/lib/registry-insights.mjs +325 -0
  157. package/lib/response-policy.mjs +256 -0
  158. package/lib/risk-scoring.mjs +164 -0
  159. package/lib/safe-fetch.mjs +446 -0
  160. package/lib/security-txt.mjs +359 -0
  161. package/lib/service-binding-dns.mjs +608 -0
  162. package/lib/sslbl-certificates.generated.mjs +143 -0
  163. package/lib/sslbl-intelligence.mjs +241 -0
  164. package/lib/static-html-analysis.mjs +340 -0
  165. package/lib/structured-data-identity.mjs +323 -0
  166. package/lib/threat-intelligence-contract.mjs +5 -0
  167. package/lib/threat-intelligence-definition-registry.mjs +34 -0
  168. package/lib/threat-intelligence-fixtures.mjs +82 -0
  169. package/lib/threat-intelligence-manifests.mjs +512 -0
  170. package/lib/threat-intelligence-runtime.mjs +662 -0
  171. package/lib/threat-intelligence-targets.mjs +168 -0
  172. package/lib/threat-intelligence-types.mjs +3 -0
  173. package/lib/threatfox-intelligence.mjs +307 -0
  174. package/lib/tls-intelligence.mjs +814 -0
  175. package/lib/typosquat-generator.mjs +884 -0
  176. package/lib/urlhaus-intelligence.mjs +361 -0
  177. package/lib/urlscan-intelligence.mjs +349 -0
  178. package/lib/website-security-posture.mjs +325 -0
  179. package/lib/website-technology.mjs +437 -0
  180. package/lib/whois-authority.mjs +98 -0
  181. package/lib/whois-chain.mjs +261 -0
  182. package/lib/whois-contacts.mjs +163 -0
  183. package/lib/whois-contracts.mjs +3 -0
  184. package/lib/whois-normalization.mjs +27 -0
  185. package/lib/whois-transport.mjs +100 -0
  186. package/lib/whois-values.mjs +92 -0
  187. package/lib/whois.mjs +1187 -0
  188. package/package.json +55 -0
  189. package/third-party-notices.txt +5571 -0
@@ -0,0 +1,465 @@
1
+ // Shared internationalized-domain analysis. This module performs no
2
+ // network requests and deliberately produces explainable similarity evidence,
3
+ // not a malicious/benign verdict. The small generated mapping is a versioned,
4
+ // bounded projection of Unicode Technical Standard #39 data plus the existing
5
+ // reviewed compatibility set. It focuses on characters that visually collapse
6
+ // to common ASCII domain-label characters.
7
+ import { GENERATED_CONFUSABLE_GROUPS, GENERATED_CONFUSABLE_MAPPING_VERSION, GENERATED_GENERATION_CONFUSABLE_GROUPS, } from "./generated/unicode-confusables-17.mjs";
8
+ import { MAX_GENERATION_CONFUSABLES_PER_ASCII, REVIEWED_GENERATION_CONFUSABLES, } from "./idn-confusable-policy.mjs";
9
+ export const IDN_ANALYSIS_VERSION = 1;
10
+ export const CONFUSABLE_MAPPING_VERSION = GENERATED_CONFUSABLE_MAPPING_VERSION;
11
+ const MAX_DOMAIN_LENGTH = 253;
12
+ const MAX_LABELS = 20;
13
+ const MAX_UNICODE_LABEL_CODEPOINTS = 128;
14
+ const MAX_REFERENCE_DOMAINS = 50;
15
+ const MAX_REFERENCE_MATCHES = 20;
16
+ const MAX_FINDINGS = 20;
17
+ const CONFUSABLE_TO_ASCII = new Map();
18
+ for (const [ascii, values] of Object.entries(GENERATED_CONFUSABLE_GROUPS)) {
19
+ for (const value of values)
20
+ CONFUSABLE_TO_ASCII.set(value, ascii);
21
+ }
22
+ const SCRIPT_TESTS = Object.freeze([
23
+ ['Latin', /\p{Script=Latin}/u],
24
+ ['Cyrillic', /\p{Script=Cyrillic}/u],
25
+ ['Greek', /\p{Script=Greek}/u],
26
+ ['Armenian', /\p{Script=Armenian}/u],
27
+ ['Coptic', /\p{Script=Coptic}/u],
28
+ ['Deseret', /\p{Script=Deseret}/u],
29
+ ['Lisu', /\p{Script=Lisu}/u],
30
+ ['Hebrew', /\p{Script=Hebrew}/u],
31
+ ['Arabic', /\p{Script=Arabic}/u],
32
+ ['Devanagari', /\p{Script=Devanagari}/u],
33
+ ['Bengali', /\p{Script=Bengali}/u],
34
+ ['Gurmukhi', /\p{Script=Gurmukhi}/u],
35
+ ['Gujarati', /\p{Script=Gujarati}/u],
36
+ ['Tamil', /\p{Script=Tamil}/u],
37
+ ['Telugu', /\p{Script=Telugu}/u],
38
+ ['Kannada', /\p{Script=Kannada}/u],
39
+ ['Malayalam', /\p{Script=Malayalam}/u],
40
+ ['Thai', /\p{Script=Thai}/u],
41
+ ['Georgian', /\p{Script=Georgian}/u],
42
+ ['Han', /\p{Script=Han}/u],
43
+ ['Hiragana', /\p{Script=Hiragana}/u],
44
+ ['Katakana', /\p{Script=Katakana}/u],
45
+ ['Hangul', /\p{Script=Hangul}/u],
46
+ ]);
47
+ const MARK_RE = /\p{Mark}/u;
48
+ const LETTER_RE = /\p{Letter}/u;
49
+ const JAPANESE_SCRIPTS = new Set(['Han', 'Hiragana', 'Katakana']);
50
+ const WHOLE_LABEL_TARGET_SCRIPTS = Object.freeze([
51
+ 'Cyrillic',
52
+ 'Greek',
53
+ 'Armenian',
54
+ 'Coptic',
55
+ 'Deseret',
56
+ 'Lisu',
57
+ ]);
58
+ const MAX_WHOLE_LABEL_VARIANTS = WHOLE_LABEL_TARGET_SCRIPTS.length;
59
+ const ASCII_GENERATION_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u;
60
+ export const MAX_ADVANCED_CONFUSABLE_VARIANTS = 256;
61
+ const ADVANCED_CONFUSABLE_SCRIPT_ORDER = Object.freeze([
62
+ 'Cyrillic',
63
+ 'Greek',
64
+ 'Latin',
65
+ 'Armenian',
66
+ 'Coptic',
67
+ 'Lisu',
68
+ 'Deseret',
69
+ ]);
70
+ function decodeDigit(codePoint) {
71
+ if (codePoint >= 0x61 && codePoint <= 0x7a)
72
+ return codePoint - 0x61;
73
+ if (codePoint >= 0x41 && codePoint <= 0x5a)
74
+ return codePoint - 0x41;
75
+ if (codePoint >= 0x30 && codePoint <= 0x39)
76
+ return codePoint - 0x30 + 26;
77
+ return -1;
78
+ }
79
+ function adaptBias(delta, pointCount, firstTime) {
80
+ let value = firstTime ? Math.floor(delta / 700) : delta >> 1;
81
+ value += Math.floor(value / pointCount);
82
+ let k = 0;
83
+ while (value > 455) {
84
+ value = Math.floor(value / 35);
85
+ k += 36;
86
+ }
87
+ return k + Math.floor((36 * value) / (value + 38));
88
+ }
89
+ // RFC 3492 decoder kept local so the browser build does not acquire a runtime
90
+ // dependency merely to present the Unicode form of an already-normalized ACE
91
+ // label. Every arithmetic step is checked and decoded output is capped.
92
+ export function decodePunycodeLabel(input) {
93
+ if (typeof input !== 'string' || input.length === 0 || input.length > 59 || !/^[a-z0-9-]+$/i.test(input))
94
+ return null;
95
+ const output = [];
96
+ const delimiter = input.lastIndexOf('-');
97
+ let cursor = 0;
98
+ if (delimiter >= 0) {
99
+ for (const char of input.slice(0, delimiter)) {
100
+ const point = char.codePointAt(0);
101
+ if (point === undefined || point >= 0x80)
102
+ return null;
103
+ output.push(point);
104
+ }
105
+ cursor = delimiter + 1;
106
+ }
107
+ let n = 128;
108
+ let index = 0;
109
+ let bias = 72;
110
+ while (cursor < input.length) {
111
+ const oldIndex = index;
112
+ let weight = 1;
113
+ for (let k = 36;; k += 36) {
114
+ if (cursor >= input.length)
115
+ return null;
116
+ const encodedPoint = input.codePointAt(cursor++);
117
+ if (encodedPoint === undefined)
118
+ return null;
119
+ const digit = decodeDigit(encodedPoint);
120
+ if (digit < 0 || digit > Math.floor((Number.MAX_SAFE_INTEGER - index) / weight))
121
+ return null;
122
+ index += digit * weight;
123
+ const threshold = k <= bias + 1 ? 1 : k >= bias + 26 ? 26 : k - bias;
124
+ if (digit < threshold)
125
+ break;
126
+ const baseMinusThreshold = 36 - threshold;
127
+ if (weight > Math.floor(Number.MAX_SAFE_INTEGER / baseMinusThreshold))
128
+ return null;
129
+ weight *= baseMinusThreshold;
130
+ }
131
+ const pointCount = output.length + 1;
132
+ bias = adaptBias(index - oldIndex, pointCount, oldIndex === 0);
133
+ const increment = Math.floor(index / pointCount);
134
+ if (increment > 0x10ffff - n)
135
+ return null;
136
+ n += increment;
137
+ index %= pointCount;
138
+ if (n >= 0xd800 && n <= 0xdfff)
139
+ return null;
140
+ output.splice(index, 0, n);
141
+ index += 1;
142
+ if (output.length > MAX_UNICODE_LABEL_CODEPOINTS)
143
+ return null;
144
+ }
145
+ try {
146
+ return String.fromCodePoint(...output).normalize('NFC');
147
+ }
148
+ catch {
149
+ return null;
150
+ }
151
+ }
152
+ function normalizeAsciiDomain(raw) {
153
+ if (typeof raw !== 'string')
154
+ return null;
155
+ const trimmed = raw.trim().replace(/\.+$/, '');
156
+ if (!trimmed || trimmed.length > MAX_DOMAIN_LENGTH || /[\u0000-\u0020\u007f]/.test(trimmed))
157
+ return null;
158
+ let hostname;
159
+ try {
160
+ hostname = new URL(`https://${trimmed}`).hostname.toLowerCase();
161
+ }
162
+ catch {
163
+ return null;
164
+ }
165
+ const labels = hostname.split('.');
166
+ if (labels.length < 2 || labels.length > MAX_LABELS)
167
+ return null;
168
+ if (!labels.every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(label)))
169
+ return null;
170
+ return hostname;
171
+ }
172
+ export function unicodeDomainFromAscii(raw) {
173
+ const asciiDomain = normalizeAsciiDomain(raw);
174
+ if (!asciiDomain)
175
+ return null;
176
+ const labels = [];
177
+ for (const label of asciiDomain.split('.')) {
178
+ if (!label.startsWith('xn--')) {
179
+ labels.push(label);
180
+ continue;
181
+ }
182
+ const decoded = decodePunycodeLabel(label.slice(4));
183
+ if (!decoded)
184
+ return null;
185
+ labels.push(decoded);
186
+ }
187
+ return labels.join('.');
188
+ }
189
+ function scriptForCharacter(character) {
190
+ if (!LETTER_RE.test(character))
191
+ return null;
192
+ for (const [name, expression] of SCRIPT_TESTS) {
193
+ if (expression.test(character))
194
+ return name;
195
+ }
196
+ return 'Other';
197
+ }
198
+ function isCompatibleScriptSet(scripts) {
199
+ return scripts.length <= 1 || scripts.every((script) => JAPANESE_SCRIPTS.has(script));
200
+ }
201
+ export function unicodeSkeleton(raw) {
202
+ if (typeof raw !== 'string' || raw.length > MAX_DOMAIN_LENGTH * 4)
203
+ return null;
204
+ let output = '';
205
+ for (const character of raw.normalize('NFKD').toLowerCase()) {
206
+ if (MARK_RE.test(character))
207
+ continue;
208
+ const mapped = CONFUSABLE_TO_ASCII.get(character);
209
+ if (mapped) {
210
+ output += mapped;
211
+ }
212
+ else if (character === '0') {
213
+ output += 'o';
214
+ }
215
+ else if (character === '1' || character === '|') {
216
+ output += 'l';
217
+ }
218
+ else {
219
+ output += character;
220
+ }
221
+ if (output.length > MAX_DOMAIN_LENGTH * 2)
222
+ return null;
223
+ }
224
+ return output;
225
+ }
226
+ export function confusableCharactersForAscii(character) {
227
+ const source = String(character || '').toLowerCase();
228
+ return [...(GENERATED_GENERATION_CONFUSABLE_GROUPS[source] || '')]
229
+ .slice(0, MAX_GENERATION_CONFUSABLES_PER_ASCII);
230
+ }
231
+ function advancedConfusablePlan(raw) {
232
+ if (typeof raw !== 'string') {
233
+ return Object.freeze({ label: '', pairs: Object.freeze([]), eligibleVariantCount: 0, omittedByPolicy: 0 });
234
+ }
235
+ const label = raw.trim().toLowerCase();
236
+ if (!ASCII_GENERATION_LABEL_RE.test(label)) {
237
+ return Object.freeze({ label: '', pairs: Object.freeze([]), eligibleVariantCount: 0, omittedByPolicy: 0 });
238
+ }
239
+ const positions = [];
240
+ for (const [index, character] of [...label].entries()) {
241
+ if (!/^[a-z]$/u.test(character))
242
+ continue;
243
+ const reviewedCharacters = REVIEWED_GENERATION_CONFUSABLES[character] || '';
244
+ const grouped = new Map();
245
+ for (const replacement of confusableCharactersForAscii(character)) {
246
+ const script = scriptForCharacter(replacement);
247
+ if (!script || !ADVANCED_CONFUSABLE_SCRIPT_ORDER.includes(script))
248
+ continue;
249
+ const values = grouped.get(script) || [];
250
+ values.push(Object.freeze({
251
+ character: replacement,
252
+ reviewed: reviewedCharacters.includes(replacement),
253
+ }));
254
+ grouped.set(script, values);
255
+ }
256
+ const byScript = new Map();
257
+ for (const [script, values] of grouped)
258
+ byScript.set(script, Object.freeze(values));
259
+ const total = [...byScript.values()].reduce((sum, values) => sum + values.length, 0);
260
+ if (total > 0)
261
+ positions.push(Object.freeze({ index, byScript, total }));
262
+ }
263
+ const pairs = [];
264
+ let eligibleVariantCount = 0;
265
+ let omittedByPolicy = 0;
266
+ for (let leftIndex = 0; leftIndex < positions.length; leftIndex += 1) {
267
+ for (let rightIndex = leftIndex + 1; rightIndex < positions.length; rightIndex += 1) {
268
+ const left = positions[leftIndex];
269
+ const right = positions[rightIndex];
270
+ if (!left || !right)
271
+ continue;
272
+ let eligibleForPair = 0;
273
+ for (const script of ADVANCED_CONFUSABLE_SCRIPT_ORDER) {
274
+ eligibleForPair += (left.byScript.get(script)?.length || 0)
275
+ * (right.byScript.get(script)?.length || 0);
276
+ }
277
+ eligibleVariantCount += eligibleForPair;
278
+ omittedByPolicy += (left.total * right.total) - eligibleForPair;
279
+ if (eligibleForPair > 0) {
280
+ pairs.push(Object.freeze({ left, right, span: right.index - left.index }));
281
+ }
282
+ }
283
+ }
284
+ pairs.sort((left, right) => left.span - right.span
285
+ || left.left.index - right.left.index
286
+ || left.right.index - right.right.index);
287
+ return Object.freeze({
288
+ label,
289
+ pairs: Object.freeze(pairs),
290
+ eligibleVariantCount,
291
+ omittedByPolicy,
292
+ });
293
+ }
294
+ export function estimateAdvancedConfusableVariants(raw) {
295
+ const plan = advancedConfusablePlan(raw);
296
+ return Object.freeze({
297
+ eligibleVariantCount: plan.eligibleVariantCount,
298
+ omittedByPolicy: plan.omittedByPolicy,
299
+ omittedByBudget: Math.max(0, plan.eligibleVariantCount - MAX_ADVANCED_CONFUSABLE_VARIANTS),
300
+ });
301
+ }
302
+ // Produces an explicit advanced candidate family. Exactly two ASCII-letter
303
+ // positions are replaced, both with characters from the same reviewed script.
304
+ // Curated compatibility mappings rank before later projected additions, then
305
+ // stable script and position order applies before the independent output cap.
306
+ export function advancedConfusableVariantsForAscii(raw) {
307
+ const plan = advancedConfusablePlan(raw);
308
+ const variants = [];
309
+ const seen = new Set();
310
+ candidateLoop: for (const visualClass of ['reviewed', 'projected']) {
311
+ for (const script of ADVANCED_CONFUSABLE_SCRIPT_ORDER) {
312
+ for (const pair of plan.pairs) {
313
+ const leftValues = pair.left.byScript.get(script) || [];
314
+ const rightValues = pair.right.byScript.get(script) || [];
315
+ for (const left of leftValues) {
316
+ for (const right of rightValues) {
317
+ const candidateClass = left.reviewed && right.reviewed ? 'reviewed' : 'projected';
318
+ if (candidateClass !== visualClass)
319
+ continue;
320
+ const characters = [...plan.label];
321
+ characters[pair.left.index] = left.character;
322
+ characters[pair.right.index] = right.character;
323
+ const unicodeLabel = characters.join('');
324
+ if (seen.has(unicodeLabel))
325
+ continue;
326
+ seen.add(unicodeLabel);
327
+ variants.push(Object.freeze({ unicodeLabel, script, visualClass }));
328
+ if (variants.length >= MAX_ADVANCED_CONFUSABLE_VARIANTS)
329
+ break candidateLoop;
330
+ }
331
+ }
332
+ }
333
+ }
334
+ }
335
+ return Object.freeze({
336
+ variants: Object.freeze(variants),
337
+ eligibleVariantCount: plan.eligibleVariantCount,
338
+ omittedByPolicy: plan.omittedByPolicy,
339
+ omittedByBudget: Math.max(0, plan.eligibleVariantCount - variants.length),
340
+ });
341
+ }
342
+ // Builds at most one deterministic candidate per reviewed non-Latin script.
343
+ // This is a deliberately narrower generation aid than the formal UTS #39
344
+ // whole-script algorithm: it uses JavaScript Script properties rather than
345
+ // resolved Script_Extensions and therefore does not expose a standards verdict.
346
+ export function wholeLabelConfusableVariantsForAscii(raw) {
347
+ if (typeof raw !== 'string')
348
+ return [];
349
+ const label = raw.trim().toLowerCase();
350
+ const letters = [...label].filter((character) => /^[a-z]$/u.test(character));
351
+ if (label.length === 0
352
+ || label.length > 63
353
+ || !ASCII_GENERATION_LABEL_RE.test(label)
354
+ || letters.length < 2)
355
+ return [];
356
+ const variants = [];
357
+ for (const targetScript of WHOLE_LABEL_TARGET_SCRIPTS) {
358
+ let unicodeLabel = '';
359
+ let complete = true;
360
+ for (const character of label) {
361
+ if (!/^[a-z]$/u.test(character)) {
362
+ unicodeLabel += character;
363
+ continue;
364
+ }
365
+ const replacement = confusableCharactersForAscii(character)
366
+ .find((candidate) => scriptForCharacter(candidate) === targetScript);
367
+ if (!replacement) {
368
+ complete = false;
369
+ break;
370
+ }
371
+ unicodeLabel += replacement;
372
+ }
373
+ if (complete && unicodeLabel !== label) {
374
+ variants.push(Object.freeze({ unicodeLabel, script: targetScript }));
375
+ if (variants.length >= MAX_WHOLE_LABEL_VARIANTS)
376
+ break;
377
+ }
378
+ }
379
+ return variants;
380
+ }
381
+ function analyzeLabels(unicodeDomain) {
382
+ const labels = [];
383
+ const allScripts = new Set();
384
+ for (const label of unicodeDomain.split('.').slice(0, MAX_LABELS)) {
385
+ const scripts = [...new Set([...label].map(scriptForCharacter).filter((script) => Boolean(script)))].sort();
386
+ for (const script of scripts)
387
+ allScripts.add(script);
388
+ labels.push({ label, scripts, mixed: !isCompatibleScriptSet(scripts) });
389
+ }
390
+ return { labels, scripts: [...allScripts].sort() };
391
+ }
392
+ export function analyzeDomainIdn(rawDomain, referenceDomains = []) {
393
+ const asciiDomain = normalizeAsciiDomain(rawDomain);
394
+ if (!asciiDomain)
395
+ return null;
396
+ const unicodeDomain = unicodeDomainFromAscii(asciiDomain);
397
+ if (!unicodeDomain)
398
+ return null;
399
+ const hasIdn = asciiDomain.split('.').some((label) => label.startsWith('xn--'));
400
+ const analyzed = analyzeLabels(unicodeDomain);
401
+ const mixedScriptLabels = analyzed.labels.filter((label) => label.mixed);
402
+ const skeleton = unicodeSkeleton(unicodeDomain);
403
+ const referenceMatches = [];
404
+ const seenReferences = new Set();
405
+ for (const rawReference of (Array.isArray(referenceDomains) ? referenceDomains : []).slice(0, MAX_REFERENCE_DOMAINS)) {
406
+ const referenceAscii = normalizeAsciiDomain(rawReference);
407
+ if (!referenceAscii || referenceAscii === asciiDomain || seenReferences.has(referenceAscii))
408
+ continue;
409
+ seenReferences.add(referenceAscii);
410
+ const referenceUnicode = unicodeDomainFromAscii(referenceAscii);
411
+ const referenceSkeleton = referenceUnicode ? unicodeSkeleton(referenceUnicode) : null;
412
+ if (!skeleton || !referenceUnicode || !referenceSkeleton || skeleton !== referenceSkeleton)
413
+ continue;
414
+ referenceMatches.push({
415
+ asciiDomain: referenceAscii,
416
+ unicodeDomain: referenceUnicode,
417
+ skeleton: referenceSkeleton,
418
+ });
419
+ if (referenceMatches.length >= MAX_REFERENCE_MATCHES)
420
+ break;
421
+ }
422
+ const findings = [];
423
+ if (hasIdn)
424
+ findings.push({
425
+ id: 'internationalized_domain',
426
+ tone: 'info',
427
+ label: 'Internationalised domain',
428
+ detail: 'The DNS-safe ASCII form contains an xn-- label. Review the Unicode and ASCII forms together.',
429
+ });
430
+ for (const label of mixedScriptLabels) {
431
+ findings.push({
432
+ id: 'mixed_script_label',
433
+ tone: 'warning',
434
+ label: 'Mixed writing scripts',
435
+ detail: `“${label.label}” combines ${label.scripts.join(' and ')} characters. Mixed scripts can be legitimate but deserve visual review.`,
436
+ });
437
+ if (findings.length >= MAX_FINDINGS)
438
+ break;
439
+ }
440
+ if (referenceMatches.length && findings.length < MAX_FINDINGS)
441
+ findings.push({
442
+ id: 'official_skeleton_match',
443
+ tone: 'warning',
444
+ label: 'Confusable with an official domain',
445
+ detail: `The normalized visual skeleton matches ${referenceMatches.map((item) => item.asciiDomain).join(', ')}. This is similarity evidence, not proof of impersonation.`,
446
+ });
447
+ return {
448
+ version: IDN_ANALYSIS_VERSION,
449
+ mappingVersion: CONFUSABLE_MAPPING_VERSION,
450
+ asciiDomain,
451
+ unicodeDomain,
452
+ hasIdn,
453
+ scripts: analyzed.scripts,
454
+ labels: analyzed.labels,
455
+ mixedScript: mixedScriptLabels.length > 0,
456
+ skeleton,
457
+ referenceMatches,
458
+ findings,
459
+ truncated: Array.isArray(referenceDomains) && referenceDomains.length > MAX_REFERENCE_DOMAINS,
460
+ limitations: [
461
+ 'Confusable analysis uses a bounded generated mapping and cannot identify every visual similarity.',
462
+ 'Script mixing and skeleton matches are contextual indicators, not maliciousness verdicts.',
463
+ ],
464
+ };
465
+ }
@@ -0,0 +1,130 @@
1
+ // Short-TTL in-memory cache for repeated RDAP/WHOIS lookups - the same
2
+ // domain looked up again shortly after (reviewing typosquat candidates,
3
+ // re-running a scan, a deep-check following a fast scan on the same
4
+ // domain) doesn't need a fresh registry round-trip every time. Same
5
+ // "public data, safe to share across all requests" reasoning as the IANA
6
+ // bootstrap cache in lib/rdap.mts - RDAP/WHOIS results are public registry
7
+ // data, not anything specific to who's asking, so caching them briefly
8
+ // isn't a privacy concern the way per-user state would be.
9
+ //
10
+ // In-memory, so (like the bootstrap cache and the rate limiter) this
11
+ // applies globally on server.mts's one long-lived process, but only within
12
+ // a single warm container on Netlify Functions.
13
+ const TTL_MS = 3 * 60 * 1000; // 3 minutes
14
+ const SWEEP_INTERVAL_MS = 5 * 60 * 1000;
15
+ // Bounds worst-case memory regardless of the TTL/sweep - without this, a
16
+ // single large fast scan (up to MAX_FAST_BULK_DOMAINS domains) landing
17
+ // within one TTL window adds that many entries, each holding a full raw
18
+ // RDAP/WHOIS response, before the next sweep has a chance to run; repeated
19
+ // or concurrent large scans compound that. `Map` preserves insertion
20
+ // order, so the oldest entry is always `store.keys().next().value` -
21
+ // simple FIFO eviction, not a full LRU (re-caching an existing key doesn't
22
+ // move it), which is a fine fit for a cache whose primary job is
23
+ // deduplicating near-simultaneous repeat lookups within a few minutes, not
24
+ // long-term hit-rate optimization.
25
+ const MAX_ENTRIES = 3000;
26
+ // MAX_ENTRIES alone bounds count, not size - it doesn't stop a hostile or
27
+ // compromised registry from serving a near-maximum-size response (RDAP:
28
+ // 2MB, lib/rdap.mts; WHOIS: 200KB/hop, lib/whois.mts) for many distinct
29
+ // domains. A full-size fast bulk scan (up to MAX_FAST_BULK_DOMAINS) hitting
30
+ // such a registry could otherwise retain gigabytes before entry count ever
31
+ // reaches MAX_ENTRIES. 100MB is generous for a normal Node process while
32
+ // still being a real ceiling.
33
+ const MAX_TOTAL_BYTES = 100 * 1024 * 1024;
34
+ const store = new Map();
35
+ let totalBytes = 0;
36
+ let nextSweepAt = 0;
37
+ // Approximate, not exact (doesn't account for JS object/string overhead) -
38
+ // good enough for a soft memory ceiling, and cheap since it only runs once
39
+ // per cache write, not per read.
40
+ function approxByteSize(value) {
41
+ try {
42
+ return Buffer.byteLength(JSON.stringify(value));
43
+ }
44
+ catch {
45
+ return 0;
46
+ }
47
+ }
48
+ function deleteEntry(key) {
49
+ const entry = store.get(key);
50
+ if (!entry)
51
+ return;
52
+ store.delete(key);
53
+ totalBytes -= entry.size;
54
+ }
55
+ function sweepExpiredEntries(now) {
56
+ if (now < nextSweepAt)
57
+ return;
58
+ nextSweepAt = now + SWEEP_INTERVAL_MS;
59
+ for (const [key, entry] of store) {
60
+ if (now > entry.expiresAt)
61
+ deleteEntry(key);
62
+ }
63
+ }
64
+ function getCached(key) {
65
+ sweepExpiredEntries(Date.now());
66
+ const entry = store.get(key);
67
+ if (!entry)
68
+ return undefined;
69
+ if (Date.now() > entry.expiresAt) {
70
+ deleteEntry(key);
71
+ return undefined;
72
+ }
73
+ return entry.value;
74
+ }
75
+ function setCached(key, value) {
76
+ sweepExpiredEntries(Date.now());
77
+ deleteEntry(key); // avoid double-counting bytes if this key is already cached
78
+ const size = approxByteSize(value);
79
+ store.set(key, { value, expiresAt: Date.now() + TTL_MS, size });
80
+ totalBytes += size;
81
+ while (store.size > 0 && (store.size > MAX_ENTRIES || totalBytes > MAX_TOTAL_BYTES)) {
82
+ const oldestKey = store.keys().next().value;
83
+ if (oldestKey === undefined)
84
+ break;
85
+ deleteEntry(oldestKey);
86
+ }
87
+ }
88
+ // Concurrent callers for the same key (e.g. a bulk scan's concurrent
89
+ // workers, or a fast scan immediately followed by a deep-check, hitting the
90
+ // same domain before the first lookup has even finished) share one
91
+ // in-flight request instead of each starting their own.
92
+ const inFlight = new Map();
93
+ // `factory` may legitimately resolve to `null` (e.g. "no RDAP registry for
94
+ // this TLD") - that's cached as a real result, distinct from `undefined`
95
+ // meaning "not cached yet", so a `null` answer doesn't get re-fetched every
96
+ // time either.
97
+ // The cache intentionally holds heterogeneous public lookup results. Callers
98
+ // supply distinct result contracts, so this internal compatibility boundary
99
+ // stays dynamically typed rather than asserting one shared payload shape.
100
+ async function cached(key, factory) {
101
+ const hit = getCached(key);
102
+ if (hit !== undefined)
103
+ return hit;
104
+ const pending = inFlight.get(key);
105
+ if (pending)
106
+ return pending;
107
+ const promise = (async () => {
108
+ try {
109
+ const value = await factory();
110
+ setCached(key, value);
111
+ return value;
112
+ }
113
+ finally {
114
+ inFlight.delete(key);
115
+ }
116
+ })();
117
+ inFlight.set(key, promise);
118
+ return promise;
119
+ }
120
+ // Expired entries are swept lazily during cache activity. This preserves the
121
+ // long-running Express cleanup behavior without creating an import-time timer,
122
+ // which some request-scoped runtimes prohibit. Entry count and byte ceilings
123
+ // remain the hard memory bounds between sweeps.
124
+ function storeSize() {
125
+ return store.size;
126
+ }
127
+ function storeBytes() {
128
+ return totalBytes;
129
+ }
130
+ export { cached, MAX_ENTRIES, MAX_TOTAL_BYTES, storeSize as _storeSize, storeBytes as _storeBytes, };