@slicedearth/whoisleuth-cli 2.0.0 → 2.1.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 (97) hide show
  1. package/cli/arguments.mjs +21 -5
  2. package/cli/artifact-validation/case-response.mjs +771 -0
  3. package/cli/artifact-validation/investigation-capsule.mjs +426 -0
  4. package/cli/artifact-validation/lookup-evidence.mjs +718 -0
  5. package/cli/artifact-validation/signed-review.mjs +598 -0
  6. package/cli/artifact-validation/structure-primitives.mjs +105 -0
  7. package/cli/artifact-verify.mjs +1 -1
  8. package/cli/assurance-command-runner.mjs +324 -0
  9. package/cli/command-reference.mjs +1175 -843
  10. package/cli/completion.mjs +56 -9
  11. package/cli/discriminated-command-handlers.mjs +11 -0
  12. package/cli/doctor.mjs +2 -2
  13. package/cli/evidence-command-runner.mjs +41 -31
  14. package/cli/export-evidence.mjs +1 -1
  15. package/cli/fail-policy.mjs +33 -8
  16. package/cli/formatters/terminal-command-formats.mjs +437 -0
  17. package/cli/formatters/terminal-lookup.mjs +758 -0
  18. package/cli/formatters/terminal-metadata.mjs +58 -0
  19. package/cli/formatters/terminal-shared.mjs +83 -0
  20. package/cli/formatters/terminal.mjs +5 -1232
  21. package/cli/history-command-runner.mjs +132 -0
  22. package/cli/inline-command-families.mjs +45 -0
  23. package/cli/inline-command-runner.mjs +55 -0
  24. package/cli/investigation-plan.mjs +9 -2
  25. package/cli/investigation-run.mjs +114 -9
  26. package/cli/lookup-brief.mjs +141 -23
  27. package/cli/lookup-browser-view.mjs +289 -0
  28. package/cli/lookup-browser.mjs +3 -288
  29. package/cli/mail-review.mjs +2 -6
  30. package/cli/offline-artifact-validation.mjs +19 -0
  31. package/cli/review-command-runner.mjs +269 -0
  32. package/cli/runner.mjs +15 -972
  33. package/cli/saved-lookup.mjs +19 -2
  34. package/cli/support-command-runner.mjs +229 -0
  35. package/cli/workflow-command-runner.mjs +129 -0
  36. package/docs/cli-reference.md +17 -10
  37. package/docs/cli.md +5 -3
  38. package/lib/availability.mjs +12 -9
  39. package/lib/bounded-contract-normalizers.mjs +2 -9
  40. package/lib/browser-library-profile.mjs +1 -2
  41. package/lib/client-behavior-profile.mjs +1 -2
  42. package/lib/credential-surface-profile.mjs +1 -1
  43. package/lib/domain-posture-analysis.mjs +10 -1
  44. package/lib/domain-posture.mjs +51 -23
  45. package/lib/evidence-export.mjs +46 -5
  46. package/lib/evidence-report-markdown.mjs +4 -0
  47. package/lib/evidence-report.mjs +50 -0
  48. package/lib/generated/cisa-kev-catalog.mjs +34 -4
  49. package/lib/generated/registrar-standing-catalogue.mjs +367 -0
  50. package/lib/json-record.mjs +12 -0
  51. package/lib/lookup-child-profile-contract.mjs +858 -0
  52. package/lib/lookup-contract-primitives.mjs +98 -0
  53. package/lib/lookup-diagnostics.mjs +96 -0
  54. package/lib/lookup-network-evidence-bounds.mjs +1 -0
  55. package/lib/lookup-response.mjs +367 -0
  56. package/lib/lookup.mjs +28 -443
  57. package/lib/page-role-profile.mjs +1 -3
  58. package/lib/project-metadata.mjs +1 -1
  59. package/lib/registrar-standing-catalogue-contract.mjs +70 -0
  60. package/lib/registrar-standing-contract.mjs +300 -0
  61. package/lib/registrar-standing.mjs +269 -0
  62. package/lib/registry-insights.mjs +2 -8
  63. package/lib/risk-scoring.mjs +26 -10
  64. package/lib/sslbl-certificates.generated.mjs +138 -136
  65. package/lib/structured-data-identity.mjs +1 -3
  66. package/lib/technology-evidence-role.mjs +51 -0
  67. package/lib/threat-intelligence-runtime.mjs +3 -3
  68. package/lib/threat-intelligence-types.mjs +13 -0
  69. package/lib/threatfox-intelligence.mjs +1 -3
  70. package/lib/tls-intelligence.mjs +1 -2
  71. package/lib/urlhaus-intelligence.mjs +1 -3
  72. package/lib/urlscan-intelligence.mjs +1 -3
  73. package/lib/website-security-posture.mjs +1 -2
  74. package/lib/website-technology.mjs +72 -23
  75. package/lib/whois-common-formats.mjs +276 -0
  76. package/lib/whois-dialects.mjs +534 -0
  77. package/lib/whois-parser-support.mjs +305 -0
  78. package/lib/whois-parser.mjs +135 -0
  79. package/lib/whois.mjs +6 -1187
  80. package/package.json +3 -3
  81. package/packages/cases/case-evidence-model.mjs +9 -0
  82. package/packages/cases/case-migration-model.mjs +17 -11
  83. package/packages/cases/case-response-model.mjs +13 -9
  84. package/packages/cases/case-response-packet.mjs +297 -112
  85. package/packages/comparison/page-similarity.mjs +23 -0
  86. package/packages/contracts/case-portability.mjs +227 -63
  87. package/packages/contracts/cli-lookup.mjs +4 -3
  88. package/packages/contracts/domain-control-manifest.mjs +1 -1
  89. package/packages/contracts/investigation-portability.mjs +1 -1
  90. package/packages/contracts/risk-calibration.mjs +2 -2
  91. package/packages/contracts/workspace-portability.mjs +34 -6
  92. package/packages/investigation/evidence-coverage-ledger.mjs +21 -0
  93. package/packages/investigation/lookup-claim-readiness.mjs +2 -2
  94. package/packages/workspace/brand-profile-model.mjs +1 -1
  95. package/packages/workspace/website-snapshot-model.mjs +79 -6
  96. package/third-party-notices.txt +1 -1
  97. package/cli/artifact-structure.mjs +0 -2507
@@ -0,0 +1,305 @@
1
+ // Bounded primitives and generic field patterns shared by the WHOIS parser.
2
+ import { boundedWhoisValue, whoisFieldLimit, } from "./whois-values.mjs";
3
+ const MAX_WHOIS_NAMESERVERS = 200;
4
+ const MAX_WHOIS_STATUSES = 100;
5
+ 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;
6
+ // ---------------------------------------------------------------------------
7
+ // WHOIS response parsing (merges the referral chain into readable fields)
8
+ // ---------------------------------------------------------------------------
9
+ function assignBoundedWhoisMatch(text, fields, key, pattern, truncatedFields) {
10
+ if (fields[key])
11
+ return;
12
+ const match = text.match(pattern);
13
+ if (!match)
14
+ return;
15
+ const bounded = boundedWhoisValue(match[1], whoisFieldLimit(key));
16
+ if (!bounded.value)
17
+ return;
18
+ fields[key] = bounded.value;
19
+ if (bounded.truncated)
20
+ truncatedFields.add(key);
21
+ }
22
+ function addBoundedWhoisSetValue(set, rawValue, { maxEntries, maxLength, field, truncatedFields, }) {
23
+ const bounded = boundedWhoisValue(rawValue, maxLength);
24
+ if (!bounded.value || set.has(bounded.value))
25
+ return 'ignored';
26
+ if (set.size >= maxEntries) {
27
+ truncatedFields.add(field);
28
+ return 'capped';
29
+ }
30
+ set.add(bounded.value);
31
+ return 'added';
32
+ }
33
+ function collectBareWhoisNameservers(text, headerRe, nameservers, truncatedFields) {
34
+ const headerMatch = text.match(headerRe);
35
+ if (!headerMatch)
36
+ return;
37
+ const lines = text.slice((headerMatch.index ?? 0) + headerMatch[0].length)
38
+ .split('\n', MAX_WHOIS_NAMESERVERS + 3);
39
+ let found = 0;
40
+ for (const line of lines) {
41
+ const trimmed = line.trim();
42
+ if (!trimmed) {
43
+ if (found)
44
+ break;
45
+ continue;
46
+ }
47
+ const hostMatch = trimmed.match(/^([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})\.?(?:\s|$)/);
48
+ if (!hostMatch)
49
+ break;
50
+ const hostname = hostMatch[1];
51
+ if (!hostname)
52
+ break;
53
+ const result = addBoundedWhoisSetValue(nameservers, hostname.replace(/\.$/, ''), {
54
+ maxEntries: MAX_WHOIS_NAMESERVERS,
55
+ maxLength: 253,
56
+ field: 'nameservers',
57
+ truncatedFields,
58
+ });
59
+ if (result === 'capped')
60
+ break;
61
+ if (result === 'added')
62
+ found += 1;
63
+ }
64
+ }
65
+ function normalizedWhoisContact(fields, prefix, role, truncatedFields) {
66
+ let address = fields[`${prefix}Address`] || [
67
+ fields[`${prefix}Street`], fields[`${prefix}City`], fields[`${prefix}State`],
68
+ fields[`${prefix}PostalCode`], fields[`${prefix}Country`],
69
+ ].filter(Boolean).join(', ') || null;
70
+ if (address && !fields[`${prefix}Address`]) {
71
+ const bounded = boundedWhoisValue(address, 1000);
72
+ address = bounded.value;
73
+ if (address)
74
+ fields[`${prefix}Address`] = address;
75
+ if (bounded.truncated)
76
+ truncatedFields.add(`${prefix}Address`);
77
+ }
78
+ const handle = fields[`${prefix}Id`] || null;
79
+ const name = fields[`${prefix}Name`] || null;
80
+ const org = fields[`${prefix}Org`] || null;
81
+ const email = fields[`${prefix}Email`] || null;
82
+ const phone = fields[`${prefix}Phone`] || null;
83
+ if (![handle, name, org, email, phone, address].some(Boolean))
84
+ return null;
85
+ return {
86
+ handle,
87
+ roles: [role],
88
+ name,
89
+ names: name ? [name] : [],
90
+ org,
91
+ organizations: org ? [org] : [],
92
+ email,
93
+ emails: email ? [email] : [],
94
+ phone,
95
+ phones: phone ? [phone] : [],
96
+ address,
97
+ addresses: address ? [address] : [],
98
+ publicIds: handle ? [{ type: 'Registry contact ID', identifier: handle }] : [],
99
+ links: [],
100
+ };
101
+ }
102
+ // [ \t]* (not \s*) after each colon - same reasoning as extractReferral:
103
+ // several registries list a field with no value (e.g. "Registrant
104
+ // Organization: " followed directly by "Registrant Street: REDACTED"),
105
+ // and \s* would cross that blank line and capture the next field's own
106
+ // label as the value.
107
+ //
108
+ // Each field lists the standard ICANN thick-WHOIS label first, then
109
+ // common alternates seen on registries that predate/ignore that format
110
+ // (e.g. .it uses "Domain:"/"Created:"/"Expire Date:"). First match wins.
111
+ // This is a broad-coverage net, not a claim of full per-registry support -
112
+ // registries with entirely different conventions (e.g. .jp's bracketed
113
+ // dual-language format) still need their own dedicated handling.
114
+ // ^[ \t*]* (not ^\s*) - some registries prefix lines with "**" (e.g. .tr's
115
+ // "** Domain Name:"). [ \t.]* before the colon - some use dot-leaders
116
+ // (.tr's "Created on..........:") or extra spaces before the colon
117
+ // (.kr's "Domain Name :") instead of a colon right after
118
+ // the label.
119
+ const WHOIS_FIELD_PATTERNS = {
120
+ domainName: [
121
+ /^[ \t*]*Domain Name[ \t.]*:[ \t]*(.+)$/im,
122
+ /^[ \t*]*Domain[ \t.]*:[ \t]*(.+)$/im,
123
+ /^[ \t]*domain_name[ \t]*:[ \t]*(.+)$/im,
124
+ ],
125
+ registryDomainId: [/^[ \t*]*Registry Domain ID[ \t.]*:[ \t]*(.+)$/im],
126
+ registrar: [
127
+ /^[ \t*]*Registrar[ \t.]*:[ \t]*(.+)$/im,
128
+ /^[ \t*]*Sponsoring Registrar[ \t.]*:[ \t]*(.+)$/im,
129
+ /^[ \t*]*Registrar Name[ \t.]*:[ \t]*(.+)$/im,
130
+ /^[ \t]*registrar_name[ \t]*:[ \t]*(.+)$/im,
131
+ ],
132
+ registrarUrl: [/^[ \t*]*Registrar URL[ \t.]*:[ \t]*(.+)$/im],
133
+ registrarWhoisServer: [/^[ \t*]*Registrar WHOIS Server[ \t.]*:[ \t]*(.+)$/im],
134
+ registrarIanaId: [/^[ \t*]*Registrar IANA ID[ \t.]*:[ \t]*(.+)$/im],
135
+ reseller: [/^[ \t*]*Reseller[ \t.]*:[ \t]*(.+)$/im],
136
+ createdDate: [
137
+ /^[ \t*]*Creation Date[ \t.]*:[ \t]*(.+)$/im,
138
+ /^[ \t*]*Created(?: On)?[ \t.]*:[ \t]*(.+)$/im,
139
+ /^[ \t*]*Regist(?:ration|ered)(?: Time| Date| On)?[ \t.]*:[ \t]*(.+)$/im,
140
+ /^[ \t*]*Domain record activated[ \t.]*:[ \t]*(.+)$/im,
141
+ /^[ \t]*domain_dateregistered[ \t]*:[ \t]*(.+)$/im,
142
+ /^[ \t]*domain_datecreated[ \t]*:[ \t]*(.+)$/im,
143
+ ],
144
+ expiryDate: [
145
+ /^[ \t*]*Registr(?:y|ar) Expiry Date[ \t.]*:[ \t]*(.+)$/im,
146
+ /^[ \t*]*Registrar Registration Expiration Date[ \t.]*:[ \t]*(.+)$/im,
147
+ /^[ \t*]*Expir(?:y|ation|e)s?(?: Date| On)?[ \t.]*:[ \t]*(.+)$/im,
148
+ /^[ \t*]*Valid Until[ \t.]*:[ \t]*(.+)$/im,
149
+ /^[ \t*]*Domain expires[ \t.]*:[ \t]*(.+)$/im,
150
+ /^[ \t*]*Renewal Date[ \t.]*:[ \t]*(.+)$/im,
151
+ /^[ \t*]*paid-till[ \t.]*:[ \t]*(.+)$/im,
152
+ /^[ \t]*domain_datebilleduntil[ \t]*:[ \t]*(.+)$/im,
153
+ ],
154
+ updatedDate: [
155
+ /^[ \t*]*Updated Date[ \t.]*:[ \t]*(.+)$/im,
156
+ /^[ \t*]*Update Date[ \t.]*:[ \t]*(.+)$/im,
157
+ /^[ \t*]*Last Update(?:d)?(?: Date| On)?[ \t.]*:[ \t]*(.+)$/im,
158
+ /^[ \t*]*Last Modified[ \t.]*:[ \t]*(.+)$/im,
159
+ /^[ \t*]*Modified(?: Date)?[ \t.]*:[ \t]*(.+)$/im,
160
+ /^[ \t*]*Modification Date[ \t.]*:[ \t]*(.+)$/im,
161
+ /^[ \t*]*last-update[ \t.]*:[ \t]*(.+)$/im,
162
+ /^[ \t*]*Changed[ \t.]*:[ \t]*(.+)$/im,
163
+ /^[ \t*]*Domain record last updated[ \t.]*:[ \t]*(.+)$/im,
164
+ /^[ \t]*domain_datelastmodified[ \t]*:[ \t]*(.+)$/im,
165
+ ],
166
+ abuseEmail: [/^[ \t*]*Registrar Abuse Contact Email[ \t.]*:[ \t]*(.+)$/im],
167
+ abusePhone: [/^[ \t*]*Registrar Abuse Contact Phone[ \t.]*:[ \t]*(.+)$/im],
168
+ dnssec: [
169
+ /^[ \t*]*DNSSEC[ \t.]*:[ \t]*(.+)$/im,
170
+ /^[ \t*]*Signed[ \t.]*:[ \t]*(.+)$/im,
171
+ /^[ \t]*domain_signed[ \t]*:[ \t]*(.+)$/im,
172
+ ],
173
+ // auDA (.au) publishes the registrant's eligibility basis (e.g. an ABN/
174
+ // ACN for a company) alongside - and often instead of - a named contact,
175
+ // since .au domain eligibility is tied to a registrable Australian
176
+ // presence rather than an individual.
177
+ eligibilityType: [/^[ \t*]*Eligibility Type[ \t.]*:[ \t]*(.+)$/im],
178
+ eligibilityId: [/^[ \t*]*Eligibility ID[ \t.]*:[ \t]*(.+)$/im],
179
+ // Standard ICANN thick-WHOIS registrant/admin fields - present verbatim
180
+ // on registries that don't redact contact data, and also what the .gt
181
+ // web-lookup fallback below is formatted to produce. Several registries
182
+ // (e.g. .au via auDA) insert an extra "Contact" word - "Registrant
183
+ // Contact Email:", "Tech Contact Name:" - handled below with an optional
184
+ // "(?:Contact )?" group rather than a whole separate pattern, except for
185
+ // registrantName, where priority matters: on .au, the plain "Registrant:"
186
+ // line carries the actual legal entity ("Example Corporation Pty Ltd") while
187
+ // "Registrant Contact Name:" is often just a generic role ("Domain
188
+ // Administrator") - kept as a lower-priority third alternate so the more
189
+ // useful value wins when both are present.
190
+ registrantName: [
191
+ /^[ \t*]*Registrant Name[ \t.]*:[ \t]*(.+)$/im,
192
+ /^[ \t*]*Registrant[ \t.]*:[ \t]*(.+)$/im,
193
+ /^[ \t*]*Registrant Contact Name[ \t.]*:[ \t]*(.+)$/im,
194
+ /^[ \t*]*Owner Name[ \t.]*:[ \t]*(.+)$/im,
195
+ /^[ \t]*registrant_contact_name[ \t]*:[ \t]*(.+)$/im,
196
+ ],
197
+ registrantId: [/^[ \t*]*(?:Registry )?Registrant ID[ \t.]*:[ \t]*(.+)$/im],
198
+ registrantOrg: [/^[ \t*]*Registrant (?:Contact )?Organi[sz]ation[ \t.]*:[ \t]*(.+)$/im],
199
+ registrantEmail: [
200
+ /^[ \t*]*Registrant (?:Contact )?Email[ \t.]*:[ \t]*(.+)$/im,
201
+ /^[ \t]*registrant_contact_email[ \t]*:[ \t]*(.+)$/im,
202
+ ],
203
+ registrantPhone: [
204
+ /^[ \t*]*Registrant (?:Contact )?Phone[ \t.]*:[ \t]*(.+)$/im,
205
+ /^[ \t]*registrant_contact_phone[ \t]*:[ \t]*(.+)$/im,
206
+ ],
207
+ registrantAddress: [
208
+ /^[ \t*]*Registrant (?:Contact )?Address[ \t.]*:[ \t]*(.+)$/im,
209
+ /^[ \t*]*Owner Address[ \t.]*:[ \t]*(.+)$/im,
210
+ ],
211
+ registrantStreet: [/^[ \t*]*Registrant (?:Contact )?Street[ \t.]*:[ \t]*(.+)$/im],
212
+ registrantCity: [
213
+ /^[ \t*]*Registrant (?:Contact )?City[ \t.]*:[ \t]*(.+)$/im,
214
+ /^[ \t]*registrant_contact_city[ \t]*:[ \t]*(.+)$/im,
215
+ ],
216
+ registrantState: [
217
+ /^[ \t*]*Registrant (?:Contact )?State(?:\/Province)?[ \t.]*:[ \t]*(.+)$/im,
218
+ /^[ \t]*registrant_contact_province[ \t]*:[ \t]*(.+)$/im,
219
+ ],
220
+ registrantPostalCode: [
221
+ /^[ \t*]*Registrant (?:Contact )?Postal Code[ \t.]*:[ \t]*(.+)$/im,
222
+ /^[ \t]*registrant_contact_postalcode[ \t]*:[ \t]*(.+)$/im,
223
+ ],
224
+ registrantCountry: [
225
+ /^[ \t*]*Registrant (?:Contact )?Country[ \t.]*:[ \t]*(.+)$/im,
226
+ /^[ \t*]*Owner Country[ \t.]*:[ \t]*(.+)$/im,
227
+ /^[ \t]*registrant_contact_country[ \t]*:[ \t]*(.+)$/im,
228
+ ],
229
+ adminId: [/^[ \t*]*(?:Registry )?Admin(?:istrative)? (?:Contact )?ID[ \t.]*:[ \t]*(.+)$/im],
230
+ adminName: [
231
+ /^[ \t*]*Admin(?:istrative)? (?:Contact )?Name[ \t.]*:[ \t]*(.+)$/im,
232
+ /^[ \t]*admin_contact_name[ \t]*:[ \t]*(.+)$/im,
233
+ ],
234
+ adminOrg: [/^[ \t*]*Admin(?:istrative)? (?:Contact )?Organi[sz]ation[ \t.]*:[ \t]*(.+)$/im],
235
+ adminEmail: [
236
+ /^[ \t*]*Admin(?:istrative)? (?:Contact )?Email[ \t.]*:[ \t]*(.+)$/im,
237
+ /^[ \t]*admin_contact_email[ \t]*:[ \t]*(.+)$/im,
238
+ ],
239
+ adminPhone: [
240
+ /^[ \t*]*Admin(?:istrative)? (?:Contact )?Phone[ \t.]*:[ \t]*(.+)$/im,
241
+ /^[ \t]*admin_contact_phone[ \t]*:[ \t]*(.+)$/im,
242
+ ],
243
+ adminAddress: [/^[ \t*]*Admin(?:istrative)? (?:Contact )?Address[ \t.]*:[ \t]*(.+)$/im],
244
+ adminStreet: [/^[ \t*]*Admin(?:istrative)? (?:Contact )?Street[ \t.]*:[ \t]*(.+)$/im],
245
+ adminCity: [
246
+ /^[ \t*]*Admin(?:istrative)? (?:Contact )?City[ \t.]*:[ \t]*(.+)$/im,
247
+ /^[ \t]*admin_contact_city[ \t]*:[ \t]*(.+)$/im,
248
+ ],
249
+ adminState: [
250
+ /^[ \t*]*Admin(?:istrative)? (?:Contact )?State(?:\/Province)?[ \t.]*:[ \t]*(.+)$/im,
251
+ /^[ \t]*admin_contact_province[ \t]*:[ \t]*(.+)$/im,
252
+ ],
253
+ adminPostalCode: [
254
+ /^[ \t*]*Admin(?:istrative)? (?:Contact )?Postal Code[ \t.]*:[ \t]*(.+)$/im,
255
+ /^[ \t]*admin_contact_postalcode[ \t]*:[ \t]*(.+)$/im,
256
+ ],
257
+ adminCountry: [
258
+ /^[ \t*]*Admin(?:istrative)? (?:Contact )?Country[ \t.]*:[ \t]*(.+)$/im,
259
+ /^[ \t]*admin_contact_country[ \t]*:[ \t]*(.+)$/im,
260
+ ],
261
+ techId: [/^[ \t*]*(?:Registry )?Tech(?:nical)? (?:Contact )?ID[ \t.]*:[ \t]*(.+)$/im],
262
+ techName: [
263
+ /^[ \t*]*Tech(?:nical)? (?:Contact )?Name[ \t.]*:[ \t]*(.+)$/im,
264
+ /^[ \t]*technical_contact_name[ \t]*:[ \t]*(.+)$/im,
265
+ ],
266
+ techOrg: [/^[ \t*]*Tech(?:nical)? (?:Contact )?Organi[sz]ation[ \t.]*:[ \t]*(.+)$/im],
267
+ techEmail: [
268
+ /^[ \t*]*Tech(?:nical)? (?:Contact )?Email[ \t.]*:[ \t]*(.+)$/im,
269
+ /^[ \t]*technical_contact_email[ \t]*:[ \t]*(.+)$/im,
270
+ ],
271
+ techPhone: [
272
+ /^[ \t*]*Tech(?:nical)? (?:Contact )?Phone[ \t.]*:[ \t]*(.+)$/im,
273
+ /^[ \t]*technical_contact_phone[ \t]*:[ \t]*(.+)$/im,
274
+ ],
275
+ techAddress: [/^[ \t*]*Tech(?:nical)? (?:Contact )?Address[ \t.]*:[ \t]*(.+)$/im],
276
+ techStreet: [/^[ \t*]*Tech(?:nical)? (?:Contact )?Street[ \t.]*:[ \t]*(.+)$/im],
277
+ techCity: [
278
+ /^[ \t*]*Tech(?:nical)? (?:Contact )?City[ \t.]*:[ \t]*(.+)$/im,
279
+ /^[ \t]*technical_contact_city[ \t]*:[ \t]*(.+)$/im,
280
+ ],
281
+ techState: [
282
+ /^[ \t*]*Tech(?:nical)? (?:Contact )?State(?:\/Province)?[ \t.]*:[ \t]*(.+)$/im,
283
+ /^[ \t]*technical_contact_province[ \t]*:[ \t]*(.+)$/im,
284
+ ],
285
+ techPostalCode: [
286
+ /^[ \t*]*Tech(?:nical)? (?:Contact )?Postal Code[ \t.]*:[ \t]*(.+)$/im,
287
+ /^[ \t]*technical_contact_postalcode[ \t]*:[ \t]*(.+)$/im,
288
+ ],
289
+ techCountry: [
290
+ /^[ \t*]*Tech(?:nical)? (?:Contact )?Country[ \t.]*:[ \t]*(.+)$/im,
291
+ /^[ \t]*technical_contact_country[ \t]*:[ \t]*(.+)$/im,
292
+ ],
293
+ billingId: [/^[ \t*]*(?:Registry )?Billing (?:Contact )?ID[ \t.]*:[ \t]*(.+)$/im],
294
+ billingName: [/^[ \t*]*Billing (?:Contact )?Name[ \t.]*:[ \t]*(.+)$/im],
295
+ billingOrg: [/^[ \t*]*Billing (?:Contact )?Organi[sz]ation[ \t.]*:[ \t]*(.+)$/im],
296
+ billingEmail: [/^[ \t*]*Billing (?:Contact )?Email[ \t.]*:[ \t]*(.+)$/im],
297
+ billingPhone: [/^[ \t*]*Billing (?:Contact )?Phone[ \t.]*:[ \t]*(.+)$/im],
298
+ billingAddress: [/^[ \t*]*Billing (?:Contact )?Address[ \t.]*:[ \t]*(.+)$/im],
299
+ billingStreet: [/^[ \t*]*Billing (?:Contact )?Street[ \t.]*:[ \t]*(.+)$/im],
300
+ billingCity: [/^[ \t*]*Billing (?:Contact )?City[ \t.]*:[ \t]*(.+)$/im],
301
+ billingState: [/^[ \t*]*Billing (?:Contact )?State(?:\/Province)?[ \t.]*:[ \t]*(.+)$/im],
302
+ billingPostalCode: [/^[ \t*]*Billing (?:Contact )?Postal Code[ \t.]*:[ \t]*(.+)$/im],
303
+ billingCountry: [/^[ \t*]*Billing (?:Contact )?Country[ \t.]*:[ \t]*(.+)$/im],
304
+ };
305
+ export { MAX_WHOIS_NAMESERVERS, MAX_WHOIS_STATUSES, WHOIS_DOMAIN_RE, WHOIS_FIELD_PATTERNS, assignBoundedWhoisMatch, addBoundedWhoisSetValue, collectBareWhoisNameservers, normalizedWhoisContact, };
@@ -0,0 +1,135 @@
1
+ // WHOIS response parsing. Transport, referral-chain and stable public exports
2
+ // remain owned by the facade in whois.mts.
3
+ import { registryDateIso } from "./registry-dates.mjs";
4
+ import { analyzeWhoisChainAuthority } from "./whois-authority.mjs";
5
+ import { normalizeWhoisChain } from "./whois-normalization.mjs";
6
+ import { applyWhoisCommonFormats } from "./whois-common-formats.mjs";
7
+ import { applyWhoisDialects } from "./whois-dialects.mjs";
8
+ import { WHOIS_FIELD_PATTERNS, normalizedWhoisContact, } from "./whois-parser-support.mjs";
9
+ import { boundedWhoisValue, whoisFieldLimit, } from "./whois-values.mjs";
10
+ function parseWhoisChain(chain) {
11
+ const source = normalizeWhoisChain(chain);
12
+ const fields = {};
13
+ const truncatedFields = new Set();
14
+ const expandedStreetFields = new Set();
15
+ const nameservers = new Set();
16
+ const statuses = new Set();
17
+ source.forEach((hop, hopIndex) => {
18
+ const text = hop.response;
19
+ if (!text)
20
+ return;
21
+ // hopIndex 0 is always whois.iana.org, whose "domain:"/"created:"/
22
+ // "changed:" fields describe the TLD's own root delegation record, not
23
+ // the queried domain - e.g. "created: 1992-08-14" for .gt is Guatemala's
24
+ // delegation date, not any individual .gt domain's registration date.
25
+ // The broadened alternate labels (everything past index 0 in each
26
+ // pattern array) are common enough on real registries' per-domain
27
+ // responses that they'd false-match that IANA hop too, so they're only
28
+ // tried against later, registry-level hops.
29
+ const isRootHop = hopIndex === 0;
30
+ for (const [key, res] of Object.entries(WHOIS_FIELD_PATTERNS)) {
31
+ // IANA's root hop describes the TLD and its operator, never a contact
32
+ // for the queried registrable domain. Domain contacts therefore come
33
+ // only from registry/registrar hops.
34
+ if (isRootHop && /^(?:registrant|admin|tech|billing)/.test(key))
35
+ continue;
36
+ if (fields[key])
37
+ continue; // earlier hop already set it - don't let a later, less-authoritative hop overwrite
38
+ const candidates = isRootHop ? res.slice(0, 1) : res;
39
+ for (const re of candidates) {
40
+ const m = text.match(re);
41
+ const matchedValue = m?.[1];
42
+ if (matchedValue) {
43
+ const value = matchedValue.trim();
44
+ // Some WHOIS formats use "Registered: yes/no" as a boolean state,
45
+ // while others use "Registered: <date>" for creation time. Never
46
+ // store the boolean form as a date ("no" previously became a truthy
47
+ // createdDate and could make availability look registered).
48
+ if (key === 'createdDate' && /^(?:yes|no|true|false|available|free)$/i.test(value))
49
+ continue;
50
+ const bounded = boundedWhoisValue(value, whoisFieldLimit(key));
51
+ if (!bounded.value)
52
+ continue;
53
+ fields[key] = bounded.value;
54
+ if (bounded.truncated)
55
+ truncatedFields.add(key);
56
+ break;
57
+ }
58
+ }
59
+ }
60
+ const dialects = applyWhoisDialects(text, isRootHop, {
61
+ expandedStreetFields,
62
+ fields,
63
+ nameservers,
64
+ statuses,
65
+ truncatedFields,
66
+ });
67
+ applyWhoisCommonFormats(text, isRootHop, dialects, {
68
+ expandedStreetFields,
69
+ fields,
70
+ nameservers,
71
+ statuses,
72
+ truncatedFields,
73
+ });
74
+ });
75
+ // Dedicated legacy-format parsers above populate some fields outside the
76
+ // generic pattern loop. Apply the same string/control bounds to every
77
+ // scalar before constructing compatibility and normalized contact views.
78
+ for (const [key, value] of Object.entries(fields)) {
79
+ if (typeof value !== 'string')
80
+ continue;
81
+ const bounded = boundedWhoisValue(value, whoisFieldLimit(key));
82
+ if (!bounded.value)
83
+ delete fields[key];
84
+ else
85
+ fields[key] = bounded.value;
86
+ if (bounded.truncated)
87
+ truncatedFields.add(key);
88
+ }
89
+ const createdDateIso = registryDateIso(fields.createdDate);
90
+ const expiryDateIso = registryDateIso(fields.expiryDate);
91
+ const updatedDateIso = registryDateIso(fields.updatedDate);
92
+ const lifecycle = {
93
+ createdDate: fields.createdDate || null,
94
+ expiryDate: fields.expiryDate || null,
95
+ updatedDate: fields.updatedDate || null,
96
+ createdDateIso,
97
+ expiryDateIso,
98
+ updatedDateIso,
99
+ };
100
+ const contactsByRole = {};
101
+ for (const [prefix, role] of [
102
+ ['registrant', 'registrant'], ['admin', 'administrative'],
103
+ ['tech', 'technical'], ['billing', 'billing'],
104
+ ]) {
105
+ const contact = normalizedWhoisContact(fields, prefix, role, truncatedFields);
106
+ if (contact)
107
+ contactsByRole[role] = [contact];
108
+ }
109
+ if (fields.abuseEmail || fields.abusePhone) {
110
+ contactsByRole.abuse = [{
111
+ handle: null, roles: ['abuse'], name: null, names: [], org: fields.registrar || null,
112
+ organizations: fields.registrar ? [fields.registrar] : [],
113
+ email: fields.abuseEmail || null, emails: fields.abuseEmail ? [fields.abuseEmail] : [],
114
+ phone: fields.abusePhone || null, phones: fields.abusePhone ? [fields.abusePhone] : [],
115
+ address: null, addresses: [], publicIds: [], links: [],
116
+ }];
117
+ }
118
+ // Existence is decided authority-aware, not by a global "any hop said no
119
+ // match" flag: positive registry evidence is never overridden by a later
120
+ // registrar hop that failed, rate-limited, or returned "no match".
121
+ const authority = analyzeWhoisChainAuthority(source);
122
+ return {
123
+ ...fields,
124
+ nameservers: [...nameservers],
125
+ statuses: [...statuses],
126
+ createdDateIso,
127
+ expiryDateIso,
128
+ updatedDateIso,
129
+ lifecycle,
130
+ contactsByRole,
131
+ fieldsTruncated: [...truncatedFields].sort(),
132
+ ...authority,
133
+ };
134
+ }
135
+ export { parseWhoisChain };