@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,261 @@
1
+ // WHOIS referral-chain orchestration. Transport remains isolated in
2
+ // whois-transport.mts; this module applies registry query profiles, follows
3
+ // bounded referrals, and adapts the registry-web fallback into WHOIS text.
4
+ import { domainToUnicode } from 'node:url';
5
+ import { cached } from "./lookup-cache.mjs";
6
+ import { registryCapabilityFor } from "./registry-capabilities.mjs";
7
+ import { readTextCapped, safeFetch } from "./safe-fetch.mjs";
8
+ import { whoisQuery } from "./whois-transport.mjs";
9
+ const IANA_WHOIS = 'whois.iana.org';
10
+ const WHOIS_HOP_DEADLINE_MS = 12_000;
11
+ const WHOIS_CHAIN_DEADLINE_MS = 25_000;
12
+ const MAX_GT_REGISTRY_HTML_BYTES = 500_000;
13
+ const WHOIS_QUERY_FORMATTERS = {
14
+ 'plain-domain': (domain) => domain,
15
+ 'denic-domain-ace': (domain) => `-T dn,ace ${domain}`,
16
+ 'jprs-domain-english': (domain) => `${domain}/e`,
17
+ 'registry-domain-unicode': (domain) => domainToUnicode(domain),
18
+ };
19
+ function whoisTransportForHop(domain, hop) {
20
+ const capability = registryCapabilityFor(domain);
21
+ const queryProfile = hop === 1 && capability?.whoisQueryScope === 'first-referral'
22
+ ? capability.whoisQueryProfile
23
+ : 'plain-domain';
24
+ return {
25
+ query: WHOIS_QUERY_FORMATTERS[queryProfile](domain),
26
+ queryProfile,
27
+ responseEncoding: capability?.whoisEncodingProfile || 'utf-8',
28
+ };
29
+ }
30
+ function errorMessage(value, fallback) {
31
+ if (!value || typeof value !== 'object' || Array.isArray(value))
32
+ return fallback;
33
+ const message = value.message;
34
+ return message ? String(message) : fallback;
35
+ }
36
+ function extractReferral(whoisText) {
37
+ // Horizontal whitespace is intentional. `\s*` can cross a blank line and
38
+ // misread the next field label when a registry publishes an empty referral.
39
+ const patterns = [
40
+ /^[ \t]*refer:[ \t]*([a-zA-Z0-9.\-]+)/mi,
41
+ /^[ \t]*ReferralServer:[ \t]*whois:\/\/([a-zA-Z0-9.\-]+)/mi,
42
+ /^[ \t]*whois:[ \t]*([a-zA-Z0-9.\-]+)/mi,
43
+ ];
44
+ for (const pattern of patterns) {
45
+ const referral = whoisText.match(pattern)?.[1]?.trim();
46
+ if (referral)
47
+ return referral;
48
+ }
49
+ return null;
50
+ }
51
+ function stripTags(html) {
52
+ return html
53
+ .replace(/<[^>]+>/g, ' ')
54
+ .replace(/&nbsp;/g, ' ')
55
+ .replace(/\s+/g, ' ')
56
+ .trim();
57
+ }
58
+ function sectionBetween(html, startRe, endRes) {
59
+ const startMatch = html.match(startRe);
60
+ if (!startMatch)
61
+ return '';
62
+ const rest = html.slice((startMatch.index ?? 0) + startMatch[0].length);
63
+ let endIndex = rest.length;
64
+ for (const endRe of endRes) {
65
+ const match = rest.match(endRe);
66
+ if (!match)
67
+ continue;
68
+ const matchIndex = match.index ?? 0;
69
+ const index = match[0].startsWith('>') ? matchIndex + 1 : matchIndex;
70
+ if (index < endIndex)
71
+ endIndex = index;
72
+ }
73
+ return rest.slice(0, endIndex);
74
+ }
75
+ function extractIconFields(html, iconMap) {
76
+ let marked = html;
77
+ for (const [icon, key] of Object.entries(iconMap)) {
78
+ marked = marked.replace(new RegExp(`<i[^>]*\\b${icon}\\b[^>]*></i>`, 'gi'), `\n@@${key}@@\n`);
79
+ }
80
+ const parts = stripTags(marked).split(/@@(\w+)@@/);
81
+ const fields = {};
82
+ for (let index = 1; index < parts.length; index += 2) {
83
+ const value = (parts[index + 1] || '').trim();
84
+ const key = parts[index];
85
+ if (key && value && !fields[key])
86
+ fields[key] = value;
87
+ }
88
+ return fields;
89
+ }
90
+ export async function fetchGtRegistryWhois(domain, { fetcher = safeFetch, } = {}) {
91
+ const url = `https://www.gt/sitio/whois.php?dn=${encodeURIComponent(domain)}.&lang=en`;
92
+ const controller = new AbortController();
93
+ const timeout = setTimeout(() => controller.abort(), 8_000);
94
+ try {
95
+ const response = await fetcher(url, {
96
+ headers: { 'User-Agent': 'Mozilla/5.0 (compatible; DomainStatusChecker/1.0)' },
97
+ signal: controller.signal,
98
+ });
99
+ if (!response.ok) {
100
+ await response.body?.cancel().catch(() => { });
101
+ return null;
102
+ }
103
+ const body = await readTextCapped(response, MAX_GT_REGISTRY_HTML_BYTES);
104
+ if (body.truncated)
105
+ return null;
106
+ const html = body.text;
107
+ if (/is not registered/i.test(html))
108
+ return { registered: false };
109
+ const statusMatch = html.match(/<i class="fas fa-bell fa-fw"><\/i>\s*([A-Za-z]+)/i);
110
+ const expiryMatch = html.match(/Expiration:\s*([0-9]{4}-[A-Za-z]{3}-[0-9]{2}[^<]*)/i);
111
+ const orgSection = sectionBetween(html, /Entitled Organization/i, [/Servers\s*<\/h4>/i]);
112
+ const org = extractIconFields(orgSection, {
113
+ 'fa-building': 'org',
114
+ 'fa-address-card': 'address',
115
+ 'fa-phone': 'phone',
116
+ });
117
+ const adminSection = sectionBetween(html, />\s*ADMINISTRATIVE\s*</i, [/>\s*TECHNICAL\s*</i, />\s*BILLING\s*</i]);
118
+ const admin = extractIconFields(adminSection, {
119
+ 'fa-user': 'name',
120
+ 'fa-envelope': 'email',
121
+ 'fa-address-card': 'address',
122
+ 'fa-building': 'org',
123
+ });
124
+ const serversSection = sectionBetween(html, /Servers\s*<\/h4>/i, [
125
+ /<div class="span6">/i,
126
+ ]);
127
+ const nameservers = [
128
+ ...serversSection.matchAll(/<strong>\s*([a-zA-Z0-9.\-]+)\.?\s*<\/strong>/gi),
129
+ ]
130
+ .map((match) => match[1]?.trim())
131
+ .filter((value) => Boolean(value));
132
+ return {
133
+ registered: true,
134
+ status: statusMatch?.[1]?.trim() || null,
135
+ expiryDate: expiryMatch?.[1]?.trim() || null,
136
+ registrantOrg: org.org || null,
137
+ registrantAddress: org.address || null,
138
+ registrantPhone: org.phone || null,
139
+ adminName: admin.name || null,
140
+ adminOrg: admin.org || null,
141
+ adminEmail: admin.email || null,
142
+ nameservers,
143
+ };
144
+ }
145
+ catch {
146
+ return null;
147
+ }
148
+ finally {
149
+ clearTimeout(timeout);
150
+ }
151
+ }
152
+ function formatGtResultAsText(domain, result) {
153
+ if (!result.registered)
154
+ return `No match for domain ${domain.toUpperCase()}.`;
155
+ const lines = [`Domain Name: ${domain.toUpperCase()}`];
156
+ if (result.status)
157
+ lines.push(`Domain Status: ${result.status}`);
158
+ if (result.expiryDate)
159
+ lines.push(`Registry Expiry Date: ${result.expiryDate}`);
160
+ if (result.registrantOrg)
161
+ lines.push(`Registrant Organization: ${result.registrantOrg}`);
162
+ if (result.registrantAddress)
163
+ lines.push(`Registrant Address: ${result.registrantAddress}`);
164
+ if (result.registrantPhone)
165
+ lines.push(`Registrant Phone: ${result.registrantPhone}`);
166
+ if (result.adminName)
167
+ lines.push(`Admin Name: ${result.adminName}`);
168
+ if (result.adminOrg)
169
+ lines.push(`Admin Organization: ${result.adminOrg}`);
170
+ if (result.adminEmail)
171
+ lines.push(`Admin Email: ${result.adminEmail}`);
172
+ for (const nameserver of result.nameservers)
173
+ lines.push(`Name Server: ${nameserver}`);
174
+ return lines.join('\n');
175
+ }
176
+ export async function buildWhoisChainUncached(query, options = {}) {
177
+ const queryWhois = options.whoisQuery || whoisQuery;
178
+ const fetchGtWhois = options.fetchGtRegistryWhois || fetchGtRegistryWhois;
179
+ const now = options.now || Date.now;
180
+ const chainDeadlineMs = options.chainDeadlineMs || WHOIS_CHAIN_DEADLINE_MS;
181
+ const chain = [];
182
+ const visited = new Set();
183
+ let currentServer = IANA_WHOIS;
184
+ const startedAt = now();
185
+ for (let hop = 0; hop < 6; hop += 1) {
186
+ if (visited.has(currentServer.toLowerCase()))
187
+ break;
188
+ visited.add(currentServer.toLowerCase());
189
+ const transport = whoisTransportForHop(query, hop);
190
+ const remainingMs = chainDeadlineMs - (now() - startedAt);
191
+ if (remainingMs <= 0) {
192
+ chain.push({
193
+ server: currentServer,
194
+ queriedAt: new Date().toISOString(),
195
+ queryProfile: transport.queryProfile,
196
+ responseEncoding: transport.responseEncoding,
197
+ error: 'WHOIS referral chain exceeded the total time limit',
198
+ });
199
+ break;
200
+ }
201
+ let responseText;
202
+ let address = null;
203
+ const queriedAt = new Date().toISOString();
204
+ try {
205
+ responseText = await queryWhois(currentServer, transport.query, {
206
+ timeoutMs: Math.min(10_000, remainingMs),
207
+ totalDeadlineMs: Math.min(WHOIS_HOP_DEADLINE_MS, remainingMs),
208
+ onAddressSelected: (selected) => {
209
+ address = selected;
210
+ },
211
+ });
212
+ }
213
+ catch (cause) {
214
+ chain.push({
215
+ server: currentServer,
216
+ queriedAt,
217
+ queryProfile: transport.queryProfile,
218
+ responseEncoding: transport.responseEncoding,
219
+ error: errorMessage(cause, 'WHOIS request failed'),
220
+ });
221
+ break;
222
+ }
223
+ chain.push({
224
+ server: currentServer,
225
+ address,
226
+ queriedAt,
227
+ queryProfile: transport.queryProfile,
228
+ responseEncoding: transport.responseEncoding,
229
+ response: responseText,
230
+ });
231
+ const referral = extractReferral(responseText);
232
+ if (!referral || referral.toLowerCase() === currentServer.toLowerCase())
233
+ break;
234
+ currentServer = referral;
235
+ }
236
+ const firstHop = chain[0];
237
+ if (query.toLowerCase().endsWith('.gt') &&
238
+ chain.length === 1 &&
239
+ firstHop &&
240
+ !('error' in firstHop)) {
241
+ try {
242
+ const result = await fetchGtWhois(query);
243
+ if (result) {
244
+ chain.push({
245
+ server: 'www.gt (registry website - .gt has no WHOIS:43 server)',
246
+ queriedAt: new Date().toISOString(),
247
+ queryProfile: 'gt-registry-web',
248
+ responseEncoding: 'utf-8',
249
+ response: formatGtResultAsText(query, result),
250
+ });
251
+ }
252
+ }
253
+ catch {
254
+ // Best-effort registry fallback. The IANA hop remains explicit on failure.
255
+ }
256
+ }
257
+ return chain;
258
+ }
259
+ export async function buildWhoisChain(query) {
260
+ return cached(`whois:${query.toLowerCase()}`, () => buildWhoisChainUncached(query));
261
+ }
@@ -0,0 +1,163 @@
1
+ // Registry-specific contact block resolvers. These helpers only interpret
2
+ // formats after their exact marker or handle relationship has been proven.
3
+ import { boundedWhoisValue } from "./whois-values.mjs";
4
+ export function resolveFredContact(text, handle) {
5
+ if (!handle)
6
+ return null;
7
+ const escaped = handle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
8
+ const headerMatch = text.match(new RegExp(`^[ \\t]*contact:[ \\t]*${escaped}[ \\t]*$`, 'im'));
9
+ if (!headerMatch)
10
+ return null;
11
+ const rest = text.slice((headerMatch.index ?? 0) + headerMatch[0].length);
12
+ const endMatch = rest.match(/\n[ \t]*\n|^[ \t]*(?:domain|nsset|contact):/im);
13
+ const block = endMatch ? rest.slice(0, endMatch.index) : rest;
14
+ const get = (pattern) => {
15
+ const match = block.match(pattern);
16
+ return match?.[1]?.trim() || null;
17
+ };
18
+ const addresses = [...block.matchAll(/^[ \t]*address:[ \t]*(.+)$/gim)]
19
+ .map((match) => match[1]?.trim())
20
+ .filter((value) => Boolean(value));
21
+ return {
22
+ name: get(/^[ \t]*name:[ \t]*(.+)$/im),
23
+ org: get(/^[ \t]*org:[ \t]*(.+)$/im),
24
+ email: get(/^[ \t]*e-?mail:[ \t]*(.+)$/im),
25
+ phone: get(/^[ \t]*phone:[ \t]*(.+)$/im),
26
+ address: addresses.length ? addresses.join(', ') : null,
27
+ };
28
+ }
29
+ export function resolveIsnicRole(text, handle) {
30
+ if (!handle)
31
+ return null;
32
+ const escaped = handle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
33
+ const headerMatch = text.match(new RegExp(`^[ \\t]*role:[ \\t]*(.+)\\r?\\n[ \\t]*nic-hdl:[ \\t]*${escaped}[ \\t]*$`, 'im'));
34
+ if (!headerMatch)
35
+ return null;
36
+ const lines = text
37
+ .slice((headerMatch.index ?? 0) + headerMatch[0].length)
38
+ .split('\n', 22);
39
+ const block = [];
40
+ for (const line of lines) {
41
+ if (!line.trim()) {
42
+ if (block.length)
43
+ break;
44
+ continue;
45
+ }
46
+ if (/^[ \t]*(?:role|person|domain):/i.test(line))
47
+ break;
48
+ if (block.length >= 20)
49
+ break;
50
+ block.push(line);
51
+ }
52
+ const blockText = block.join('\n');
53
+ const get = (pattern, maxLength) => {
54
+ const match = blockText.match(pattern);
55
+ return match
56
+ ? boundedWhoisValue(match[1], maxLength)
57
+ : { value: null, truncated: false };
58
+ };
59
+ const rawAddresses = [
60
+ ...blockText.matchAll(/^[ \t]*address:[ \t]*(.+)$/gim),
61
+ ];
62
+ const addresses = rawAddresses
63
+ .slice(0, 4)
64
+ .map((match) => boundedWhoisValue(match[1], 300))
65
+ .filter((entry) => entry.value);
66
+ const address = boundedWhoisValue(addresses.map((entry) => entry.value).join(', '), 1000);
67
+ return {
68
+ org: boundedWhoisValue(headerMatch[1], 300),
69
+ email: get(/^[ \t]*e-?mail:[ \t]*(.+)$/im, 320),
70
+ phone: get(/^[ \t]*phone:[ \t]*(.+)$/im, 100),
71
+ address,
72
+ truncated: block.length >= 20
73
+ || rawAddresses.length > addresses.length
74
+ || addresses.some((entry) => entry.truncated),
75
+ };
76
+ }
77
+ export function resolveIrnicContact(text, handle) {
78
+ if (!handle)
79
+ return null;
80
+ const escaped = handle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
81
+ const headerMatch = text.match(new RegExp(`^[ \\t]*nic-hdl:[ \\t]*${escaped}[ \\t]*$`, 'im'));
82
+ if (!headerMatch)
83
+ return null;
84
+ const lines = text
85
+ .slice((headerMatch.index ?? 0) + headerMatch[0].length)
86
+ .split('\n', 22);
87
+ const block = [];
88
+ for (const line of lines) {
89
+ if (!line.trim()) {
90
+ if (block.length)
91
+ break;
92
+ continue;
93
+ }
94
+ if (/^[ \t]*(?:domain|nic-hdl):/i.test(line))
95
+ break;
96
+ if (block.length >= 20)
97
+ break;
98
+ block.push(line);
99
+ }
100
+ const blockText = block.join('\n');
101
+ if (!/^[ \t]*source[ \t]*:[ \t]*IRNIC(?:\s|$)/im.test(blockText)) {
102
+ return null;
103
+ }
104
+ const get = (pattern, maxLength) => {
105
+ const match = blockText.match(pattern);
106
+ return match
107
+ ? boundedWhoisValue(match[1], maxLength)
108
+ : { value: null, truncated: false };
109
+ };
110
+ const rawAddresses = [
111
+ ...blockText.matchAll(/^[ \t]*address[ \t]*:[ \t]*(.+)$/gim),
112
+ ];
113
+ const addresses = rawAddresses
114
+ .slice(0, 4)
115
+ .map((match) => boundedWhoisValue(match[1], 300))
116
+ .filter((entry) => entry.value);
117
+ const address = boundedWhoisValue(addresses.map((entry) => entry.value).join(', '), 1000);
118
+ return {
119
+ name: get(/^[ \t]*person[ \t]*:[ \t]*(.+)$/im, 300),
120
+ org: get(/^[ \t]*org[ \t]*:[ \t]*(.+)$/im, 300),
121
+ email: get(/^[ \t]*e-?mail[ \t]*:[ \t]*(.+)$/im, 320),
122
+ phone: get(/^[ \t]*phone[ \t]*:[ \t]*(.+)$/im, 100),
123
+ address,
124
+ truncated: block.length >= 20
125
+ || rawAddresses.length > addresses.length
126
+ || addresses.some((entry) => entry.truncated),
127
+ };
128
+ }
129
+ export function parseIndentedContactBlock(text, headerRe) {
130
+ const headerMatch = text.match(headerRe);
131
+ if (!headerMatch)
132
+ return null;
133
+ const rest = text.slice((headerMatch.index ?? 0) + headerMatch[0].length);
134
+ const blankLineMatch = rest.match(/\n[ \t]*\n/);
135
+ const blockText = blankLineMatch
136
+ ? rest.slice(0, blankLineMatch.index)
137
+ : rest;
138
+ const allLines = blockText
139
+ .split('\n')
140
+ .map((line) => line.trim())
141
+ .filter(Boolean);
142
+ const lines = allLines.slice(0, 20);
143
+ if (lines.length === 0)
144
+ return null;
145
+ const remaining = [...lines];
146
+ const emailIndex = remaining.findIndex((line) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(line));
147
+ const email = emailIndex !== -1
148
+ ? remaining.splice(emailIndex, 1)[0]
149
+ : null;
150
+ const phoneIndex = remaining.findIndex((line) => /^[+\d][\d.\-() ]{6,}$/.test(line));
151
+ const phone = phoneIndex !== -1
152
+ ? remaining.splice(phoneIndex, 1)[0]
153
+ : null;
154
+ const name = remaining.shift() || null;
155
+ const address = remaining.length ? remaining.join(', ') : null;
156
+ return {
157
+ name,
158
+ address,
159
+ phone,
160
+ email,
161
+ truncated: allLines.length > lines.length,
162
+ };
163
+ }
@@ -0,0 +1,3 @@
1
+ // Stable normalized WHOIS contracts. Parsing and transport remain separate so
2
+ // consumers can depend on the record shape without importing network code.
3
+ export {};
@@ -0,0 +1,27 @@
1
+ // Bounded normalization for imported and collected WHOIS referral chains.
2
+ // Keeping this separate lets authority analysis and field parsing share the
3
+ // exact same defensive input boundary.
4
+ import { MAX_WHOIS_BYTES } from "./whois-transport.mjs";
5
+ const MAX_WHOIS_HOPS = 6;
6
+ export function normalizeWhoisChain(value) {
7
+ if (!Array.isArray(value))
8
+ return [];
9
+ const normalized = [];
10
+ for (const hopValue of value.slice(0, MAX_WHOIS_HOPS)) {
11
+ if (!hopValue || typeof hopValue !== 'object' || Array.isArray(hopValue))
12
+ continue;
13
+ const hop = hopValue;
14
+ if (typeof hop.server !== 'string' || !hop.server.trim())
15
+ continue;
16
+ normalized.push({
17
+ server: hop.server.slice(0, 300),
18
+ ...(typeof hop.address === 'string' || hop.address === null ? { address: hop.address } : {}),
19
+ ...(typeof hop.queriedAt === 'string' ? { queriedAt: hop.queriedAt.slice(0, 64) } : {}),
20
+ ...(typeof hop.queryProfile === 'string' ? { queryProfile: hop.queryProfile.slice(0, 80) } : {}),
21
+ ...(typeof hop.responseEncoding === 'string' ? { responseEncoding: hop.responseEncoding.slice(0, 40) } : {}),
22
+ ...(typeof hop.response === 'string' ? { response: hop.response.slice(0, MAX_WHOIS_BYTES) } : {}),
23
+ ...(typeof hop.error === 'string' ? { error: hop.error.slice(0, 1000) } : {}),
24
+ });
25
+ }
26
+ return normalized;
27
+ }
@@ -0,0 +1,100 @@
1
+ // Bounded WHOIS port-43 transport with public-address validation and
2
+ // connection pinning. Referral orchestration and response parsing remain in
3
+ // lib/whois.mts so this module has one responsibility: safely query one
4
+ // validated WHOIS server.
5
+ import net from 'node:net';
6
+ import { resolvePublicAddresses } from "./safe-fetch.mjs";
7
+ const MAX_WHOIS_BYTES = 200000;
8
+ const WHOIS_HOP_DEADLINE_MS = 12000;
9
+ const MAX_WHOIS_ADDRESSES = 3;
10
+ function errorMessage(value, fallback) {
11
+ if (!value || typeof value !== 'object' || Array.isArray(value))
12
+ return fallback;
13
+ const message = value.message;
14
+ return message ? String(message) : fallback;
15
+ }
16
+ // A referral hostname is upstream-controlled, so resolve and validate its
17
+ // addresses before connecting. Connect to the selected address directly to
18
+ // prevent a second DNS lookup from bypassing the public-address decision.
19
+ function queryWhoisAddress(address, server, query, { port = 43, timeoutMs = 10000, totalDeadlineMs = WHOIS_HOP_DEADLINE_MS, createConnection = net.createConnection, } = {}) {
20
+ return new Promise((resolve, reject) => {
21
+ const socket = createConnection({ host: address, port }, () => {
22
+ socket.write(query + '\r\n');
23
+ });
24
+ const chunks = [];
25
+ let totalBytes = 0;
26
+ let settled = false;
27
+ const deadline = setTimeout(() => {
28
+ settled = true;
29
+ socket.destroy();
30
+ reject(new Error(`WHOIS request to ${server} exceeded the total time limit`));
31
+ }, totalDeadlineMs);
32
+ function settle(fn, value) {
33
+ if (settled)
34
+ return;
35
+ settled = true;
36
+ clearTimeout(deadline);
37
+ fn(value);
38
+ }
39
+ socket.setTimeout(Math.min(timeoutMs, totalDeadlineMs));
40
+ socket.on('data', (chunk) => {
41
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
42
+ totalBytes += buffer.length;
43
+ if (totalBytes > MAX_WHOIS_BYTES) {
44
+ socket.destroy();
45
+ settle(reject, new Error(`WHOIS response from ${server} exceeded ${MAX_WHOIS_BYTES} bytes`));
46
+ return;
47
+ }
48
+ chunks.push(buffer);
49
+ });
50
+ const responseText = () => Buffer.concat(chunks, totalBytes).toString('utf8');
51
+ socket.on('end', () => settle(resolve, responseText()));
52
+ socket.on('close', () => settle(resolve, responseText()));
53
+ socket.on('timeout', () => {
54
+ socket.destroy();
55
+ settle(reject, new Error(`WHOIS request to ${server} timed out`));
56
+ });
57
+ socket.on('error', (error) => settle(reject, error));
58
+ });
59
+ }
60
+ async function whoisQuery(server, query, { port = 43, timeoutMs = 10000, totalDeadlineMs = WHOIS_HOP_DEADLINE_MS, resolveAddresses = resolvePublicAddresses, queryAddress = queryWhoisAddress, now = Date.now, onAddressSelected = (_address) => { }, } = {}) {
61
+ const startedAt = now();
62
+ let resolutionTimer;
63
+ const records = await Promise.race([
64
+ resolveAddresses(server),
65
+ new Promise((_, reject) => {
66
+ resolutionTimer = setTimeout(() => reject(new Error(`WHOIS request to ${server} timed out during DNS resolution`)), totalDeadlineMs);
67
+ }),
68
+ ]).finally(() => clearTimeout(resolutionTimer));
69
+ const candidates = records.slice(0, MAX_WHOIS_ADDRESSES);
70
+ const failures = [];
71
+ let attempts = 0;
72
+ for (const { address } of candidates) {
73
+ const remainingMs = totalDeadlineMs - (now() - startedAt);
74
+ if (remainingMs <= 0)
75
+ break;
76
+ attempts += 1;
77
+ try {
78
+ const response = await queryAddress(address, server, query, {
79
+ port,
80
+ timeoutMs: Math.min(timeoutMs, remainingMs),
81
+ totalDeadlineMs: remainingMs,
82
+ });
83
+ try {
84
+ onAddressSelected(address);
85
+ }
86
+ catch {
87
+ // Diagnostics must not break a successful lookup.
88
+ }
89
+ return response;
90
+ }
91
+ catch (error) {
92
+ failures.push(errorMessage(error, 'connection failed').slice(0, 200));
93
+ }
94
+ }
95
+ const detail = failures.length
96
+ ? `: ${failures.join('; ')}`
97
+ : ' because the total time limit expired';
98
+ throw new Error(`WHOIS request to ${server} failed after ${attempts} of ${candidates.length} validated address(es)${detail}`);
99
+ }
100
+ export { MAX_WHOIS_BYTES, queryWhoisAddress, whoisQuery, };
@@ -0,0 +1,92 @@
1
+ // Shared bounded scalar and section helpers for WHOIS parsers. Registry
2
+ // branches may interpret different labels, but they all use the same control
3
+ // character, size, indentation, and section traversal rules.
4
+ export const MAX_WHOIS_FIELD_LENGTH = 1000;
5
+ export function boundedWhoisValue(value, maxLength = MAX_WHOIS_FIELD_LENGTH) {
6
+ if (typeof value !== 'string'
7
+ || /[\u0000-\u0008\u000a-\u001f\u007f]/.test(value)) {
8
+ return { value: null, truncated: false };
9
+ }
10
+ const normalized = value.replace(/\s+/g, ' ').trim();
11
+ if (!normalized)
12
+ return { value: null, truncated: false };
13
+ return {
14
+ value: normalized.slice(0, maxLength).trim() || null,
15
+ truncated: normalized.length > maxLength,
16
+ };
17
+ }
18
+ export function parseIndentedWhoisValue(text, headerRe, maxLength) {
19
+ const headerMatch = text.match(headerRe);
20
+ if (!headerMatch)
21
+ return null;
22
+ const lines = text
23
+ .slice((headerMatch.index ?? 0) + headerMatch[0].length)
24
+ .split('\n')
25
+ .slice(0, 8);
26
+ for (const line of lines) {
27
+ if (!line.trim())
28
+ continue;
29
+ if (!/^[ \t]/.test(line))
30
+ return null;
31
+ return boundedWhoisValue(line, maxLength);
32
+ }
33
+ return null;
34
+ }
35
+ export function parseIndentedWhoisSubfield(text, headerRe, subfieldRe, maxLength) {
36
+ const headerMatch = text.match(headerRe);
37
+ if (!headerMatch)
38
+ return null;
39
+ const lines = text
40
+ .slice((headerMatch.index ?? 0) + headerMatch[0].length)
41
+ .split('\n')
42
+ .slice(0, 8);
43
+ for (const line of lines) {
44
+ if (!line.trim())
45
+ continue;
46
+ if (!/^[ \t]/.test(line))
47
+ return null;
48
+ const match = line.match(subfieldRe);
49
+ if (match)
50
+ return boundedWhoisValue(match[1], maxLength);
51
+ }
52
+ return null;
53
+ }
54
+ export function parseBoundedWhoisSection(text, headerRe, maxLines = 20) {
55
+ const headerMatch = text.match(headerRe);
56
+ if (!headerMatch)
57
+ return '';
58
+ const lines = text
59
+ .slice((headerMatch.index ?? 0) + headerMatch[0].length)
60
+ .split('\n', maxLines + 2);
61
+ const section = [];
62
+ for (const line of lines) {
63
+ if (!line.trim()) {
64
+ if (section.length)
65
+ break;
66
+ continue;
67
+ }
68
+ if (/^[ \t]*\[[^\]]+\][ \t]*$/.test(line))
69
+ break;
70
+ if (section.length >= maxLines)
71
+ break;
72
+ section.push(line);
73
+ }
74
+ return section.join('\n');
75
+ }
76
+ export function whoisFieldLimit(key) {
77
+ if (/Email$/i.test(key))
78
+ return 320;
79
+ if (/Phone$/i.test(key))
80
+ return 100;
81
+ if (/Date$/i.test(key))
82
+ return 100;
83
+ if (/Url$/i.test(key))
84
+ return 2048;
85
+ if (/domainName/i.test(key))
86
+ return 253;
87
+ if (/Address/i.test(key))
88
+ return 1000;
89
+ if (/Street/i.test(key))
90
+ return 1000;
91
+ return 300;
92
+ }