@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
package/lib/whois.mjs ADDED
@@ -0,0 +1,1187 @@
1
+ // WHOIS: raw TCP port-43 lookups, following registry referrals starting from
2
+ // the IANA root WHOIS server, plus response parsing. Shared by the Express
3
+ // server and the Netlify Functions.
4
+ import { domainToASCII } from 'node:url';
5
+ import { registryDateIso } from "./registry-dates.mjs";
6
+ import { queryWhoisAddress, whoisQuery, } from "./whois-transport.mjs";
7
+ import { buildWhoisChain, buildWhoisChainUncached, fetchGtRegistryWhois, } from "./whois-chain.mjs";
8
+ import { analyzeWhoisChainAuthority, hasNicKgRegistrationEvidence, } from "./whois-authority.mjs";
9
+ import { parseIndentedContactBlock, resolveFredContact, resolveIrnicContact, resolveIsnicRole, } from "./whois-contacts.mjs";
10
+ import { normalizeWhoisChain } from "./whois-normalization.mjs";
11
+ import { MAX_WHOIS_FIELD_LENGTH, boundedWhoisValue, parseBoundedWhoisSection, parseIndentedWhoisSubfield, parseIndentedWhoisValue, whoisFieldLimit, } from "./whois-values.mjs";
12
+ const MAX_WHOIS_NAMESERVERS = 200;
13
+ const MAX_WHOIS_STATUSES = 100;
14
+ const WHOIS_DOMAIN_RE = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
15
+ // ---------------------------------------------------------------------------
16
+ // WHOIS response parsing (merges the referral chain into readable fields)
17
+ // ---------------------------------------------------------------------------
18
+ function assignBoundedWhoisMatch(text, fields, key, pattern, truncatedFields) {
19
+ if (fields[key])
20
+ return;
21
+ const match = text.match(pattern);
22
+ if (!match)
23
+ return;
24
+ const bounded = boundedWhoisValue(match[1], whoisFieldLimit(key));
25
+ if (!bounded.value)
26
+ return;
27
+ fields[key] = bounded.value;
28
+ if (bounded.truncated)
29
+ truncatedFields.add(key);
30
+ }
31
+ function addBoundedWhoisSetValue(set, rawValue, { maxEntries, maxLength, field, truncatedFields, }) {
32
+ const bounded = boundedWhoisValue(rawValue, maxLength);
33
+ if (!bounded.value || set.has(bounded.value))
34
+ return 'ignored';
35
+ if (set.size >= maxEntries) {
36
+ truncatedFields.add(field);
37
+ return 'capped';
38
+ }
39
+ set.add(bounded.value);
40
+ return 'added';
41
+ }
42
+ function collectBareWhoisNameservers(text, headerRe, nameservers, truncatedFields) {
43
+ const headerMatch = text.match(headerRe);
44
+ if (!headerMatch)
45
+ return;
46
+ const lines = text.slice((headerMatch.index ?? 0) + headerMatch[0].length)
47
+ .split('\n', MAX_WHOIS_NAMESERVERS + 3);
48
+ let found = 0;
49
+ for (const line of lines) {
50
+ const trimmed = line.trim();
51
+ if (!trimmed) {
52
+ if (found)
53
+ break;
54
+ continue;
55
+ }
56
+ const hostMatch = trimmed.match(/^([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})\.?(?:\s|$)/);
57
+ if (!hostMatch)
58
+ break;
59
+ const hostname = hostMatch[1];
60
+ if (!hostname)
61
+ break;
62
+ const result = addBoundedWhoisSetValue(nameservers, hostname.replace(/\.$/, ''), {
63
+ maxEntries: MAX_WHOIS_NAMESERVERS,
64
+ maxLength: 253,
65
+ field: 'nameservers',
66
+ truncatedFields,
67
+ });
68
+ if (result === 'capped')
69
+ break;
70
+ if (result === 'added')
71
+ found += 1;
72
+ }
73
+ }
74
+ function normalizedWhoisContact(fields, prefix, role, truncatedFields) {
75
+ let address = fields[`${prefix}Address`] || [
76
+ fields[`${prefix}Street`], fields[`${prefix}City`], fields[`${prefix}State`],
77
+ fields[`${prefix}PostalCode`], fields[`${prefix}Country`],
78
+ ].filter(Boolean).join(', ') || null;
79
+ if (address && !fields[`${prefix}Address`]) {
80
+ const bounded = boundedWhoisValue(address, 1000);
81
+ address = bounded.value;
82
+ if (address)
83
+ fields[`${prefix}Address`] = address;
84
+ if (bounded.truncated)
85
+ truncatedFields.add(`${prefix}Address`);
86
+ }
87
+ const handle = fields[`${prefix}Id`] || null;
88
+ const name = fields[`${prefix}Name`] || null;
89
+ const org = fields[`${prefix}Org`] || null;
90
+ const email = fields[`${prefix}Email`] || null;
91
+ const phone = fields[`${prefix}Phone`] || null;
92
+ if (![handle, name, org, email, phone, address].some(Boolean))
93
+ return null;
94
+ return {
95
+ handle,
96
+ roles: [role],
97
+ name,
98
+ names: name ? [name] : [],
99
+ org,
100
+ organizations: org ? [org] : [],
101
+ email,
102
+ emails: email ? [email] : [],
103
+ phone,
104
+ phones: phone ? [phone] : [],
105
+ address,
106
+ addresses: address ? [address] : [],
107
+ publicIds: handle ? [{ type: 'Registry contact ID', identifier: handle }] : [],
108
+ links: [],
109
+ };
110
+ }
111
+ function parseWhoisChain(chain) {
112
+ const source = normalizeWhoisChain(chain);
113
+ const fields = {};
114
+ const truncatedFields = new Set();
115
+ const expandedStreetFields = new Set();
116
+ // [ \t]* (not \s*) after each colon - same reasoning as extractReferral:
117
+ // several registries list a field with no value (e.g. "Registrant
118
+ // Organization: " followed directly by "Registrant Street: REDACTED"),
119
+ // and \s* would cross that blank line and capture the next field's own
120
+ // label as the value.
121
+ //
122
+ // Each field lists the standard ICANN thick-WHOIS label first, then
123
+ // common alternates seen on registries that predate/ignore that format
124
+ // (e.g. .it uses "Domain:"/"Created:"/"Expire Date:"). First match wins.
125
+ // This is a broad-coverage net, not a claim of full per-registry support -
126
+ // registries with entirely different conventions (e.g. .jp's bracketed
127
+ // dual-language format) still need their own dedicated handling.
128
+ // ^[ \t*]* (not ^\s*) - some registries prefix lines with "**" (e.g. .tr's
129
+ // "** Domain Name:"). [ \t.]* before the colon - some use dot-leaders
130
+ // (.tr's "Created on..........:") or extra spaces before the colon
131
+ // (.kr's "Domain Name :") instead of a colon right after
132
+ // the label.
133
+ const patterns = {
134
+ domainName: [
135
+ /^[ \t*]*Domain Name[ \t.]*:[ \t]*(.+)$/im,
136
+ /^[ \t*]*Domain[ \t.]*:[ \t]*(.+)$/im,
137
+ /^[ \t]*domain_name[ \t]*:[ \t]*(.+)$/im,
138
+ ],
139
+ registryDomainId: [/^[ \t*]*Registry Domain ID[ \t.]*:[ \t]*(.+)$/im],
140
+ registrar: [
141
+ /^[ \t*]*Registrar[ \t.]*:[ \t]*(.+)$/im,
142
+ /^[ \t*]*Sponsoring Registrar[ \t.]*:[ \t]*(.+)$/im,
143
+ /^[ \t*]*Registrar Name[ \t.]*:[ \t]*(.+)$/im,
144
+ /^[ \t]*registrar_name[ \t]*:[ \t]*(.+)$/im,
145
+ ],
146
+ registrarUrl: [/^[ \t*]*Registrar URL[ \t.]*:[ \t]*(.+)$/im],
147
+ registrarWhoisServer: [/^[ \t*]*Registrar WHOIS Server[ \t.]*:[ \t]*(.+)$/im],
148
+ registrarIanaId: [/^[ \t*]*Registrar IANA ID[ \t.]*:[ \t]*(.+)$/im],
149
+ reseller: [/^[ \t*]*Reseller[ \t.]*:[ \t]*(.+)$/im],
150
+ createdDate: [
151
+ /^[ \t*]*Creation Date[ \t.]*:[ \t]*(.+)$/im,
152
+ /^[ \t*]*Created(?: On)?[ \t.]*:[ \t]*(.+)$/im,
153
+ /^[ \t*]*Regist(?:ration|ered)(?: Time| Date| On)?[ \t.]*:[ \t]*(.+)$/im,
154
+ /^[ \t*]*Domain record activated[ \t.]*:[ \t]*(.+)$/im,
155
+ /^[ \t]*domain_dateregistered[ \t]*:[ \t]*(.+)$/im,
156
+ /^[ \t]*domain_datecreated[ \t]*:[ \t]*(.+)$/im,
157
+ ],
158
+ expiryDate: [
159
+ /^[ \t*]*Registr(?:y|ar) Expiry Date[ \t.]*:[ \t]*(.+)$/im,
160
+ /^[ \t*]*Registrar Registration Expiration Date[ \t.]*:[ \t]*(.+)$/im,
161
+ /^[ \t*]*Expir(?:y|ation|e)s?(?: Date| On)?[ \t.]*:[ \t]*(.+)$/im,
162
+ /^[ \t*]*Valid Until[ \t.]*:[ \t]*(.+)$/im,
163
+ /^[ \t*]*Domain expires[ \t.]*:[ \t]*(.+)$/im,
164
+ /^[ \t*]*Renewal Date[ \t.]*:[ \t]*(.+)$/im,
165
+ /^[ \t*]*paid-till[ \t.]*:[ \t]*(.+)$/im,
166
+ /^[ \t]*domain_datebilleduntil[ \t]*:[ \t]*(.+)$/im,
167
+ ],
168
+ updatedDate: [
169
+ /^[ \t*]*Updated Date[ \t.]*:[ \t]*(.+)$/im,
170
+ /^[ \t*]*Update Date[ \t.]*:[ \t]*(.+)$/im,
171
+ /^[ \t*]*Last Update(?:d)?(?: Date| On)?[ \t.]*:[ \t]*(.+)$/im,
172
+ /^[ \t*]*Last Modified[ \t.]*:[ \t]*(.+)$/im,
173
+ /^[ \t*]*Modified(?: Date)?[ \t.]*:[ \t]*(.+)$/im,
174
+ /^[ \t*]*Modification Date[ \t.]*:[ \t]*(.+)$/im,
175
+ /^[ \t*]*last-update[ \t.]*:[ \t]*(.+)$/im,
176
+ /^[ \t*]*Changed[ \t.]*:[ \t]*(.+)$/im,
177
+ /^[ \t*]*Domain record last updated[ \t.]*:[ \t]*(.+)$/im,
178
+ /^[ \t]*domain_datelastmodified[ \t]*:[ \t]*(.+)$/im,
179
+ ],
180
+ abuseEmail: [/^[ \t*]*Registrar Abuse Contact Email[ \t.]*:[ \t]*(.+)$/im],
181
+ abusePhone: [/^[ \t*]*Registrar Abuse Contact Phone[ \t.]*:[ \t]*(.+)$/im],
182
+ dnssec: [
183
+ /^[ \t*]*DNSSEC[ \t.]*:[ \t]*(.+)$/im,
184
+ /^[ \t*]*Signed[ \t.]*:[ \t]*(.+)$/im,
185
+ /^[ \t]*domain_signed[ \t]*:[ \t]*(.+)$/im,
186
+ ],
187
+ // auDA (.au) publishes the registrant's eligibility basis (e.g. an ABN/
188
+ // ACN for a company) alongside - and often instead of - a named contact,
189
+ // since .au domain eligibility is tied to a registrable Australian
190
+ // presence rather than an individual.
191
+ eligibilityType: [/^[ \t*]*Eligibility Type[ \t.]*:[ \t]*(.+)$/im],
192
+ eligibilityId: [/^[ \t*]*Eligibility ID[ \t.]*:[ \t]*(.+)$/im],
193
+ // Standard ICANN thick-WHOIS registrant/admin fields - present verbatim
194
+ // on registries that don't redact contact data, and also what the .gt
195
+ // web-lookup fallback below is formatted to produce. Several registries
196
+ // (e.g. .au via auDA) insert an extra "Contact" word - "Registrant
197
+ // Contact Email:", "Tech Contact Name:" - handled below with an optional
198
+ // "(?:Contact )?" group rather than a whole separate pattern, except for
199
+ // registrantName, where priority matters: on .au, the plain "Registrant:"
200
+ // line carries the actual legal entity ("Example Corporation Pty Ltd") while
201
+ // "Registrant Contact Name:" is often just a generic role ("Domain
202
+ // Administrator") - kept as a lower-priority third alternate so the more
203
+ // useful value wins when both are present.
204
+ registrantName: [
205
+ /^[ \t*]*Registrant Name[ \t.]*:[ \t]*(.+)$/im,
206
+ /^[ \t*]*Registrant[ \t.]*:[ \t]*(.+)$/im,
207
+ /^[ \t*]*Registrant Contact Name[ \t.]*:[ \t]*(.+)$/im,
208
+ /^[ \t*]*Owner Name[ \t.]*:[ \t]*(.+)$/im,
209
+ /^[ \t]*registrant_contact_name[ \t]*:[ \t]*(.+)$/im,
210
+ ],
211
+ registrantId: [/^[ \t*]*(?:Registry )?Registrant ID[ \t.]*:[ \t]*(.+)$/im],
212
+ registrantOrg: [/^[ \t*]*Registrant (?:Contact )?Organi[sz]ation[ \t.]*:[ \t]*(.+)$/im],
213
+ registrantEmail: [
214
+ /^[ \t*]*Registrant (?:Contact )?Email[ \t.]*:[ \t]*(.+)$/im,
215
+ /^[ \t]*registrant_contact_email[ \t]*:[ \t]*(.+)$/im,
216
+ ],
217
+ registrantPhone: [
218
+ /^[ \t*]*Registrant (?:Contact )?Phone[ \t.]*:[ \t]*(.+)$/im,
219
+ /^[ \t]*registrant_contact_phone[ \t]*:[ \t]*(.+)$/im,
220
+ ],
221
+ registrantAddress: [
222
+ /^[ \t*]*Registrant (?:Contact )?Address[ \t.]*:[ \t]*(.+)$/im,
223
+ /^[ \t*]*Owner Address[ \t.]*:[ \t]*(.+)$/im,
224
+ ],
225
+ registrantStreet: [/^[ \t*]*Registrant (?:Contact )?Street[ \t.]*:[ \t]*(.+)$/im],
226
+ registrantCity: [
227
+ /^[ \t*]*Registrant (?:Contact )?City[ \t.]*:[ \t]*(.+)$/im,
228
+ /^[ \t]*registrant_contact_city[ \t]*:[ \t]*(.+)$/im,
229
+ ],
230
+ registrantState: [
231
+ /^[ \t*]*Registrant (?:Contact )?State(?:\/Province)?[ \t.]*:[ \t]*(.+)$/im,
232
+ /^[ \t]*registrant_contact_province[ \t]*:[ \t]*(.+)$/im,
233
+ ],
234
+ registrantPostalCode: [
235
+ /^[ \t*]*Registrant (?:Contact )?Postal Code[ \t.]*:[ \t]*(.+)$/im,
236
+ /^[ \t]*registrant_contact_postalcode[ \t]*:[ \t]*(.+)$/im,
237
+ ],
238
+ registrantCountry: [
239
+ /^[ \t*]*Registrant (?:Contact )?Country[ \t.]*:[ \t]*(.+)$/im,
240
+ /^[ \t*]*Owner Country[ \t.]*:[ \t]*(.+)$/im,
241
+ /^[ \t]*registrant_contact_country[ \t]*:[ \t]*(.+)$/im,
242
+ ],
243
+ adminId: [/^[ \t*]*(?:Registry )?Admin(?:istrative)? (?:Contact )?ID[ \t.]*:[ \t]*(.+)$/im],
244
+ adminName: [
245
+ /^[ \t*]*Admin(?:istrative)? (?:Contact )?Name[ \t.]*:[ \t]*(.+)$/im,
246
+ /^[ \t]*admin_contact_name[ \t]*:[ \t]*(.+)$/im,
247
+ ],
248
+ adminOrg: [/^[ \t*]*Admin(?:istrative)? (?:Contact )?Organi[sz]ation[ \t.]*:[ \t]*(.+)$/im],
249
+ adminEmail: [
250
+ /^[ \t*]*Admin(?:istrative)? (?:Contact )?Email[ \t.]*:[ \t]*(.+)$/im,
251
+ /^[ \t]*admin_contact_email[ \t]*:[ \t]*(.+)$/im,
252
+ ],
253
+ adminPhone: [
254
+ /^[ \t*]*Admin(?:istrative)? (?:Contact )?Phone[ \t.]*:[ \t]*(.+)$/im,
255
+ /^[ \t]*admin_contact_phone[ \t]*:[ \t]*(.+)$/im,
256
+ ],
257
+ adminAddress: [/^[ \t*]*Admin(?:istrative)? (?:Contact )?Address[ \t.]*:[ \t]*(.+)$/im],
258
+ adminStreet: [/^[ \t*]*Admin(?:istrative)? (?:Contact )?Street[ \t.]*:[ \t]*(.+)$/im],
259
+ adminCity: [
260
+ /^[ \t*]*Admin(?:istrative)? (?:Contact )?City[ \t.]*:[ \t]*(.+)$/im,
261
+ /^[ \t]*admin_contact_city[ \t]*:[ \t]*(.+)$/im,
262
+ ],
263
+ adminState: [
264
+ /^[ \t*]*Admin(?:istrative)? (?:Contact )?State(?:\/Province)?[ \t.]*:[ \t]*(.+)$/im,
265
+ /^[ \t]*admin_contact_province[ \t]*:[ \t]*(.+)$/im,
266
+ ],
267
+ adminPostalCode: [
268
+ /^[ \t*]*Admin(?:istrative)? (?:Contact )?Postal Code[ \t.]*:[ \t]*(.+)$/im,
269
+ /^[ \t]*admin_contact_postalcode[ \t]*:[ \t]*(.+)$/im,
270
+ ],
271
+ adminCountry: [
272
+ /^[ \t*]*Admin(?:istrative)? (?:Contact )?Country[ \t.]*:[ \t]*(.+)$/im,
273
+ /^[ \t]*admin_contact_country[ \t]*:[ \t]*(.+)$/im,
274
+ ],
275
+ techId: [/^[ \t*]*(?:Registry )?Tech(?:nical)? (?:Contact )?ID[ \t.]*:[ \t]*(.+)$/im],
276
+ techName: [
277
+ /^[ \t*]*Tech(?:nical)? (?:Contact )?Name[ \t.]*:[ \t]*(.+)$/im,
278
+ /^[ \t]*technical_contact_name[ \t]*:[ \t]*(.+)$/im,
279
+ ],
280
+ techOrg: [/^[ \t*]*Tech(?:nical)? (?:Contact )?Organi[sz]ation[ \t.]*:[ \t]*(.+)$/im],
281
+ techEmail: [
282
+ /^[ \t*]*Tech(?:nical)? (?:Contact )?Email[ \t.]*:[ \t]*(.+)$/im,
283
+ /^[ \t]*technical_contact_email[ \t]*:[ \t]*(.+)$/im,
284
+ ],
285
+ techPhone: [
286
+ /^[ \t*]*Tech(?:nical)? (?:Contact )?Phone[ \t.]*:[ \t]*(.+)$/im,
287
+ /^[ \t]*technical_contact_phone[ \t]*:[ \t]*(.+)$/im,
288
+ ],
289
+ techAddress: [/^[ \t*]*Tech(?:nical)? (?:Contact )?Address[ \t.]*:[ \t]*(.+)$/im],
290
+ techStreet: [/^[ \t*]*Tech(?:nical)? (?:Contact )?Street[ \t.]*:[ \t]*(.+)$/im],
291
+ techCity: [
292
+ /^[ \t*]*Tech(?:nical)? (?:Contact )?City[ \t.]*:[ \t]*(.+)$/im,
293
+ /^[ \t]*technical_contact_city[ \t]*:[ \t]*(.+)$/im,
294
+ ],
295
+ techState: [
296
+ /^[ \t*]*Tech(?:nical)? (?:Contact )?State(?:\/Province)?[ \t.]*:[ \t]*(.+)$/im,
297
+ /^[ \t]*technical_contact_province[ \t]*:[ \t]*(.+)$/im,
298
+ ],
299
+ techPostalCode: [
300
+ /^[ \t*]*Tech(?:nical)? (?:Contact )?Postal Code[ \t.]*:[ \t]*(.+)$/im,
301
+ /^[ \t]*technical_contact_postalcode[ \t]*:[ \t]*(.+)$/im,
302
+ ],
303
+ techCountry: [
304
+ /^[ \t*]*Tech(?:nical)? (?:Contact )?Country[ \t.]*:[ \t]*(.+)$/im,
305
+ /^[ \t]*technical_contact_country[ \t]*:[ \t]*(.+)$/im,
306
+ ],
307
+ billingId: [/^[ \t*]*(?:Registry )?Billing (?:Contact )?ID[ \t.]*:[ \t]*(.+)$/im],
308
+ billingName: [/^[ \t*]*Billing (?:Contact )?Name[ \t.]*:[ \t]*(.+)$/im],
309
+ billingOrg: [/^[ \t*]*Billing (?:Contact )?Organi[sz]ation[ \t.]*:[ \t]*(.+)$/im],
310
+ billingEmail: [/^[ \t*]*Billing (?:Contact )?Email[ \t.]*:[ \t]*(.+)$/im],
311
+ billingPhone: [/^[ \t*]*Billing (?:Contact )?Phone[ \t.]*:[ \t]*(.+)$/im],
312
+ billingAddress: [/^[ \t*]*Billing (?:Contact )?Address[ \t.]*:[ \t]*(.+)$/im],
313
+ billingStreet: [/^[ \t*]*Billing (?:Contact )?Street[ \t.]*:[ \t]*(.+)$/im],
314
+ billingCity: [/^[ \t*]*Billing (?:Contact )?City[ \t.]*:[ \t]*(.+)$/im],
315
+ billingState: [/^[ \t*]*Billing (?:Contact )?State(?:\/Province)?[ \t.]*:[ \t]*(.+)$/im],
316
+ billingPostalCode: [/^[ \t*]*Billing (?:Contact )?Postal Code[ \t.]*:[ \t]*(.+)$/im],
317
+ billingCountry: [/^[ \t*]*Billing (?:Contact )?Country[ \t.]*:[ \t]*(.+)$/im],
318
+ };
319
+ const nameservers = new Set();
320
+ const statuses = new Set();
321
+ source.forEach((hop, hopIndex) => {
322
+ const text = hop.response;
323
+ if (!text)
324
+ return;
325
+ // hopIndex 0 is always whois.iana.org, whose "domain:"/"created:"/
326
+ // "changed:" fields describe the TLD's own root delegation record, not
327
+ // the queried domain - e.g. "created: 1992-08-14" for .gt is Guatemala's
328
+ // delegation date, not any individual .gt domain's registration date.
329
+ // The broadened alternate labels (everything past index 0 in each
330
+ // pattern array) are common enough on real registries' per-domain
331
+ // responses that they'd false-match that IANA hop too, so they're only
332
+ // tried against later, registry-level hops.
333
+ const isRootHop = hopIndex === 0;
334
+ const isDnsBelgium = !isRootHop
335
+ && /^%[^\r\n]*\.be Whois Server/im.test(text)
336
+ && /^[ \t]*Registered[ \t]*:/im.test(text);
337
+ const isEurid = !isRootHop
338
+ && /^[ \t]*Script[ \t]*:/im.test(text)
339
+ && /(?:^|\s)(?:www\.)?eurid\.eu(?:\s|\/|$)/im.test(text);
340
+ const isNorid = !isRootHop
341
+ && /^[ \t]*Domain Information[ \t]*$/im.test(text)
342
+ && /^[ \t]*NORID Handle[ \t.]*:/im.test(text)
343
+ && /^[ \t]*Registrar Handle[ \t.]*:/im.test(text)
344
+ && /^[ \t]*Additional information[ \t]*:/im.test(text);
345
+ const isCnnic = !isRootHop
346
+ && /^[ \t]*ROID[ \t]*:/im.test(text)
347
+ && /^[ \t]*Sponsoring Registrar[ \t]*:/im.test(text)
348
+ && /^[ \t]*Registration Time[ \t]*:/im.test(text)
349
+ && /^[ \t]*Expiration Time[ \t]*:/im.test(text);
350
+ const isPunktum = !isRootHop
351
+ && /^[ \t]*Registration period[ \t]*:/im.test(text)
352
+ && /^[ \t]*VID[ \t]*:/im.test(text)
353
+ && /^[ \t]*Nameservers[ \t]*$/im.test(text);
354
+ const isPandi = !isRootHop
355
+ && /^[ \t]*Domain ID[ \t]*:/im.test(text)
356
+ && /^[ \t]*Sponsoring Registrar Organization[ \t]*:/im.test(text);
357
+ const isIsocIl = !isRootHop
358
+ && /^[ \t]*query[ \t]*:/im.test(text)
359
+ && /^[ \t]*reg-name[ \t]*:/im.test(text)
360
+ && /^[ \t]*validity[ \t]*:/im.test(text);
361
+ const isTwnic = !isRootHop
362
+ && /^[ \t]*Record created on[ \t]*:/im.test(text)
363
+ && /^[ \t]*Record expires on[ \t]*:/im.test(text)
364
+ && /^[ \t]*Registration Service Provider[ \t]*:/im.test(text);
365
+ const isRegisterBg = !isRootHop
366
+ && /^[ \t]*registration status[ \t]*:/im.test(text)
367
+ && /^[ \t]*NAME SERVER INFORMATION[ \t]*:[ \t]*$/im.test(text);
368
+ const isEif = !isRootHop
369
+ && /Estonia \.ee Top Level Domain WHOIS server/i.test(text)
370
+ && /^[ \t]*Domain[ \t]*:[ \t]*$/im.test(text)
371
+ && /^[ \t]*Name servers[ \t]*:[ \t]*$/im.test(text);
372
+ const isIszt = !isRootHop
373
+ && /^% Whois server[^\r\n]*hu ccTLD/im.test(text)
374
+ && /^[ \t]*record created[ \t]*:/im.test(text);
375
+ const isIsnic = !isRootHop
376
+ && /^% This is the ISNIC Whois server\./im.test(text)
377
+ && /^[ \t]*source[ \t]*:[ \t]*ISNIC(?:\s|$)/im.test(text);
378
+ const isNicLv = !isRootHop
379
+ && /^[ \t]*\[Domain\][ \t]*$/im.test(text)
380
+ && /^[ \t]*\[Holder\][ \t]*$/im.test(text)
381
+ && /^[ \t]*\[Whois\][ \t]*$/im.test(text);
382
+ const isSidn = !isRootHop
383
+ && /^[ \t]*Domain nameservers[ \t]*:[ \t]*$/im.test(text)
384
+ && /^[ \t]*Abuse Contact[ \t]*:[ \t]*$/im.test(text)
385
+ && /^[ \t]*Record maintained by[ \t]*:/im.test(text);
386
+ const isRnids = !isRootHop
387
+ && /^[ \t]*Registration date[ \t]*:/im.test(text)
388
+ && /^[ \t]*Modification date[ \t]*:/im.test(text)
389
+ && /^[ \t]*DNSSEC signed[ \t]*:/im.test(text);
390
+ const isCctldBy = !isRootHop
391
+ && /^[ \t]*Domain name[ \t]*:/im.test(text)
392
+ && /^[ \t]*Registration or other identification number[ \t]*:/im.test(text);
393
+ const isHkirc = !isRootHop
394
+ && /^[ \t]*Domain Name Information[ \t]*:[ \t]*$/im.test(text)
395
+ && /^[ \t]*Name Servers Information[ \t]*:[ \t]*$/im.test(text)
396
+ && /^[ \t]*Domain Name Commencement Date[ \t]*:/im.test(text);
397
+ const isIrnic = !isRootHop
398
+ && /^[ \t]*source[ \t]*:[ \t]*IRNIC(?:\s|$)/im.test(text)
399
+ && /^[ \t]*holder-c[ \t]*:/im.test(text)
400
+ && /^[ \t]*nic-hdl[ \t]*:/im.test(text);
401
+ const isNicKz = !isRootHop
402
+ && /^[ \t]*Domain Name[ \t.]*:/im.test(text)
403
+ && /^[ \t]*Current Registar[ \t]*:/im.test(text)
404
+ && /^[ \t]*Primary server[ \t.]*:/im.test(text);
405
+ const isDnsLu = !isRootHop
406
+ && /^[ \t]*domainname[ \t]*:/im.test(text)
407
+ && /^[ \t]*domaintype[ \t]*:/im.test(text)
408
+ && /^[ \t]*registrar-name[ \t]*:/im.test(text);
409
+ const isNicMd = !isRootHop
410
+ && /^[ \t]*Domain[ \t]+name[ \t]*:/im.test(text)
411
+ && /^[ \t]*Domain state[ \t]*:/im.test(text)
412
+ && /^[ \t]*Registered on[ \t]*:/im.test(text);
413
+ const isThnic = !isRootHop
414
+ && /^[ \t]*Domain Holder Organization[ \t]*:/im.test(text)
415
+ && /^[ \t]*Created date[ \t]*:/im.test(text)
416
+ && /^[ \t]*Exp date[ \t]*:/im.test(text);
417
+ const isAtiTn = !isRootHop
418
+ && /^[ \t]*NIC Whois server for cTLDs[ \t.]*:/im.test(text)
419
+ && /^[ \t]*Domain name[ \t.]*:/im.test(text)
420
+ && /^[ \t]*Creation date[ \t.]*:/im.test(text);
421
+ const isMonic = !isRootHop
422
+ && /^%[ \t]*Monic Whois Server Version[ \t]+\d/im.test(text)
423
+ && /^[ \t]*Domain Name[ \t]*:/im.test(text)
424
+ && /^[ \t]*Record created on[ \t]+[^:\r\n]/im.test(text)
425
+ && /^[ \t]*Domain name servers[ \t]*:[ \t]*$/im.test(text);
426
+ const isNicKg = !isRootHop && hasNicKgRegistrationEvidence(text);
427
+ for (const [key, res] of Object.entries(patterns)) {
428
+ // IANA's root hop describes the TLD and its operator, never a contact
429
+ // for the queried registrable domain. Domain contacts therefore come
430
+ // only from registry/registrar hops.
431
+ if (isRootHop && /^(?:registrant|admin|tech|billing)/.test(key))
432
+ continue;
433
+ if (fields[key])
434
+ continue; // earlier hop already set it - don't let a later, less-authoritative hop overwrite
435
+ const candidates = isRootHop ? res.slice(0, 1) : res;
436
+ for (const re of candidates) {
437
+ const m = text.match(re);
438
+ const matchedValue = m?.[1];
439
+ if (matchedValue) {
440
+ const value = matchedValue.trim();
441
+ // Some WHOIS formats use "Registered: yes/no" as a boolean state,
442
+ // while others use "Registered: <date>" for creation time. Never
443
+ // store the boolean form as a date ("no" previously became a truthy
444
+ // createdDate and could make availability look registered).
445
+ if (key === 'createdDate' && /^(?:yes|no|true|false|available|free)$/i.test(value))
446
+ continue;
447
+ const bounded = boundedWhoisValue(value, whoisFieldLimit(key));
448
+ if (!bounded.value)
449
+ continue;
450
+ fields[key] = bounded.value;
451
+ if (bounded.truncated)
452
+ truncatedFields.add(key);
453
+ break;
454
+ }
455
+ }
456
+ }
457
+ // A small set of ccTLD dialects reuse terse labels that would be unsafe
458
+ // as global aliases (for example `org:` and `state:` also occur inside
459
+ // contact/address blocks). Gate those fields on registry-specific marker
460
+ // combinations, while retaining the same scalar/list bounds as the
461
+ // generic parser and leaving endpoint discovery and authority untouched.
462
+ if (!isRootHop) {
463
+ if (isRegisterBg) {
464
+ const domainLine = text.match(/^[ \t]*DOMAIN NAME[ \t]*:[ \t]*([^\r\n]+)/im);
465
+ const rawDomainLine = domainLine?.[1];
466
+ if (rawDomainLine) {
467
+ const boundedLine = rawDomainLine.slice(0, MAX_WHOIS_FIELD_LENGTH);
468
+ const parenthesized = boundedLine.match(/\(([a-z0-9.-]{1,253})\)[ \t]*$/i)?.[1] || '';
469
+ const leadingAscii = boundedLine.match(/^([a-z0-9.-]{1,253})(?:[ \t]|$)/i)?.[1] || '';
470
+ const unicodeDomain = boundedLine.replace(/[ \t]+\([^\r\n()]{1,253}\)[ \t]*$/, '').trim();
471
+ const domain = [parenthesized, leadingAscii, domainToASCII(unicodeDomain)]
472
+ .find((candidate) => WHOIS_DOMAIN_RE.test(candidate));
473
+ const bounded = boundedWhoisValue(domain, whoisFieldLimit('domainName'));
474
+ if (bounded.value)
475
+ fields.domainName = bounded.value;
476
+ if (bounded.truncated)
477
+ truncatedFields.add('domainName');
478
+ }
479
+ for (const match of text.matchAll(/^[ \t]*registration status[ \t]*:[ \t]*([^\r\n]+)/gim)) {
480
+ if (addBoundedWhoisSetValue(statuses, match[1], {
481
+ maxEntries: MAX_WHOIS_STATUSES, maxLength: 160,
482
+ field: 'statuses', truncatedFields,
483
+ }) === 'capped')
484
+ break;
485
+ }
486
+ collectBareWhoisNameservers(text, /^[ \t]*NAME SERVER INFORMATION[ \t]*:[ \t]*$/im, nameservers, truncatedFields);
487
+ }
488
+ if (isEif) {
489
+ const domainSection = parseBoundedWhoisSection(text, /^[ \t]*Domain[ \t]*:[ \t]*$/im);
490
+ const registrantSection = parseBoundedWhoisSection(text, /^[ \t]*Registrant[ \t]*:[ \t]*$/im);
491
+ const adminSection = parseBoundedWhoisSection(text, /^[ \t]*Administrative contact[ \t]*:[ \t]*$/im);
492
+ const techSection = parseBoundedWhoisSection(text, /^[ \t]*Technical contact[ \t]*:[ \t]*$/im);
493
+ const registrarSection = parseBoundedWhoisSection(text, /^[ \t]*Registrar[ \t]*:[ \t]*$/im);
494
+ assignBoundedWhoisMatch(domainSection, fields, 'domainName', /^[ \t]*name[ \t]*:[ \t]*(.+)$/im, truncatedFields);
495
+ assignBoundedWhoisMatch(registrantSection, fields, 'registrantName', /^[ \t]*name[ \t]*:[ \t]*(.+)$/im, truncatedFields);
496
+ assignBoundedWhoisMatch(registrantSection, fields, 'registrantId', /^[ \t]*org id[ \t]*:[ \t]*(.+)$/im, truncatedFields);
497
+ assignBoundedWhoisMatch(registrantSection, fields, 'registrantCountry', /^[ \t]*country[ \t]*:[ \t]*(.+)$/im, truncatedFields);
498
+ assignBoundedWhoisMatch(adminSection, fields, 'adminName', /^[ \t]*name[ \t]*:[ \t]*(.+)$/im, truncatedFields);
499
+ assignBoundedWhoisMatch(techSection, fields, 'techName', /^[ \t]*name[ \t]*:[ \t]*(.+)$/im, truncatedFields);
500
+ assignBoundedWhoisMatch(registrarSection, fields, 'registrar', /^[ \t]*name[ \t]*:[ \t]*(.+)$/im, truncatedFields);
501
+ assignBoundedWhoisMatch(registrarSection, fields, 'registrarUrl', /^[ \t]*url[ \t]*:[ \t]*(.+)$/im, truncatedFields);
502
+ }
503
+ if (isIszt) {
504
+ assignBoundedWhoisMatch(text, fields, 'createdDate', /^[ \t]*record created[ \t]*:[ \t]*(.+)$/im, truncatedFields);
505
+ }
506
+ if (isIsnic) {
507
+ const handle = text.match(/^[ \t]*registrant[ \t]*:[ \t]*(.+)$/im)?.[1] || null;
508
+ if (handle) {
509
+ const boundedHandle = boundedWhoisValue(handle, whoisFieldLimit('registrantId'));
510
+ if (boundedHandle.value)
511
+ fields.registrantId = boundedHandle.value;
512
+ if (boundedHandle.truncated)
513
+ truncatedFields.add('registrantId');
514
+ delete fields.registrantName;
515
+ const role = resolveIsnicRole(text, boundedHandle.value);
516
+ if (role) {
517
+ if (role.org.value)
518
+ fields.registrantOrg = role.org.value;
519
+ if (role.email.value)
520
+ fields.registrantEmail = role.email.value;
521
+ if (role.phone.value)
522
+ fields.registrantPhone = role.phone.value;
523
+ if (role.address.value)
524
+ fields.registrantAddress = role.address.value;
525
+ if (role.org.truncated)
526
+ truncatedFields.add('registrantOrg');
527
+ if (role.email.truncated)
528
+ truncatedFields.add('registrantEmail');
529
+ if (role.phone.truncated)
530
+ truncatedFields.add('registrantPhone');
531
+ if (role.address.truncated || role.truncated)
532
+ truncatedFields.add('registrantAddress');
533
+ }
534
+ }
535
+ assignBoundedWhoisMatch(text, fields, 'adminId', /^[ \t]*admin-c[ \t]*:[ \t]*(.+)$/im, truncatedFields);
536
+ assignBoundedWhoisMatch(text, fields, 'techId', /^[ \t]*tech-c[ \t]*:[ \t]*(.+)$/im, truncatedFields);
537
+ assignBoundedWhoisMatch(text, fields, 'billingId', /^[ \t]*billing-c[ \t]*:[ \t]*(.+)$/im, truncatedFields);
538
+ }
539
+ if (isNicLv) {
540
+ const holderSection = parseBoundedWhoisSection(text, /^[ \t]*\[Holder\][ \t]*$/im);
541
+ assignBoundedWhoisMatch(holderSection, fields, 'registrantName', /^[ \t]*Name[ \t]*:[ \t]*(.+)$/im, truncatedFields);
542
+ assignBoundedWhoisMatch(holderSection, fields, 'registrantId', /^[ \t]*RegNr[ \t]*:[ \t]*(.+)$/im, truncatedFields);
543
+ assignBoundedWhoisMatch(holderSection, fields, 'registrantAddress', /^[ \t]*Address[ \t]*:[ \t]*(.+)$/im, truncatedFields);
544
+ assignBoundedWhoisMatch(holderSection, fields, 'registrantCountry', /^[ \t]*Country[ \t]*:[ \t]*(.+)$/im, truncatedFields);
545
+ }
546
+ if (isSidn) {
547
+ const registrar = parseIndentedWhoisValue(text, /^[ \t]*Registrar[ \t]*:[ \t]*$/im, whoisFieldLimit('registrar'));
548
+ if (registrar?.value)
549
+ fields.registrar = registrar.value;
550
+ if (registrar?.truncated)
551
+ truncatedFields.add('registrar');
552
+ const abuse = parseIndentedContactBlock(text, /^[ \t]*Abuse Contact[ \t]*:[ \t]*$/im);
553
+ if (abuse?.email)
554
+ fields.abuseEmail = abuse.email;
555
+ if (abuse?.phone)
556
+ fields.abusePhone = abuse.phone;
557
+ if (abuse?.truncated) {
558
+ truncatedFields.add('abuseEmail');
559
+ truncatedFields.add('abusePhone');
560
+ }
561
+ collectBareWhoisNameservers(text, /^[ \t]*Domain nameservers[ \t]*:[ \t]*$/im, nameservers, truncatedFields);
562
+ }
563
+ if (isRnids) {
564
+ assignBoundedWhoisMatch(text, fields, 'adminName', /^[ \t]*Administrative contact[ \t]*:[ \t]*(.+)$/im, truncatedFields);
565
+ assignBoundedWhoisMatch(text, fields, 'techName', /^[ \t]*Technical contact[ \t]*:[ \t]*(.+)$/im, truncatedFields);
566
+ assignBoundedWhoisMatch(text, fields, 'dnssec', /^[ \t]*DNSSEC signed[ \t]*:[ \t]*(.+)$/im, truncatedFields);
567
+ for (const match of text.matchAll(/^[ \t]*Domain status[ \t]*:[ \t]*(.*?)(?:[ \t]+https?:\/\/\S+)?[ \t]*$/gim)) {
568
+ if (addBoundedWhoisSetValue(statuses, match[1], {
569
+ maxEntries: MAX_WHOIS_STATUSES, maxLength: 160,
570
+ field: 'statuses', truncatedFields,
571
+ }) === 'capped')
572
+ break;
573
+ }
574
+ }
575
+ if (isCctldBy) {
576
+ assignBoundedWhoisMatch(text, fields, 'registrantOrg', /^[ \t]*Org[ \t]*:[ \t]*(.+)$/im, truncatedFields);
577
+ assignBoundedWhoisMatch(text, fields, 'registrantId', /^[ \t]*Registration or other identification number[ \t]*:[ \t]*(.+)$/im, truncatedFields);
578
+ }
579
+ if (isHkirc) {
580
+ assignBoundedWhoisMatch(text, fields, 'createdDate', /^[ \t]*Domain Name Commencement Date[ \t]*:[ \t]*(.+)$/im, truncatedFields);
581
+ collectBareWhoisNameservers(text, /^[ \t]*Name Servers Information[ \t]*:[ \t]*$/im, nameservers, truncatedFields);
582
+ }
583
+ if (isIrnic) {
584
+ const registrantId = text.match(/^[ \t]*holder-c[ \t]*:[ \t]*(.+)$/im)?.[1] || null;
585
+ if (registrantId) {
586
+ assignBoundedWhoisMatch(text, fields, 'registrantId', /^[ \t]*holder-c[ \t]*:[ \t]*(.+)$/im, truncatedFields);
587
+ const contact = resolveIrnicContact(text, fields.registrantId);
588
+ if (contact) {
589
+ if (contact.name.value)
590
+ fields.registrantName = contact.name.value;
591
+ if (contact.org.value)
592
+ fields.registrantOrg = contact.org.value;
593
+ if (contact.email.value)
594
+ fields.registrantEmail = contact.email.value;
595
+ if (contact.phone.value)
596
+ fields.registrantPhone = contact.phone.value;
597
+ if (contact.address.value)
598
+ fields.registrantAddress = contact.address.value;
599
+ if (contact.name.truncated)
600
+ truncatedFields.add('registrantName');
601
+ if (contact.org.truncated)
602
+ truncatedFields.add('registrantOrg');
603
+ if (contact.email.truncated)
604
+ truncatedFields.add('registrantEmail');
605
+ if (contact.phone.truncated)
606
+ truncatedFields.add('registrantPhone');
607
+ if (contact.address.truncated || contact.truncated)
608
+ truncatedFields.add('registrantAddress');
609
+ }
610
+ }
611
+ assignBoundedWhoisMatch(text, fields, 'adminId', /^[ \t]*admin-c[ \t]*:[ \t]*(.+)$/im, truncatedFields);
612
+ assignBoundedWhoisMatch(text, fields, 'techId', /^[ \t]*tech-c[ \t]*:[ \t]*(.+)$/im, truncatedFields);
613
+ assignBoundedWhoisMatch(text, fields, 'billingId', /^[ \t]*bill-c[ \t]*:[ \t]*(.+)$/im, truncatedFields);
614
+ }
615
+ if (isNicKz) {
616
+ assignBoundedWhoisMatch(text, fields, 'createdDate', /^[ \t]*Domain created[ \t]*:[ \t]*(.+)$/im, truncatedFields);
617
+ assignBoundedWhoisMatch(text, fields, 'updatedDate', /^[ \t]*Last modified[ \t.]*:[ \t]*(.+)$/im, truncatedFields);
618
+ assignBoundedWhoisMatch(text, fields, 'registrar', /^[ \t]*Current Registar[ \t]*:[ \t]*(.+)$/im, truncatedFields);
619
+ for (const pattern of [
620
+ /^[ \t]*Primary server[ \t.]*:[ \t]*([a-zA-Z0-9.-]+)/gim,
621
+ /^[ \t]*Secondary server[ \t.]*:[ \t]*([a-zA-Z0-9.-]+)/gim,
622
+ ]) {
623
+ for (const match of text.matchAll(pattern)) {
624
+ const result = addBoundedWhoisSetValue(nameservers, match[1], {
625
+ maxEntries: MAX_WHOIS_NAMESERVERS, maxLength: 253,
626
+ field: 'nameservers', truncatedFields,
627
+ });
628
+ if (result === 'capped')
629
+ break;
630
+ }
631
+ }
632
+ }
633
+ if (isDnsLu) {
634
+ assignBoundedWhoisMatch(text, fields, 'domainName', /^[ \t]*domainname[ \t]*:[ \t]*(.+)$/im, truncatedFields);
635
+ assignBoundedWhoisMatch(text, fields, 'registrar', /^[ \t]*registrar-name[ \t]*:[ \t]*(.+)$/im, truncatedFields);
636
+ assignBoundedWhoisMatch(text, fields, 'registrarUrl', /^[ \t]*registrar-url[ \t]*:[ \t]*(.+)$/im, truncatedFields);
637
+ }
638
+ if (isNicMd) {
639
+ assignBoundedWhoisMatch(text, fields, 'domainName', /^[ \t]*Domain[ \t]+name[ \t]*:[ \t]*(.+)$/im, truncatedFields);
640
+ for (const match of text.matchAll(/^[ \t]*Domain state[ \t]*:[ \t]*([^\r\n]+)/gim)) {
641
+ if (addBoundedWhoisSetValue(statuses, match[1], {
642
+ maxEntries: MAX_WHOIS_STATUSES, maxLength: 160,
643
+ field: 'statuses', truncatedFields,
644
+ }) === 'capped')
645
+ break;
646
+ }
647
+ }
648
+ if (isThnic) {
649
+ assignBoundedWhoisMatch(text, fields, 'createdDate', /^[ \t]*Created date[ \t]*:[ \t]*(.+)$/im, truncatedFields);
650
+ assignBoundedWhoisMatch(text, fields, 'expiryDate', /^[ \t]*Exp date[ \t]*:[ \t]*(.+)$/im, truncatedFields);
651
+ assignBoundedWhoisMatch(text, fields, 'registrantOrg', /^[ \t]*Domain Holder Organization[ \t]*:[ \t]*(.+)$/im, truncatedFields);
652
+ assignBoundedWhoisMatch(text, fields, 'registrantCountry', /^[ \t]*Domain Holder Country[ \t]*:[ \t]*(.+)$/im, truncatedFields);
653
+ assignBoundedWhoisMatch(text, fields, 'registrantStreet', /^[ \t]*Domain Holder Street[ \t]*:[ \t]*(.+)$/im, truncatedFields);
654
+ }
655
+ if (isMonic) {
656
+ assignBoundedWhoisMatch(text, fields, 'createdDate', /^[ \t]*Record created on[ \t]+(.+)$/im, truncatedFields);
657
+ collectBareWhoisNameservers(text, /^[ \t]*Domain name servers[ \t]*:[ \t]*$/im, nameservers, truncatedFields);
658
+ }
659
+ if (isNicKg) {
660
+ const domainHeader = text.match(/^[ \t]*Domain[ \t]+([a-z0-9.-]+)[ \t]+\(([A-Z][A-Z0-9_-]*)\)[ \t]*$/im);
661
+ if (domainHeader) {
662
+ const domain = boundedWhoisValue(domainHeader[1], whoisFieldLimit('domainName'));
663
+ if (domain.value)
664
+ fields.domainName = domain.value;
665
+ if (domain.truncated)
666
+ truncatedFields.add('domainName');
667
+ addBoundedWhoisSetValue(statuses, domainHeader[2], {
668
+ maxEntries: MAX_WHOIS_STATUSES,
669
+ maxLength: 160,
670
+ field: 'statuses',
671
+ truncatedFields,
672
+ });
673
+ }
674
+ assignBoundedWhoisMatch(text, fields, 'createdDate', /^[ \t]*Record created[ \t]*:[ \t]*(.+)$/im, truncatedFields);
675
+ assignBoundedWhoisMatch(text, fields, 'updatedDate', /^[ \t]*Record last updated on[ \t]*:[ \t]*(.+)$/im, truncatedFields);
676
+ assignBoundedWhoisMatch(text, fields, 'expiryDate', /^[ \t]*Record expires on[ \t]*:[ \t]*(.+)$/im, truncatedFields);
677
+ collectBareWhoisNameservers(text, /^[ \t]*Name servers in the listed order[ \t]*:[ \t]*$/im, nameservers, truncatedFields);
678
+ }
679
+ const isRegistroBr = /^[ \t]*owner-c[ \t]*:/im.test(text)
680
+ && /^[ \t]*country[ \t]*:[ \t]*BR(?:\s|$)/im.test(text);
681
+ if (isRegistroBr) {
682
+ assignBoundedWhoisMatch(text, fields, 'registrantName', /^[ \t]*owner[ \t]*:[ \t]*(.+)$/im, truncatedFields);
683
+ assignBoundedWhoisMatch(text, fields, 'registrantId', /^[ \t]*owner-c[ \t]*:[ \t]*(.+)$/im, truncatedFields);
684
+ assignBoundedWhoisMatch(text, fields, 'adminId', /^[ \t]*admin-c[ \t]*:[ \t]*(.+)$/im, truncatedFields);
685
+ assignBoundedWhoisMatch(text, fields, 'techId', /^[ \t]*tech-c[ \t]*:[ \t]*(.+)$/im, truncatedFields);
686
+ }
687
+ const isAfnic = /^[ \t]*source[ \t]*:[ \t]*FRNIC(?:\s|$)/im.test(text);
688
+ if (isAfnic) {
689
+ assignBoundedWhoisMatch(text, fields, 'registrantId', /^[ \t]*holder-c[ \t]*:[ \t]*(.+)$/im, truncatedFields);
690
+ assignBoundedWhoisMatch(text, fields, 'adminId', /^[ \t]*admin-c[ \t]*:[ \t]*(.+)$/im, truncatedFields);
691
+ assignBoundedWhoisMatch(text, fields, 'techId', /^[ \t]*tech-c[ \t]*:[ \t]*(.+)$/im, truncatedFields);
692
+ for (const match of text.matchAll(/^[ \t]*eppstatus[ \t]*:[ \t]*(.+)$/gim)) {
693
+ if (addBoundedWhoisSetValue(statuses, match[1], {
694
+ maxEntries: MAX_WHOIS_STATUSES, maxLength: 160,
695
+ field: 'statuses', truncatedFields,
696
+ }) === 'capped')
697
+ break;
698
+ }
699
+ }
700
+ const isTci = /^[ \t]*source[ \t]*:[ \t]*TCI(?:\s|$)/im.test(text)
701
+ && /^[ \t]*paid-till[ \t]*:/im.test(text);
702
+ if (isTci) {
703
+ assignBoundedWhoisMatch(text, fields, 'registrantOrg', /^[ \t]*org[ \t]*:[ \t]*(.+)$/im, truncatedFields);
704
+ for (const match of text.matchAll(/^[ \t]*state[ \t]*:[ \t]*(.+)$/gim)) {
705
+ if (addBoundedWhoisSetValue(statuses, match[1], {
706
+ maxEntries: MAX_WHOIS_STATUSES, maxLength: 160,
707
+ field: 'statuses', truncatedFields,
708
+ }) === 'capped')
709
+ break;
710
+ }
711
+ }
712
+ const isInternetstiftelsen = /^[ \t]*registry-lock[ \t]*:/im.test(text)
713
+ && /^[ \t]*holder[ \t]*:/im.test(text);
714
+ if (isInternetstiftelsen) {
715
+ assignBoundedWhoisMatch(text, fields, 'registrantId', /^[ \t]*holder[ \t]*:[ \t]*(.+)$/im, truncatedFields);
716
+ for (const match of text.matchAll(/^[ \t]*state[ \t]*:[ \t]*(.+)$/gim)) {
717
+ if (addBoundedWhoisSetValue(statuses, match[1], {
718
+ maxEntries: MAX_WHOIS_STATUSES, maxLength: 160,
719
+ field: 'statuses', truncatedFields,
720
+ }) === 'capped')
721
+ break;
722
+ }
723
+ }
724
+ const isNask = /^[ \t]*DOMAIN NAME[ \t]*:/im.test(text)
725
+ && /^[ \t]*registrant type[ \t]*:/im.test(text)
726
+ && /^[ \t]*renewal date[ \t]*:/im.test(text)
727
+ && /^[ \t]*REGISTRAR[ \t]*:[ \t]*$/im.test(text);
728
+ if (isNask) {
729
+ if (!fields.registrar) {
730
+ const registrarHeader = text.match(/^[ \t]*REGISTRAR[ \t]*:[ \t]*$/im);
731
+ const followingLines = registrarHeader
732
+ ? text.slice((registrarHeader.index ?? 0) + registrarHeader[0].length).split('\n').slice(0, 8)
733
+ : [];
734
+ for (const line of followingLines) {
735
+ if (!line.trim())
736
+ continue;
737
+ const bounded = boundedWhoisValue(line, whoisFieldLimit('registrar'));
738
+ if (bounded.value && !/^[a-z][a-z0-9+.-]*:\/\//i.test(bounded.value)
739
+ && !/^[a-z][a-z0-9 -]{0,50}:[ \t]/i.test(bounded.value)) {
740
+ fields.registrar = bounded.value;
741
+ }
742
+ if (bounded.truncated)
743
+ truncatedFields.add('registrar');
744
+ break;
745
+ }
746
+ }
747
+ const nameserverHeader = text.match(/^[ \t]*nameservers[ \t]*:[ \t]*(.*)$/im);
748
+ if (nameserverHeader) {
749
+ const candidates = [
750
+ nameserverHeader[1] ?? '',
751
+ ...text.slice((nameserverHeader.index ?? 0) + nameserverHeader[0].length)
752
+ .split('\n').slice(1, MAX_WHOIS_NAMESERVERS + 2),
753
+ ];
754
+ let found = 0;
755
+ for (const line of candidates) {
756
+ if (line === undefined)
757
+ continue;
758
+ const trimmed = line.trim();
759
+ if (!trimmed) {
760
+ if (found > 0)
761
+ break;
762
+ continue;
763
+ }
764
+ const hostMatch = trimmed.match(/^([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})\.?(?:\s|$)/);
765
+ if (!hostMatch)
766
+ break;
767
+ const hostname = hostMatch[1];
768
+ if (!hostname)
769
+ break;
770
+ const result = addBoundedWhoisSetValue(nameservers, hostname.replace(/\.$/, ''), {
771
+ maxEntries: MAX_WHOIS_NAMESERVERS, maxLength: 253,
772
+ field: 'nameservers', truncatedFields,
773
+ });
774
+ if (result === 'capped')
775
+ break;
776
+ if (result === 'added')
777
+ found += 1;
778
+ }
779
+ }
780
+ }
781
+ if ((isDnsBelgium || isEurid) && !fields.registrar) {
782
+ const registrar = parseIndentedWhoisSubfield(text, /^[ \t]*Registrar[ \t]*:[ \t]*$/im, /^[ \t]*Name[ \t]*:[ \t]*(.+)$/i, whoisFieldLimit('registrar'));
783
+ if (registrar?.value)
784
+ fields.registrar = registrar.value;
785
+ if (registrar?.truncated)
786
+ truncatedFields.add('registrar');
787
+ }
788
+ if (isNorid) {
789
+ assignBoundedWhoisMatch(text, fields, 'registryDomainId', /^[ \t]*NORID Handle[ \t.]*:[ \t]*(.+)$/im, truncatedFields);
790
+ assignBoundedWhoisMatch(text, fields, 'registrar', /^[ \t]*Registrar Handle[ \t.]*:[ \t]*(.+)$/im, truncatedFields);
791
+ }
792
+ // These aliases are meaningful only inside their registry's
793
+ // distinctive response dialect. Keeping them marker-gated avoids
794
+ // treating generic contact IDs, validity text, or service-provider
795
+ // prose as domain-level evidence in unrelated WHOIS responses.
796
+ if (isCnnic) {
797
+ assignBoundedWhoisMatch(text, fields, 'registryDomainId', /^[ \t]*ROID[ \t]*:[ \t]*(.+)$/im, truncatedFields);
798
+ assignBoundedWhoisMatch(text, fields, 'expiryDate', /^[ \t]*Expiration Time[ \t]*:[ \t]*(.+)$/im, truncatedFields);
799
+ }
800
+ if (isPandi) {
801
+ assignBoundedWhoisMatch(text, fields, 'registryDomainId', /^[ \t]*Domain ID[ \t]*:[ \t]*(.+)$/im, truncatedFields);
802
+ assignBoundedWhoisMatch(text, fields, 'registrar', /^[ \t]*Sponsoring Registrar Organization[ \t]*:[ \t]*(.+)$/im, truncatedFields);
803
+ }
804
+ if (isIsocIl) {
805
+ assignBoundedWhoisMatch(text, fields, 'expiryDate', /^[ \t]*validity[ \t]*:[ \t]*(.+)$/im, truncatedFields);
806
+ }
807
+ if (isTwnic) {
808
+ assignBoundedWhoisMatch(text, fields, 'registrar', /^[ \t]*Registration Service Provider[ \t]*:[ \t]*(.+)$/im, truncatedFields);
809
+ assignBoundedWhoisMatch(text, fields, 'createdDate', /^[ \t]*Record created on[ \t]*:[ \t]*(.+)$/im, truncatedFields);
810
+ assignBoundedWhoisMatch(text, fields, 'expiryDate', /^[ \t]*Record expires on[ \t]*:[ \t]*(.+)$/im, truncatedFields);
811
+ }
812
+ }
813
+ // Sectioned legacy port-43 responses use a distinctive pair of section
814
+ // headers and place several values on following indented lines. Gate the
815
+ // dialect on both markers so generic responses with one blank field are
816
+ // not reinterpreted. This is parser compatibility only: IANA still
817
+ // selects the endpoint and authority analysis remains unchanged.
818
+ const isSectionedRegistryResponse = !isRootHop
819
+ && /^[ \t]*Relevant dates[ \t]*:[ \t]*$/im.test(text)
820
+ && /^[ \t]*Registration status[ \t]*:[ \t]*$/im.test(text);
821
+ if (isSectionedRegistryResponse) {
822
+ for (const [key, headerRe] of [
823
+ ['domainName', /^[ \t]*Domain(?: name)?[ \t]*:[ \t]*$/im],
824
+ ['registrantName', /^[ \t]*Registrant[ \t]*:[ \t]*$/im],
825
+ ['registrar', /^[ \t]*Registrar[ \t]*:[ \t]*$/im],
826
+ ]) {
827
+ if (fields[key])
828
+ continue;
829
+ const parsed = parseIndentedWhoisValue(text, headerRe, whoisFieldLimit(key));
830
+ if (!parsed?.value)
831
+ continue;
832
+ fields[key] = parsed.value;
833
+ if (parsed.truncated)
834
+ truncatedFields.add(key);
835
+ }
836
+ const registrationStatus = parseIndentedWhoisValue(text, /^[ \t]*Registration status[ \t]*:[ \t]*$/im, 160);
837
+ if (registrationStatus?.value) {
838
+ addBoundedWhoisSetValue(statuses, registrationStatus.value, {
839
+ maxEntries: MAX_WHOIS_STATUSES,
840
+ maxLength: 160,
841
+ field: 'statuses',
842
+ truncatedFields,
843
+ });
844
+ }
845
+ const isChannelIslandsResponse = /^[ \t]*Domain Status[ \t]*:[ \t]*$/im.test(text)
846
+ && /^[ \t]*WHOIS lookup made on[ \t]+[^\r\n]+$/im.test(text);
847
+ if (isChannelIslandsResponse) {
848
+ assignBoundedWhoisMatch(text, fields, 'createdDate', /^[ \t]*Registered on[ \t]+(.+)$/im, truncatedFields);
849
+ const domainStatus = parseIndentedWhoisValue(text, /^[ \t]*Domain Status[ \t]*:[ \t]*$/im, 160);
850
+ if (domainStatus?.value) {
851
+ addBoundedWhoisSetValue(statuses, domainStatus.value, {
852
+ maxEntries: MAX_WHOIS_STATUSES,
853
+ maxLength: 160,
854
+ field: 'statuses',
855
+ truncatedFields,
856
+ });
857
+ }
858
+ }
859
+ }
860
+ if (!isRootHop) {
861
+ for (const [prefix, label] of [
862
+ ['registrant', 'Registrant'], ['admin', 'Admin(?:istrative)?'],
863
+ ['tech', 'Tech(?:nical)?'], ['billing', 'Billing'],
864
+ ]) {
865
+ const key = `${prefix}Street`;
866
+ if (expandedStreetFields.has(key))
867
+ continue;
868
+ const streetRe = new RegExp(`^[ \\t*]*${label} (?:Contact )?Street[ \\t.]*:[ \\t]*(.+)$`, 'gim');
869
+ const lines = [];
870
+ for (const match of text.matchAll(streetRe)) {
871
+ const bounded = boundedWhoisValue(match[1], 300);
872
+ if (bounded.truncated)
873
+ truncatedFields.add(key);
874
+ if (!bounded.value || lines.includes(bounded.value))
875
+ continue;
876
+ if (lines.length >= 4) {
877
+ truncatedFields.add(key);
878
+ break;
879
+ }
880
+ lines.push(bounded.value);
881
+ }
882
+ if (lines.length)
883
+ fields[key] = lines.join(', ');
884
+ expandedStreetFields.add(key);
885
+ }
886
+ // The documented .nz protocol numbers up to two address lines using
887
+ // underscore field names. Aggregate only those exact fields, keeping
888
+ // the same four-line/300-character bounds as repeated ICANN-style
889
+ // street fields and leaving city/province/postcode separately typed.
890
+ for (const [prefix, nzPrefix] of [
891
+ ['registrant', 'registrant'], ['admin', 'admin'], ['tech', 'technical'],
892
+ ]) {
893
+ const key = `${prefix}Street`;
894
+ if (fields[key])
895
+ continue;
896
+ const addressRe = new RegExp(`^[ \\t]*${nzPrefix}_contact_address(?:1|2)[ \\t]*:[ \\t]*(.+)$`, 'gim');
897
+ const lines = [];
898
+ for (const match of text.matchAll(addressRe)) {
899
+ const bounded = boundedWhoisValue(match[1], 300);
900
+ if (bounded.truncated)
901
+ truncatedFields.add(key);
902
+ if (!bounded.value || lines.includes(bounded.value))
903
+ continue;
904
+ if (lines.length >= 4) {
905
+ truncatedFields.add(key);
906
+ break;
907
+ }
908
+ lines.push(bounded.value);
909
+ }
910
+ if (lines.length)
911
+ fields[key] = lines.join(', ');
912
+ }
913
+ }
914
+ // If registrantName looks like it's actually a handle (a matching
915
+ // "contact: <handle>" block exists in this same hop), resolve it for
916
+ // the real name/org/email/phone/address. Harmless no-op otherwise -
917
+ // registries where "Registrant:" is already the real name (e.g. .kr)
918
+ // simply won't have a matching contact block to find.
919
+ if (!isRootHop && fields.registrantName) {
920
+ const resolved = resolveFredContact(text, fields.registrantName);
921
+ if (resolved) {
922
+ if (resolved.name)
923
+ fields.registrantName = resolved.name;
924
+ if (resolved.org && !fields.registrantOrg)
925
+ fields.registrantOrg = resolved.org;
926
+ if (resolved.email && !fields.registrantEmail)
927
+ fields.registrantEmail = resolved.email;
928
+ if (resolved.phone && !fields.registrantPhone)
929
+ fields.registrantPhone = resolved.phone;
930
+ if (resolved.address && !fields.registrantAddress)
931
+ fields.registrantAddress = resolved.address;
932
+ }
933
+ }
934
+ // EDUCAUSE (.edu) and similar legacy registries list registrant/admin/
935
+ // technical contacts as indented blocks rather than "Field: value"
936
+ // pairs - see parseIndentedContactBlock. The plain "Registrant:" header
937
+ // has no separate person name on .edu (registrants are institutions),
938
+ // so its block's first line maps to the org, not a name.
939
+ if (!isRootHop && !fields.registrantOrg && !fields.registrantName) {
940
+ const block = parseIndentedContactBlock(text, /^[ \t]*Registrant:[ \t]*$/im);
941
+ if (block) {
942
+ if (block.name)
943
+ fields.registrantOrg = block.name;
944
+ if (block.address)
945
+ fields.registrantAddress = block.address;
946
+ if (block.phone)
947
+ fields.registrantPhone = block.phone;
948
+ if (block.email)
949
+ fields.registrantEmail = block.email;
950
+ if (block.truncated)
951
+ truncatedFields.add('registrantAddress');
952
+ }
953
+ }
954
+ if (!isRootHop && !fields.adminName) {
955
+ const block = parseIndentedContactBlock(text, /^[ \t]*Administrative Contact:[ \t]*$/im);
956
+ if (block) {
957
+ if (block.name)
958
+ fields.adminName = block.name;
959
+ if (block.address)
960
+ fields.adminAddress = block.address;
961
+ if (block.phone)
962
+ fields.adminPhone = block.phone;
963
+ if (block.email)
964
+ fields.adminEmail = block.email;
965
+ if (block.truncated)
966
+ truncatedFields.add('adminAddress');
967
+ }
968
+ }
969
+ if (!isRootHop && !fields.techName) {
970
+ const block = parseIndentedContactBlock(text, /^[ \t]*Technical Contact:[ \t]*$/im);
971
+ if (block) {
972
+ if (block.name)
973
+ fields.techName = block.name;
974
+ if (block.address)
975
+ fields.techAddress = block.address;
976
+ if (block.phone)
977
+ fields.techPhone = block.phone;
978
+ if (block.email)
979
+ fields.techEmail = block.email;
980
+ if (block.truncated)
981
+ truncatedFields.add('techAddress');
982
+ }
983
+ }
984
+ if (!isRootHop && !fields.billingName) {
985
+ const block = parseIndentedContactBlock(text, /^[ \t]*Billing Contact:[ \t]*$/im);
986
+ if (block) {
987
+ if (block.name)
988
+ fields.billingName = block.name;
989
+ if (block.address)
990
+ fields.billingAddress = block.address;
991
+ if (block.phone)
992
+ fields.billingPhone = block.phone;
993
+ if (block.email)
994
+ fields.billingEmail = block.email;
995
+ if (block.truncated)
996
+ truncatedFields.add('billingAddress');
997
+ }
998
+ }
999
+ // .jp (JPRS) uses a bracketed dual-language format instead of
1000
+ // "Label: value" - e.g. "[Domain Name] GOO.JP", with Japanese-only
1001
+ // labels alongside English ones for the same field. The bracket syntax
1002
+ // is distinctive enough to not need root-hop gating.
1003
+ if (!fields.domainName) {
1004
+ const m = text.match(/\[Domain Name\][ \t]*(.+)/i);
1005
+ if (m?.[1])
1006
+ fields.domainName = m[1].trim();
1007
+ }
1008
+ if (!fields.registrantName) {
1009
+ const m = text.match(/\[Registrant\][ \t]*(.+)/i);
1010
+ if (m?.[1])
1011
+ fields.registrantName = m[1].trim();
1012
+ }
1013
+ if (!fields.createdDate) {
1014
+ const m = text.match(/\[登録年月日\][ \t]*(.+)/);
1015
+ if (m?.[1])
1016
+ fields.createdDate = m[1].trim();
1017
+ }
1018
+ if (!fields.expiryDate) {
1019
+ const m = text.match(/\[有効期限\][ \t]*(.+)/);
1020
+ if (m?.[1])
1021
+ fields.expiryDate = m[1].trim();
1022
+ }
1023
+ for (const m of text.matchAll(/\[状態\][ \t]*(.+)/g)) {
1024
+ if (addBoundedWhoisSetValue(statuses, m[1], {
1025
+ maxEntries: MAX_WHOIS_STATUSES, maxLength: 160,
1026
+ field: 'statuses', truncatedFields,
1027
+ }) === 'capped')
1028
+ break;
1029
+ }
1030
+ for (const m of text.matchAll(/\[Name Server\][ \t]*([a-zA-Z0-9.\-]+)/gi)) {
1031
+ if (addBoundedWhoisSetValue(nameservers, m[1], {
1032
+ maxEntries: MAX_WHOIS_NAMESERVERS, maxLength: 253,
1033
+ field: 'nameservers', truncatedFields,
1034
+ }) === 'capped')
1035
+ break;
1036
+ }
1037
+ // "Name Server:" never collides with the IANA root hop (which uses
1038
+ // lowercase "nserver:" for the TLD's own root nameservers) so it's safe
1039
+ // on every hop; "nserver:"/"Host Name:"/"DNS:" are real per-domain
1040
+ // labels on some registries (e.g. .ru, .kr, .mx) but only once we're
1041
+ // past the root hop, for the same reason as the field patterns above.
1042
+ const nsLinePatterns = [/^[ \t*]*Name Server[ \t.]*:[ \t]*([a-zA-Z0-9.\-]+)/gim];
1043
+ if (!isRootHop) {
1044
+ nsLinePatterns.push(/^[ \t*]*nserver[ \t.]*:[ \t]*([a-zA-Z0-9.\-]+)/gim, /^[ \t*]*Nameserver[ \t.]*:[ \t]*([a-zA-Z0-9.\-]+)/gim, /^[ \t*]*Host Name[ \t.]*:[ \t]*([a-zA-Z0-9.\-]+)/gim, /^[ \t]*ns_name_\d{2}[ \t]*:[ \t]*([a-zA-Z0-9.\-]+)/gim);
1045
+ // Punktum dk uses `DNS: example.dk` for the queried domain, then
1046
+ // `Hostname:` inside its nameserver section. Other supported
1047
+ // registries use `DNS:` for an actual nameserver, so switch aliases
1048
+ // only when the full .dk marker set is present.
1049
+ nsLinePatterns.push(isPunktum
1050
+ ? /^[ \t*]*Hostname[ \t.]*:[ \t]*([a-zA-Z0-9.\-]+)/gim
1051
+ : /^[ \t*]*DNS[ \t.]*:[ \t]*([a-zA-Z0-9.\-]+)/gim);
1052
+ }
1053
+ for (const re of nsLinePatterns) {
1054
+ for (const m of text.matchAll(re)) {
1055
+ if (addBoundedWhoisSetValue(nameservers, m[1], {
1056
+ maxEntries: MAX_WHOIS_NAMESERVERS, maxLength: 253,
1057
+ field: 'nameservers', truncatedFields,
1058
+ }) === 'capped')
1059
+ break;
1060
+ }
1061
+ }
1062
+ // Same root-hop exclusion as above: bare "status:" is also how IANA
1063
+ // reports the TLD's own delegation status (e.g. "status: ACTIVE" for
1064
+ // .gt itself), not the queried domain's status. Deliberately not adding
1065
+ // "state:" as an alternate here (some registries, e.g. .ru/.se, use it
1066
+ // for domain status) - it's also the standard label for a postal
1067
+ // address's state/province in Name/City/State/Country contact blocks
1068
+ // (seen on .mx), and there's no reliable way to tell those apart from
1069
+ // the label alone - a missing status is safer than a wrong one.
1070
+ const statusRe = isRootHop
1071
+ ? /^[ \t*]*Domain Status[ \t.]*:[ \t]*([a-zA-Z][a-zA-Z0-9_-]*)/gim
1072
+ : (isDnsBelgium || isPunktum || isIsocIl || isTwnic || isEif
1073
+ || isHkirc || isNicKz || isNicMd || isAtiTn)
1074
+ ? /^[ \t*]*(?:Domain Status|Status)[ \t.]*:[ \t]*([^\r\n]+)/gim
1075
+ : /^[ \t*]*(?:Domain Status|Status)[ \t.]*:[ \t]*([a-zA-Z][a-zA-Z0-9_-]*)/gim;
1076
+ if (!isRnids) {
1077
+ for (const m of text.matchAll(statusRe)) {
1078
+ if (addBoundedWhoisSetValue(statuses, m[1], {
1079
+ maxEntries: MAX_WHOIS_STATUSES, maxLength: 160,
1080
+ field: 'statuses', truncatedFields,
1081
+ }) === 'capped')
1082
+ break;
1083
+ }
1084
+ }
1085
+ if (!isRootHop) {
1086
+ for (const m of text.matchAll(/^[ \t]*query_status[ \t]*:[ \t]*(\d{3}(?:[ \t]+[^\r\n]+)?)/gim)) {
1087
+ if (addBoundedWhoisSetValue(statuses, m[1], {
1088
+ maxEntries: MAX_WHOIS_STATUSES, maxLength: 160,
1089
+ field: 'statuses', truncatedFields,
1090
+ }) === 'capped')
1091
+ break;
1092
+ }
1093
+ }
1094
+ // Some registries (e.g. .it, .tr) list nameservers as a bare header
1095
+ // ("Nameservers", "Domain Servers") followed by unlabeled lines -
1096
+ // sometimes just a hostname, sometimes "hostname ip.addr" - rather
1097
+ // than a per-line "Name Server:" label. Only meaningful on non-root hops.
1098
+ if (!isRootHop && nameservers.size === 0) {
1099
+ const headerMatch = text.match(/^[ \t*]*(?:Name ?[Ss]ervers|Domain Servers|DNS servers)[ \t.]*:?[ \t]*$/mi);
1100
+ if (headerMatch) {
1101
+ let found = 0;
1102
+ for (const line of text.slice((headerMatch.index ?? 0) + headerMatch[0].length).split('\n')) {
1103
+ const trimmed = line.trim();
1104
+ if (!trimmed) {
1105
+ if (found > 0)
1106
+ break; // blank line after >=1 hostname ends the section
1107
+ continue; // the header line's own line break - not a real gap yet
1108
+ }
1109
+ const hostMatch = trimmed.match(/^([a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})(?:\s|$)/);
1110
+ if (hostMatch) {
1111
+ const result = addBoundedWhoisSetValue(nameservers, hostMatch[1], {
1112
+ maxEntries: MAX_WHOIS_NAMESERVERS, maxLength: 253,
1113
+ field: 'nameservers', truncatedFields,
1114
+ });
1115
+ if (result === 'capped')
1116
+ break;
1117
+ if (result === 'added')
1118
+ found += 1;
1119
+ }
1120
+ else {
1121
+ break;
1122
+ }
1123
+ }
1124
+ }
1125
+ }
1126
+ });
1127
+ // Dedicated legacy-format parsers above populate some fields outside the
1128
+ // generic pattern loop. Apply the same string/control bounds to every
1129
+ // scalar before constructing compatibility and normalized contact views.
1130
+ for (const [key, value] of Object.entries(fields)) {
1131
+ if (typeof value !== 'string')
1132
+ continue;
1133
+ const bounded = boundedWhoisValue(value, whoisFieldLimit(key));
1134
+ if (!bounded.value)
1135
+ delete fields[key];
1136
+ else
1137
+ fields[key] = bounded.value;
1138
+ if (bounded.truncated)
1139
+ truncatedFields.add(key);
1140
+ }
1141
+ const createdDateIso = registryDateIso(fields.createdDate);
1142
+ const expiryDateIso = registryDateIso(fields.expiryDate);
1143
+ const updatedDateIso = registryDateIso(fields.updatedDate);
1144
+ const lifecycle = {
1145
+ createdDate: fields.createdDate || null,
1146
+ expiryDate: fields.expiryDate || null,
1147
+ updatedDate: fields.updatedDate || null,
1148
+ createdDateIso,
1149
+ expiryDateIso,
1150
+ updatedDateIso,
1151
+ };
1152
+ const contactsByRole = {};
1153
+ for (const [prefix, role] of [
1154
+ ['registrant', 'registrant'], ['admin', 'administrative'],
1155
+ ['tech', 'technical'], ['billing', 'billing'],
1156
+ ]) {
1157
+ const contact = normalizedWhoisContact(fields, prefix, role, truncatedFields);
1158
+ if (contact)
1159
+ contactsByRole[role] = [contact];
1160
+ }
1161
+ if (fields.abuseEmail || fields.abusePhone) {
1162
+ contactsByRole.abuse = [{
1163
+ handle: null, roles: ['abuse'], name: null, names: [], org: fields.registrar || null,
1164
+ organizations: fields.registrar ? [fields.registrar] : [],
1165
+ email: fields.abuseEmail || null, emails: fields.abuseEmail ? [fields.abuseEmail] : [],
1166
+ phone: fields.abusePhone || null, phones: fields.abusePhone ? [fields.abusePhone] : [],
1167
+ address: null, addresses: [], publicIds: [], links: [],
1168
+ }];
1169
+ }
1170
+ // Existence is decided authority-aware, not by a global "any hop said no
1171
+ // match" flag: positive registry evidence is never overridden by a later
1172
+ // registrar hop that failed, rate-limited, or returned "no match".
1173
+ const authority = analyzeWhoisChainAuthority(source);
1174
+ return {
1175
+ ...fields,
1176
+ nameservers: [...nameservers],
1177
+ statuses: [...statuses],
1178
+ createdDateIso,
1179
+ expiryDateIso,
1180
+ updatedDateIso,
1181
+ lifecycle,
1182
+ contactsByRole,
1183
+ fieldsTruncated: [...truncatedFields].sort(),
1184
+ ...authority,
1185
+ };
1186
+ }
1187
+ export { buildWhoisChain, parseWhoisChain, analyzeWhoisChainAuthority, whoisQuery, queryWhoisAddress, buildWhoisChainUncached, fetchGtRegistryWhois, };