@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,884 @@
1
+ // Pure typosquat candidate generation. The UI consumes richer candidate
2
+ // objects so a later scan can explain why each domain was generated; callers
3
+ // that only need the historical string list can use the compatibility wrapper.
4
+ import { advancedConfusableVariantsForAscii, confusableCharactersForAscii, estimateAdvancedConfusableVariants, MAX_ADVANCED_CONFUSABLE_VARIANTS, wholeLabelConfusableVariantsForAscii, } from "./idn-confusables.mjs";
5
+ export { MAX_ADVANCED_CONFUSABLE_VARIANTS };
6
+ function buildKeyboardAdjacency(rows) {
7
+ const positions = new Map();
8
+ for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
9
+ const row = rows[rowIndex];
10
+ if (!row)
11
+ continue;
12
+ for (let index = 0; index < row.keys.length; index += 1) {
13
+ positions.set(row.keys.charAt(index), { rowIndex, index, x: index + row.offset });
14
+ }
15
+ }
16
+ const adjacency = {};
17
+ for (const [key, position] of positions) {
18
+ const neighbours = [];
19
+ const sameRow = rows[position.rowIndex]?.keys ?? '';
20
+ if (position.index > 0)
21
+ neighbours.push(sameRow.charAt(position.index - 1));
22
+ if (position.index + 1 < sameRow.length)
23
+ neighbours.push(sameRow.charAt(position.index + 1));
24
+ for (const adjacentRowIndex of [position.rowIndex - 1, position.rowIndex + 1]) {
25
+ const adjacentRow = rows[adjacentRowIndex];
26
+ if (!adjacentRow)
27
+ continue;
28
+ for (let index = 0; index < adjacentRow.keys.length; index += 1) {
29
+ if (Math.abs((index + adjacentRow.offset) - position.x) <= 0.51) {
30
+ neighbours.push(adjacentRow.keys.charAt(index));
31
+ }
32
+ }
33
+ }
34
+ adjacency[key] = [...new Set(neighbours)].join('');
35
+ }
36
+ return Object.freeze(adjacency);
37
+ }
38
+ const QWERTY_ADJACENT = buildKeyboardAdjacency([
39
+ { keys: '1234567890', offset: 0 },
40
+ { keys: 'qwertyuiop', offset: 0.5 },
41
+ { keys: 'asdfghjkl', offset: 1 },
42
+ { keys: 'zxcvbnm', offset: 1.5 },
43
+ ]);
44
+ const AZERTY_ADJACENT = buildKeyboardAdjacency([
45
+ { keys: '1234567890', offset: 0 },
46
+ { keys: 'azertyuiop', offset: 0.5 },
47
+ { keys: 'qsdfghjklm', offset: 1 },
48
+ { keys: 'wxcvbn', offset: 1.5 },
49
+ ]);
50
+ const QWERTZ_ADJACENT = buildKeyboardAdjacency([
51
+ { keys: '1234567890', offset: 0 },
52
+ { keys: 'qwertzuiop', offset: 0.5 },
53
+ { keys: 'asdfghjkl', offset: 1 },
54
+ { keys: 'yxcvbnm', offset: 1.5 },
55
+ ]);
56
+ function combineKeyboardAdjacency(layouts) {
57
+ const combined = {};
58
+ for (const layout of layouts) {
59
+ for (const [key, neighbours] of Object.entries(layout)) {
60
+ combined[key] = [...new Set(`${combined[key] || ''}${neighbours}`)].join('');
61
+ }
62
+ }
63
+ return Object.freeze(combined);
64
+ }
65
+ const ALL_KEYBOARD_ADJACENT = combineKeyboardAdjacency([QWERTY_ADJACENT, AZERTY_ADJACENT, QWERTZ_ADJACENT]);
66
+ export const DEFAULT_KEYBOARD_LAYOUT = 'qwerty';
67
+ export const KEYBOARD_LAYOUTS = Object.freeze({
68
+ qwerty: Object.freeze({ id: 'qwerty', label: 'QWERTY', adjacent: Object.freeze(QWERTY_ADJACENT) }),
69
+ azerty: Object.freeze({ id: 'azerty', label: 'AZERTY', adjacent: AZERTY_ADJACENT }),
70
+ qwertz: Object.freeze({ id: 'qwertz', label: 'QWERTZ', adjacent: QWERTZ_ADJACENT }),
71
+ all: Object.freeze({ id: 'all', label: 'All supported layouts', adjacent: ALL_KEYBOARD_ADJACENT }),
72
+ });
73
+ const HOMOGLYPH_SWAPS = [
74
+ ['rn', 'm'], ['m', 'rn'], ['o', '0'], ['0', 'o'], ['l', '1'], ['1', 'l'], ['i', '1'], ['vv', 'w'], ['w', 'vv'],
75
+ ];
76
+ const VOWELS = 'aeiou';
77
+ const IMPERSONATION_TERMS = Object.freeze([
78
+ 'login',
79
+ 'signin',
80
+ 'auth',
81
+ 'sso',
82
+ 'secure',
83
+ 'verify',
84
+ 'account',
85
+ 'password',
86
+ 'recovery',
87
+ 'support',
88
+ 'helpdesk',
89
+ 'security',
90
+ 'update',
91
+ 'confirm',
92
+ 'portal',
93
+ 'admin',
94
+ 'service',
95
+ 'billing',
96
+ 'payment',
97
+ 'wallet',
98
+ 'identity',
99
+ 'password-reset',
100
+ 'account-recovery',
101
+ 'customer-support',
102
+ 'security-check',
103
+ ]);
104
+ const TLD_TYPOS = {
105
+ com: ['cm', 'co', 'om', 'con', 'comm', 'cim', 'vom'],
106
+ net: ['ner', 'nte', 'ne'],
107
+ org: ['ogr', 'rg', 'orgg'],
108
+ };
109
+ export const MAX_GENERATION_TLDS = 20;
110
+ export const MAX_NAME_VARIANTS = 1_500;
111
+ export const MAX_GENERATED_CANDIDATES = 2_000;
112
+ export const MAX_GENERATION_INPUT_LENGTH = 253;
113
+ export const MAX_CUSTOM_DICTIONARY_TERMS = 100;
114
+ export const MAX_CUSTOM_DICTIONARY_TERM_LENGTH = 32;
115
+ export const MAX_CUSTOM_DICTIONARY_TEXT_LENGTH = 4_096;
116
+ const MAX_LABEL_LENGTH = 63;
117
+ const MAX_TLD_INPUTS_INSPECTED = MAX_GENERATION_TLDS * 4;
118
+ const MAX_CUSTOM_DICTIONARY_INPUTS_INSPECTED = MAX_CUSTOM_DICTIONARY_TERMS * 4;
119
+ const CONTROL_CHARACTER_RE = /[\x00-\x1f\x7f]/;
120
+ const DOMAIN_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
121
+ const TLD_RE = /^[a-z]{2,63}$/;
122
+ const CUSTOM_DICTIONARY_TERM_RE = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
123
+ const FAMILY_NEW_VARIANT_LIMITS = Object.freeze({
124
+ character_addition: 128,
125
+ character_omission: 128,
126
+ character_duplication: 128,
127
+ character_transposition: 128,
128
+ pluralization: 16,
129
+ tld_embedding: 16,
130
+ www_prefix: 8,
131
+ hyphenation: 64,
132
+ separator_omission: 16,
133
+ word_reordering: 32,
134
+ keyboard_substitution: 512,
135
+ keyboard_insertion: 768,
136
+ vowel_swap: 256,
137
+ bitsquatting: 512,
138
+ ascii_homoglyph: 128,
139
+ unicode_homoglyph: 768,
140
+ unicode_whole_label: 8,
141
+ unicode_homoglyph_depth_2: MAX_ADVANCED_CONFUSABLE_VARIANTS,
142
+ dictionary: 512,
143
+ dictionary_token_replacement: 200,
144
+ });
145
+ const ADVANCED_ONLY_MUTATIONS = Object.freeze([
146
+ 'unicode_homoglyph_depth_2',
147
+ ]);
148
+ const COMMON_EDIT_MUTATIONS = Object.freeze([
149
+ 'character_addition',
150
+ 'character_omission',
151
+ 'character_duplication',
152
+ 'character_transposition',
153
+ 'pluralization',
154
+ 'tld_embedding',
155
+ 'www_prefix',
156
+ 'hyphenation',
157
+ 'separator_omission',
158
+ 'word_reordering',
159
+ 'keyboard_substitution',
160
+ 'vowel_swap',
161
+ 'bitsquatting',
162
+ 'tld_typo',
163
+ 'tld_substitution',
164
+ ]);
165
+ const IMPERSONATION_MUTATIONS = Object.freeze([
166
+ 'ascii_homoglyph',
167
+ 'unicode_homoglyph',
168
+ 'unicode_whole_label',
169
+ 'dictionary',
170
+ 'dictionary_token_replacement',
171
+ 'hyphenation',
172
+ 'word_reordering',
173
+ 'tld_typo',
174
+ 'tld_substitution',
175
+ ]);
176
+ const ALL_MUTATIONS = Object.freeze([
177
+ ...Object.keys(FAMILY_NEW_VARIANT_LIMITS)
178
+ .filter((mutationType) => !ADVANCED_ONLY_MUTATIONS.includes(mutationType)),
179
+ 'tld_typo',
180
+ 'tld_substitution',
181
+ ]);
182
+ export const DEFAULT_CUSTOM_MUTATION_FAMILY_IDS = ALL_MUTATIONS;
183
+ export const MUTATION_FAMILY_IDS = Object.freeze([
184
+ ...Object.keys(FAMILY_NEW_VARIANT_LIMITS),
185
+ 'tld_typo',
186
+ 'tld_substitution',
187
+ ]);
188
+ export const DEFAULT_GENERATION_PRESET = 'all';
189
+ export const GENERATION_PRESETS = Object.freeze({
190
+ common: Object.freeze({
191
+ id: 'common',
192
+ label: 'Common edits',
193
+ description: 'Character, separator, word-order, keyboard, bitsquat, and TLD changes.',
194
+ mutationTypes: COMMON_EDIT_MUTATIONS,
195
+ }),
196
+ impersonation: Object.freeze({
197
+ id: 'impersonation',
198
+ label: 'Impersonation',
199
+ description: 'Lookalike characters, account-themed terms, word forms, and TLD changes.',
200
+ mutationTypes: IMPERSONATION_MUTATIONS,
201
+ }),
202
+ all: Object.freeze({
203
+ id: 'all',
204
+ label: 'All families',
205
+ description: 'Every standard bounded family, including keyboard insertions.',
206
+ mutationTypes: ALL_MUTATIONS,
207
+ }),
208
+ custom: Object.freeze({
209
+ id: 'custom',
210
+ label: 'Custom families',
211
+ description: 'Use only the mutation families you select.',
212
+ mutationTypes: Object.freeze([]),
213
+ }),
214
+ });
215
+ export const MUTATION_LABELS = {
216
+ character_addition: 'Character addition',
217
+ character_omission: 'Character omission',
218
+ character_duplication: 'Character duplication',
219
+ character_transposition: 'Adjacent transposition',
220
+ pluralization: 'Plural form',
221
+ tld_embedding: 'TLD embedded in label',
222
+ www_prefix: 'WWW-style prefix',
223
+ hyphenation: 'Hyphen insertion',
224
+ separator_omission: 'Separator omission',
225
+ word_reordering: 'Word reordering',
226
+ keyboard_substitution: 'Keyboard substitution',
227
+ keyboard_insertion: 'Keyboard insertion',
228
+ vowel_swap: 'Vowel swap',
229
+ bitsquatting: 'Bitsquatting',
230
+ ascii_homoglyph: 'ASCII lookalike',
231
+ unicode_homoglyph: 'Unicode confusable',
232
+ unicode_whole_label: 'Whole-label Unicode confusable',
233
+ unicode_homoglyph_depth_2: 'Advanced two-character Unicode confusable',
234
+ dictionary: 'Impersonation term',
235
+ dictionary_token_replacement: 'Dictionary token replacement',
236
+ tld_typo: 'TLD typo',
237
+ tld_substitution: 'Selected TLD substitution',
238
+ };
239
+ function isValidDomainLabel(value) {
240
+ return typeof value === 'string' && value.length <= MAX_LABEL_LENGTH && DOMAIN_LABEL_RE.test(value);
241
+ }
242
+ function splitDomainParts(input) {
243
+ if (typeof input !== 'string' || input.length > MAX_GENERATION_INPUT_LENGTH || CONTROL_CHARACTER_RE.test(input))
244
+ return null;
245
+ const trimmed = input.trim().toLowerCase();
246
+ if (!trimmed)
247
+ return null;
248
+ if (trimmed.includes('.')) {
249
+ const match = trimmed.match(/^([a-z0-9-]+)\.([a-z]+)$/);
250
+ const name = match?.[1];
251
+ const tld = match?.[2];
252
+ if (!isValidDomainLabel(name) || typeof tld !== 'string' || !TLD_RE.test(tld))
253
+ return null;
254
+ const tokens = name.split('-');
255
+ return { name, tld, wordTokens: tokens.length >= 2 && tokens.length <= 4 && tokens.every(Boolean) ? tokens : [] };
256
+ }
257
+ // Preserve the established brand-label convenience while refusing to turn
258
+ // ambiguous dotted inputs into a different domain by deleting punctuation.
259
+ const name = trimmed.replace(/[^a-z0-9-]/g, '');
260
+ if (!isValidDomainLabel(name))
261
+ return null;
262
+ const tokenized = /^[a-z0-9]+(?:[\s_-]+[a-z0-9]+){1,3}$/.test(trimmed)
263
+ ? trimmed.split(/[\s_-]+/)
264
+ : [];
265
+ return { name, tld: null, wordTokens: tokenized.length >= 2 && tokenized.length <= 4 ? tokenized : [] };
266
+ }
267
+ function distinctWordOrders(tokens) {
268
+ if (!Array.isArray(tokens) || tokens.length < 2 || tokens.length > 4)
269
+ return [];
270
+ if (!tokens.every((token) => typeof token === 'string'))
271
+ return [];
272
+ const strings = tokens;
273
+ const orders = [];
274
+ const used = Array(strings.length).fill(false);
275
+ const current = [];
276
+ function visit() {
277
+ if (current.length === strings.length) {
278
+ orders.push([...current]);
279
+ return;
280
+ }
281
+ const seenAtDepth = new Set();
282
+ for (let index = 0; index < strings.length; index += 1) {
283
+ const value = strings[index];
284
+ if (used[index] || !value || seenAtDepth.has(value))
285
+ continue;
286
+ seenAtDepth.add(value);
287
+ used[index] = true;
288
+ current.push(value);
289
+ visit();
290
+ current.pop();
291
+ used[index] = false;
292
+ }
293
+ }
294
+ visit();
295
+ return orders;
296
+ }
297
+ function normalizeTlds(values) {
298
+ if (!Array.isArray(values))
299
+ return { values: [], truncated: false };
300
+ const normalized = [];
301
+ const seen = new Set();
302
+ let truncated = values.length > MAX_TLD_INPUTS_INSPECTED;
303
+ for (const raw of values.slice(0, MAX_TLD_INPUTS_INSPECTED)) {
304
+ if (typeof raw !== 'string' || raw.length > MAX_LABEL_LENGTH + 1 || CONTROL_CHARACTER_RE.test(raw))
305
+ continue;
306
+ const value = raw.trim().toLowerCase().replace(/^\./, '');
307
+ if (!TLD_RE.test(value) || seen.has(value))
308
+ continue;
309
+ if (normalized.length >= MAX_GENERATION_TLDS) {
310
+ truncated = true;
311
+ continue;
312
+ }
313
+ seen.add(value);
314
+ normalized.push(value);
315
+ }
316
+ return { values: normalized, truncated };
317
+ }
318
+ export function normalizeCustomDictionaryTerms(raw) {
319
+ let inputs;
320
+ let truncated = false;
321
+ if (typeof raw === 'string') {
322
+ const bounded = raw.slice(0, MAX_CUSTOM_DICTIONARY_TEXT_LENGTH);
323
+ truncated = raw.length > MAX_CUSTOM_DICTIONARY_TEXT_LENGTH;
324
+ inputs = bounded.split(/[;,\s]+/).filter(Boolean);
325
+ }
326
+ else if (Array.isArray(raw)) {
327
+ inputs = raw.slice(0, MAX_CUSTOM_DICTIONARY_INPUTS_INSPECTED);
328
+ truncated = raw.length > MAX_CUSTOM_DICTIONARY_INPUTS_INSPECTED;
329
+ }
330
+ else {
331
+ inputs = [];
332
+ }
333
+ if (inputs.length > MAX_CUSTOM_DICTIONARY_INPUTS_INSPECTED) {
334
+ inputs = inputs.slice(0, MAX_CUSTOM_DICTIONARY_INPUTS_INSPECTED);
335
+ truncated = true;
336
+ }
337
+ const values = [];
338
+ const seen = new Set();
339
+ let rejectedCount = 0;
340
+ for (const rawValue of inputs) {
341
+ if (typeof rawValue !== 'string'
342
+ || rawValue.length > MAX_CUSTOM_DICTIONARY_TERM_LENGTH
343
+ || CONTROL_CHARACTER_RE.test(rawValue)) {
344
+ rejectedCount += 1;
345
+ continue;
346
+ }
347
+ const value = rawValue.trim().toLowerCase();
348
+ if (!CUSTOM_DICTIONARY_TERM_RE.test(value)) {
349
+ rejectedCount += 1;
350
+ continue;
351
+ }
352
+ if (seen.has(value))
353
+ continue;
354
+ if (values.length >= MAX_CUSTOM_DICTIONARY_TERMS) {
355
+ truncated = true;
356
+ continue;
357
+ }
358
+ seen.add(value);
359
+ values.push(value);
360
+ }
361
+ return { values, truncated, rejectedCount };
362
+ }
363
+ function resolveGenerationPreset(options) {
364
+ const requested = typeof options?.preset === 'string' ? options.preset : DEFAULT_GENERATION_PRESET;
365
+ const preset = GENERATION_PRESETS[requested] ?? GENERATION_PRESETS[DEFAULT_GENERATION_PRESET];
366
+ if (!preset)
367
+ throw new Error('Default generation preset is unavailable');
368
+ return preset;
369
+ }
370
+ export function normalizeMutationFamilyIds(raw) {
371
+ if (!Array.isArray(raw))
372
+ return [];
373
+ const supported = new Set(MUTATION_FAMILY_IDS);
374
+ const normalized = [];
375
+ const seen = new Set();
376
+ for (const value of raw.slice(0, MUTATION_FAMILY_IDS.length * 2)) {
377
+ if (typeof value !== 'string' || !supported.has(value) || seen.has(value))
378
+ continue;
379
+ seen.add(value);
380
+ normalized.push(value);
381
+ }
382
+ return normalized;
383
+ }
384
+ function resolveEnabledFamilies(preset, options) {
385
+ return new Set(preset.id === 'custom'
386
+ ? normalizeMutationFamilyIds(options.mutationTypes)
387
+ : preset.mutationTypes);
388
+ }
389
+ function pluralForms(name, wordTokens) {
390
+ const forms = new Set();
391
+ const addPlural = (value) => {
392
+ if (!value || value.endsWith('s'))
393
+ return;
394
+ forms.add(`${value}${/(?:s|x|z|ch|sh)$/.test(value) ? 'es' : 's'}`);
395
+ };
396
+ addPlural(name);
397
+ if (wordTokens.length >= 2) {
398
+ const last = wordTokens.at(-1) || '';
399
+ if (last && !last.endsWith('s')) {
400
+ const pluralLast = `${last}${/(?:s|x|z|ch|sh)$/.test(last) ? 'es' : 's'}`;
401
+ forms.add([...wordTokens.slice(0, -1), pluralLast].join(''));
402
+ forms.add([...wordTokens.slice(0, -1), pluralLast].join('-'));
403
+ }
404
+ }
405
+ return [...forms];
406
+ }
407
+ function resolveKeyboardLayout(options) {
408
+ const requested = typeof options?.keyboardLayout === 'string' ? options.keyboardLayout : DEFAULT_KEYBOARD_LAYOUT;
409
+ const layout = KEYBOARD_LAYOUTS[requested] ?? KEYBOARD_LAYOUTS[DEFAULT_KEYBOARD_LAYOUT];
410
+ if (!layout)
411
+ throw new Error('Default keyboard layout is unavailable');
412
+ return layout;
413
+ }
414
+ function selectCandidateTlds(sourceTld, fallbackTlds, enabledFamilies) {
415
+ if (sourceTld && !enabledFamilies.has('tld_substitution')) {
416
+ return { values: [sourceTld], truncated: false };
417
+ }
418
+ const normalizedFallbackTlds = normalizeTlds(fallbackTlds);
419
+ const combinedTlds = sourceTld
420
+ ? [sourceTld, ...normalizedFallbackTlds.values.filter((value) => value !== sourceTld)]
421
+ : normalizedFallbackTlds.values;
422
+ return {
423
+ values: combinedTlds.slice(0, MAX_GENERATION_TLDS),
424
+ truncated: normalizedFallbackTlds.truncated || combinedTlds.length > MAX_GENERATION_TLDS,
425
+ };
426
+ }
427
+ function toAsciiLabel(label) {
428
+ try {
429
+ return new URL(`https://${label}.example`).hostname.replace(/\.example$/, '');
430
+ }
431
+ catch {
432
+ return null;
433
+ }
434
+ }
435
+ function generationState(sourceName, enabledFamilies) {
436
+ return {
437
+ sourceName,
438
+ variants: new Map(),
439
+ familyCounts: new Map(),
440
+ limitReasons: new Set(),
441
+ rejectedVariantCount: 0,
442
+ enabledFamilies,
443
+ };
444
+ }
445
+ function addVariant(state, variant, mutationType) {
446
+ if (!state.enabledFamilies.has(mutationType))
447
+ return;
448
+ if (!isValidDomainLabel(variant) || variant === state.sourceName) {
449
+ if (variant && variant !== state.sourceName)
450
+ state.rejectedVariantCount += 1;
451
+ return;
452
+ }
453
+ const existing = state.variants.get(variant);
454
+ if (existing) {
455
+ // Limits constrain unique output growth, never provenance for a candidate
456
+ // that another mutation family already generated.
457
+ existing.add(mutationType);
458
+ return;
459
+ }
460
+ const familyLimit = FAMILY_NEW_VARIANT_LIMITS[mutationType];
461
+ const familyCount = state.familyCounts.get(mutationType) || 0;
462
+ if (typeof familyLimit === 'number' && familyCount >= familyLimit) {
463
+ state.limitReasons.add(`family:${mutationType}`);
464
+ return;
465
+ }
466
+ if (state.variants.size >= MAX_NAME_VARIANTS) {
467
+ state.limitReasons.add('name-variants');
468
+ return;
469
+ }
470
+ state.variants.set(variant, new Set([mutationType]));
471
+ state.familyCounts.set(mutationType, familyCount + 1);
472
+ }
473
+ function interleaveVariantsByFamily(variants) {
474
+ const grouped = new Map();
475
+ for (const entry of variants) {
476
+ const primaryFamily = entry[1].values().next().value;
477
+ if (typeof primaryFamily !== 'string')
478
+ continue;
479
+ const group = grouped.get(primaryFamily) || [];
480
+ group.push(entry);
481
+ grouped.set(primaryFamily, group);
482
+ }
483
+ const ordered = [];
484
+ const groups = [...grouped.values()];
485
+ const longestGroup = Math.max(0, ...groups.map((group) => group.length));
486
+ for (let index = 0; index < longestGroup; index += 1) {
487
+ for (const group of groups) {
488
+ const entry = group[index];
489
+ if (entry)
490
+ ordered.push(entry);
491
+ }
492
+ }
493
+ return ordered;
494
+ }
495
+ /**
496
+ * Returns a cheap upper-bound estimate without allocating candidate objects.
497
+ * The estimate intentionally precedes validity filtering and deduplication, so
498
+ * the generated result can be smaller but never larger than this bounded value.
499
+ */
500
+ export function estimateTyposquatCandidateCount(rawInput, fallbackTlds, options = {}) {
501
+ const parts = splitDomainParts(rawInput);
502
+ const preset = resolveGenerationPreset(options);
503
+ const keyboardLayout = resolveKeyboardLayout(options);
504
+ const customDictionary = normalizeCustomDictionaryTerms(options.dictionaryTerms);
505
+ if (!parts) {
506
+ return { inputValid: false, preset: preset.id, tldCount: 0, estimatedMaximum: 0, mayReachLimit: false };
507
+ }
508
+ const enabledFamilies = resolveEnabledFamilies(preset, options);
509
+ const selectedTlds = selectCandidateTlds(parts.tld, fallbackTlds, enabledFamilies);
510
+ if (selectedTlds.values.length === 0) {
511
+ return {
512
+ inputValid: true,
513
+ preset: preset.id,
514
+ tldCount: 0,
515
+ estimatedMaximum: 0,
516
+ mayReachLimit: selectedTlds.truncated,
517
+ };
518
+ }
519
+ const name = parts.name;
520
+ const hyphenCount = [...name].filter((character) => character === '-').length;
521
+ let hyphenationCount = 0;
522
+ for (let index = 1; index < name.length; index += 1) {
523
+ if (name[index - 1] !== '-' && name[index] !== '-')
524
+ hyphenationCount += 1;
525
+ }
526
+ let wordOrderMaximum = 0;
527
+ if (parts.wordTokens.length >= 2) {
528
+ let orderCount = 1;
529
+ for (let value = 2; value <= parts.wordTokens.length; value += 1)
530
+ orderCount *= value;
531
+ wordOrderMaximum = Math.max(0, orderCount - 1) * 2;
532
+ }
533
+ let adjacentCount = 0;
534
+ let vowelCount = 0;
535
+ let asciiHomoglyphCount = 0;
536
+ let unicodeHomoglyphCount = 0;
537
+ for (const character of name) {
538
+ adjacentCount += keyboardLayout.adjacent[character]?.length || 0;
539
+ if (VOWELS.includes(character))
540
+ vowelCount += 1;
541
+ unicodeHomoglyphCount += confusableCharactersForAscii(character)?.length || 0;
542
+ }
543
+ for (const [from] of HOMOGLYPH_SWAPS) {
544
+ if (name.includes(from))
545
+ asciiHomoglyphCount += 1;
546
+ }
547
+ const rawFamilyCounts = {
548
+ character_addition: 36 * (1 + hyphenCount),
549
+ character_omission: name.length,
550
+ character_duplication: name.length,
551
+ character_transposition: Math.max(0, name.length - 1),
552
+ pluralization: pluralForms(name, parts.wordTokens).length,
553
+ tld_embedding: parts.tld ? 2 : 0,
554
+ www_prefix: 3,
555
+ hyphenation: hyphenationCount,
556
+ separator_omission: hyphenCount > 0 ? hyphenCount + 1 : 0,
557
+ word_reordering: wordOrderMaximum,
558
+ keyboard_substitution: adjacentCount,
559
+ keyboard_insertion: adjacentCount * 2,
560
+ vowel_swap: vowelCount * (VOWELS.length - 1),
561
+ bitsquatting: name.length * 8,
562
+ ascii_homoglyph: asciiHomoglyphCount,
563
+ unicode_homoglyph: unicodeHomoglyphCount,
564
+ unicode_whole_label: wholeLabelConfusableVariantsForAscii(name).length,
565
+ unicode_homoglyph_depth_2: estimateAdvancedConfusableVariants(name).eligibleVariantCount,
566
+ dictionary: [...new Set([...IMPERSONATION_TERMS, ...customDictionary.values])].length * 4,
567
+ dictionary_token_replacement: name.includes('-') && parts.wordTokens.length >= 2
568
+ ? customDictionary.values.length * 2
569
+ : 0,
570
+ };
571
+ let rawVariantMaximum = 0;
572
+ let familyLimitPossible = false;
573
+ for (const [mutationType, rawCount] of Object.entries(rawFamilyCounts)) {
574
+ if (!enabledFamilies.has(mutationType))
575
+ continue;
576
+ const familyLimit = FAMILY_NEW_VARIANT_LIMITS[mutationType];
577
+ if (familyLimit === undefined)
578
+ continue;
579
+ rawVariantMaximum += Math.min(rawCount, familyLimit);
580
+ if (rawCount > familyLimit)
581
+ familyLimitPossible = true;
582
+ }
583
+ const variantMaximum = Math.min(rawVariantMaximum, MAX_NAME_VARIANTS);
584
+ const tldTypoMaximum = parts.tld && enabledFamilies.has('tld_typo') ? (TLD_TYPOS[parts.tld]?.length || 0) : 0;
585
+ const tldSubstitutionMaximum = parts.tld && enabledFamilies.has('tld_substitution')
586
+ ? Math.max(0, selectedTlds.values.length - 1)
587
+ : 0;
588
+ const rawCandidateMaximum = (variantMaximum * selectedTlds.values.length) + tldTypoMaximum + tldSubstitutionMaximum;
589
+ return {
590
+ inputValid: true,
591
+ preset: preset.id,
592
+ tldCount: selectedTlds.values.length,
593
+ estimatedMaximum: Math.min(rawCandidateMaximum, MAX_GENERATED_CANDIDATES),
594
+ mayReachLimit: selectedTlds.truncated
595
+ || ((enabledFamilies.has('dictionary') || enabledFamilies.has('dictionary_token_replacement'))
596
+ && customDictionary.truncated)
597
+ || familyLimitPossible
598
+ || rawVariantMaximum > MAX_NAME_VARIANTS
599
+ || rawCandidateMaximum > MAX_GENERATED_CANDIDATES,
600
+ };
601
+ }
602
+ /**
603
+ * Generates a bounded, deterministic candidate set with diagnostic metadata.
604
+ * Compatibility callers can continue to use generateTyposquatCandidates().
605
+ * @returns {TyposquatGenerationResult}
606
+ */
607
+ export function generateTyposquatCandidateSet(rawInput, fallbackTlds, options = {}) {
608
+ const preset = resolveGenerationPreset(options);
609
+ const keyboardLayout = resolveKeyboardLayout(options);
610
+ const customDictionary = normalizeCustomDictionaryTerms(options.dictionaryTerms);
611
+ const enabledFamilies = resolveEnabledFamilies(preset, options);
612
+ const parts = splitDomainParts(rawInput);
613
+ if (!parts) {
614
+ return {
615
+ version: 1,
616
+ candidates: [],
617
+ inputValid: false,
618
+ truncated: false,
619
+ limitReasons: [],
620
+ rejectedVariantCount: 0,
621
+ limits: { tlds: MAX_GENERATION_TLDS, nameVariants: MAX_NAME_VARIANTS, candidates: MAX_GENERATED_CANDIDATES },
622
+ };
623
+ }
624
+ const { name, tld } = parts;
625
+ const normalizedTlds = selectCandidateTlds(tld, fallbackTlds, enabledFamilies);
626
+ const tlds = normalizedTlds.values;
627
+ if (tlds.length === 0) {
628
+ return {
629
+ version: 1,
630
+ candidates: [],
631
+ inputValid: true,
632
+ truncated: normalizedTlds.truncated,
633
+ limitReasons: normalizedTlds.truncated ? ['tlds'] : [],
634
+ rejectedVariantCount: 0,
635
+ limits: { tlds: MAX_GENERATION_TLDS, nameVariants: MAX_NAME_VARIANTS, candidates: MAX_GENERATED_CANDIDATES },
636
+ };
637
+ }
638
+ const state = generationState(name, enabledFamilies);
639
+ let advancedConfusable;
640
+ if ((enabledFamilies.has('dictionary') || enabledFamilies.has('dictionary_token_replacement'))
641
+ && customDictionary.truncated) {
642
+ state.limitReasons.add('dictionary-terms');
643
+ }
644
+ if (enabledFamilies.has('character_addition')) {
645
+ const insertionPoints = [name.length];
646
+ for (let index = 1; index < name.length; index += 1) {
647
+ if (name[index] === '-')
648
+ insertionPoints.push(index);
649
+ }
650
+ for (const index of insertionPoints) {
651
+ for (const character of '0123456789abcdefghijklmnopqrstuvwxyz') {
652
+ addVariant(state, `${name.slice(0, index)}${character}${name.slice(index)}`, 'character_addition');
653
+ }
654
+ }
655
+ }
656
+ if (enabledFamilies.has('character_omission') || enabledFamilies.has('character_duplication')) {
657
+ for (let i = 0; i < name.length; i += 1) {
658
+ if (enabledFamilies.has('character_omission')) {
659
+ addVariant(state, name.slice(0, i) + name.slice(i + 1), 'character_omission');
660
+ }
661
+ if (enabledFamilies.has('character_duplication')) {
662
+ addVariant(state, name.slice(0, i + 1) + name[i] + name.slice(i + 1), 'character_duplication');
663
+ }
664
+ }
665
+ }
666
+ if (enabledFamilies.has('character_transposition')) {
667
+ for (let i = 0; i < name.length - 1; i += 1) {
668
+ addVariant(state, name.slice(0, i) + name[i + 1] + name[i] + name.slice(i + 2), 'character_transposition');
669
+ }
670
+ }
671
+ if (enabledFamilies.has('pluralization')) {
672
+ for (const variant of pluralForms(name, parts.wordTokens))
673
+ addVariant(state, variant, 'pluralization');
674
+ }
675
+ if (enabledFamilies.has('tld_embedding') && tld) {
676
+ addVariant(state, `${name}${tld}`, 'tld_embedding');
677
+ addVariant(state, `${name}-${tld}`, 'tld_embedding');
678
+ }
679
+ if (enabledFamilies.has('www_prefix')) {
680
+ addVariant(state, `ww${name}`, 'www_prefix');
681
+ addVariant(state, `www${name}`, 'www_prefix');
682
+ addVariant(state, `www-${name}`, 'www_prefix');
683
+ }
684
+ if (enabledFamilies.has('hyphenation')) {
685
+ for (let index = 1; index < name.length; index += 1) {
686
+ if (name[index - 1] === '-' || name[index] === '-')
687
+ continue;
688
+ addVariant(state, `${name.slice(0, index)}-${name.slice(index)}`, 'hyphenation');
689
+ }
690
+ }
691
+ if (enabledFamilies.has('separator_omission') && name.includes('-')) {
692
+ for (let index = 0; index < name.length; index += 1) {
693
+ if (name[index] === '-')
694
+ addVariant(state, name.slice(0, index) + name.slice(index + 1), 'separator_omission');
695
+ }
696
+ addVariant(state, name.replaceAll('-', ''), 'separator_omission');
697
+ }
698
+ if (enabledFamilies.has('word_reordering') && parts.wordTokens.length >= 2) {
699
+ const originalOrder = parts.wordTokens.join('\u0000');
700
+ for (const order of distinctWordOrders(parts.wordTokens)) {
701
+ if (order.join('\u0000') === originalOrder)
702
+ continue;
703
+ addVariant(state, order.join(''), 'word_reordering');
704
+ addVariant(state, order.join('-'), 'word_reordering');
705
+ }
706
+ }
707
+ if (enabledFamilies.has('keyboard_substitution') || enabledFamilies.has('keyboard_insertion')) {
708
+ for (let i = 0; i < name.length; i += 1) {
709
+ const adjacent = keyboardLayout.adjacent[name.charAt(i)];
710
+ if (!adjacent)
711
+ continue;
712
+ for (const key of adjacent) {
713
+ if (enabledFamilies.has('keyboard_substitution')) {
714
+ addVariant(state, name.slice(0, i) + key + name.slice(i + 1), 'keyboard_substitution');
715
+ }
716
+ if (enabledFamilies.has('keyboard_insertion')) {
717
+ addVariant(state, name.slice(0, i) + key + name.slice(i), 'keyboard_insertion');
718
+ addVariant(state, name.slice(0, i + 1) + key + name.slice(i + 1), 'keyboard_insertion');
719
+ }
720
+ }
721
+ }
722
+ }
723
+ if (enabledFamilies.has('vowel_swap')) {
724
+ for (let i = 0; i < name.length; i += 1) {
725
+ const character = name.charAt(i);
726
+ if (!VOWELS.includes(character))
727
+ continue;
728
+ for (const vowel of VOWELS) {
729
+ if (vowel !== character)
730
+ addVariant(state, name.slice(0, i) + vowel + name.slice(i + 1), 'vowel_swap');
731
+ }
732
+ }
733
+ }
734
+ if (enabledFamilies.has('bitsquatting')) {
735
+ for (let i = 0; i < name.length; i += 1) {
736
+ const code = name.charCodeAt(i);
737
+ for (let bit = 0; bit < 8; bit += 1) {
738
+ const flipped = String.fromCharCode(code ^ (1 << bit));
739
+ if (/^[a-z0-9-]$/.test(flipped)) {
740
+ addVariant(state, name.slice(0, i) + flipped + name.slice(i + 1), 'bitsquatting');
741
+ }
742
+ }
743
+ }
744
+ }
745
+ if (enabledFamilies.has('ascii_homoglyph')) {
746
+ for (const [from, to] of HOMOGLYPH_SWAPS) {
747
+ if (name.includes(from))
748
+ addVariant(state, name.split(from).join(to), 'ascii_homoglyph');
749
+ }
750
+ }
751
+ if (enabledFamilies.has('unicode_whole_label')) {
752
+ for (const variant of wholeLabelConfusableVariantsForAscii(name)) {
753
+ const ascii = toAsciiLabel(variant.unicodeLabel);
754
+ if (!ascii)
755
+ continue;
756
+ // Preserve the established Risk-model input while separately explaining
757
+ // that every replaceable letter uses one reviewed non-Latin script.
758
+ addVariant(state, ascii, 'unicode_homoglyph');
759
+ addVariant(state, ascii, 'unicode_whole_label');
760
+ }
761
+ }
762
+ if (enabledFamilies.has('unicode_homoglyph')) {
763
+ for (let i = 0; i < name.length; i += 1) {
764
+ const substitutions = confusableCharactersForAscii(name[i]);
765
+ if (!substitutions)
766
+ continue;
767
+ for (const substitution of substitutions) {
768
+ const ascii = toAsciiLabel(name.slice(0, i) + substitution + name.slice(i + 1));
769
+ if (ascii)
770
+ addVariant(state, ascii, 'unicode_homoglyph');
771
+ }
772
+ }
773
+ }
774
+ if (enabledFamilies.has('unicode_homoglyph_depth_2')) {
775
+ const advanced = advancedConfusableVariantsForAscii(name);
776
+ const rejectedBefore = state.rejectedVariantCount;
777
+ let rejectedByEncoding = 0;
778
+ for (const variant of advanced.variants) {
779
+ const ascii = toAsciiLabel(variant.unicodeLabel);
780
+ if (!ascii) {
781
+ rejectedByEncoding += 1;
782
+ continue;
783
+ }
784
+ addVariant(state, ascii, 'unicode_homoglyph_depth_2');
785
+ }
786
+ const generated = [...state.variants.values()]
787
+ .filter((mutationTypes) => mutationTypes.has('unicode_homoglyph_depth_2'))
788
+ .length;
789
+ const rejectedByValidation = state.rejectedVariantCount - rejectedBefore;
790
+ const omittedByBudget = advanced.omittedByBudget
791
+ + Math.max(0, advanced.variants.length - rejectedByEncoding - rejectedByValidation - generated);
792
+ advancedConfusable = {
793
+ generated,
794
+ omittedByPolicy: advanced.omittedByPolicy + rejectedByEncoding + rejectedByValidation,
795
+ omittedByBudget,
796
+ };
797
+ if (advanced.omittedByBudget > 0) {
798
+ state.limitReasons.add('family:unicode_homoglyph_depth_2');
799
+ }
800
+ }
801
+ if (enabledFamilies.has('dictionary')) {
802
+ // Analyst-supplied terms are deliberate inputs, so they precede the
803
+ // built-in vocabulary when the per-family or global cap applies.
804
+ for (const word of [...new Set([...customDictionary.values, ...IMPERSONATION_TERMS])]) {
805
+ addVariant(state, `${word}${name}`, 'dictionary');
806
+ addVariant(state, `${word}-${name}`, 'dictionary');
807
+ addVariant(state, `${name}${word}`, 'dictionary');
808
+ addVariant(state, `${name}-${word}`, 'dictionary');
809
+ }
810
+ }
811
+ if (enabledFamilies.has('dictionary_token_replacement')
812
+ && name.includes('-')
813
+ && parts.wordTokens.length >= 2) {
814
+ const firstTail = parts.wordTokens.slice(1).join('-');
815
+ const lastHead = parts.wordTokens.slice(0, -1).join('-');
816
+ for (const word of customDictionary.values) {
817
+ addVariant(state, `${word}-${firstTail}`, 'dictionary_token_replacement');
818
+ addVariant(state, `${lastHead}-${word}`, 'dictionary_token_replacement');
819
+ }
820
+ }
821
+ const byDomain = new Map();
822
+ const source = tld ? `${name}.${tld}` : name;
823
+ function addCandidate(domain, candidateTld, mutationTypes) {
824
+ const existing = byDomain.get(domain);
825
+ if (existing) {
826
+ for (const mutationType of mutationTypes) {
827
+ if (!existing.mutationTypes.includes(mutationType))
828
+ existing.mutationTypes.push(mutationType);
829
+ }
830
+ return;
831
+ }
832
+ if (byDomain.size >= MAX_GENERATED_CANDIDATES) {
833
+ state.limitReasons.add('candidates');
834
+ return;
835
+ }
836
+ byDomain.set(domain, { domain, source, tld: candidateTld, mutationTypes: [...mutationTypes] });
837
+ }
838
+ // Reserve room for same-name TLD variants before the cross-product reaches
839
+ // the global cap; these candidates are otherwise easy to starve with 20 TLDs.
840
+ if (tld && enabledFamilies.has('tld_typo') && TLD_TYPOS[tld]) {
841
+ for (const typoTld of TLD_TYPOS[tld])
842
+ addCandidate(`${name}.${typoTld}`, typoTld, ['tld_typo']);
843
+ }
844
+ if (tld && enabledFamilies.has('tld_substitution')) {
845
+ for (const candidateTld of tlds) {
846
+ if (candidateTld !== tld)
847
+ addCandidate(`${name}.${candidateTld}`, candidateTld, ['tld_substitution']);
848
+ }
849
+ }
850
+ // Interleave families before applying the global candidate cap. Without
851
+ // this deterministic round-robin, early high-volume edit families can
852
+ // completely hide later analyst-directed or Unicode families.
853
+ candidateLoop: for (const [variant, mutationTypes] of interleaveVariantsByFamily(state.variants)) {
854
+ for (const candidateTld of tlds) {
855
+ if (byDomain.size >= MAX_GENERATED_CANDIDATES) {
856
+ state.limitReasons.add('candidates');
857
+ break candidateLoop;
858
+ }
859
+ const domain = `${variant}.${candidateTld}`;
860
+ const candidateMutationTypes = tld && candidateTld !== tld
861
+ ? [...mutationTypes, 'tld_substitution']
862
+ : mutationTypes;
863
+ addCandidate(domain, candidateTld, candidateMutationTypes);
864
+ }
865
+ }
866
+ if (normalizedTlds.truncated)
867
+ state.limitReasons.add('tlds');
868
+ return {
869
+ version: 1,
870
+ candidates: [...byDomain.values()],
871
+ inputValid: true,
872
+ truncated: state.limitReasons.size > 0,
873
+ limitReasons: [...state.limitReasons],
874
+ rejectedVariantCount: state.rejectedVariantCount,
875
+ ...(advancedConfusable ? { advancedConfusable } : {}),
876
+ limits: { tlds: MAX_GENERATION_TLDS, nameVariants: MAX_NAME_VARIANTS, candidates: MAX_GENERATED_CANDIDATES },
877
+ };
878
+ }
879
+ export function generateTyposquatCandidates(rawInput, fallbackTlds, options = {}) {
880
+ return generateTyposquatCandidateSet(rawInput, fallbackTlds, options).candidates;
881
+ }
882
+ export function generateTyposquatVariants(rawInput, fallbackTlds, options = {}) {
883
+ return generateTyposquatCandidates(rawInput, fallbackTlds, options).map((candidate) => candidate.domain);
884
+ }