@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.
- package/cli/arguments.mjs +21 -5
- package/cli/artifact-validation/case-response.mjs +771 -0
- package/cli/artifact-validation/investigation-capsule.mjs +426 -0
- package/cli/artifact-validation/lookup-evidence.mjs +718 -0
- package/cli/artifact-validation/signed-review.mjs +598 -0
- package/cli/artifact-validation/structure-primitives.mjs +105 -0
- package/cli/artifact-verify.mjs +1 -1
- package/cli/assurance-command-runner.mjs +324 -0
- package/cli/command-reference.mjs +1175 -843
- package/cli/completion.mjs +56 -9
- package/cli/discriminated-command-handlers.mjs +11 -0
- package/cli/doctor.mjs +2 -2
- package/cli/evidence-command-runner.mjs +41 -31
- package/cli/export-evidence.mjs +1 -1
- package/cli/fail-policy.mjs +33 -8
- package/cli/formatters/terminal-command-formats.mjs +437 -0
- package/cli/formatters/terminal-lookup.mjs +758 -0
- package/cli/formatters/terminal-metadata.mjs +58 -0
- package/cli/formatters/terminal-shared.mjs +83 -0
- package/cli/formatters/terminal.mjs +5 -1232
- package/cli/history-command-runner.mjs +132 -0
- package/cli/inline-command-families.mjs +45 -0
- package/cli/inline-command-runner.mjs +55 -0
- package/cli/investigation-plan.mjs +9 -2
- package/cli/investigation-run.mjs +114 -9
- package/cli/lookup-brief.mjs +141 -23
- package/cli/lookup-browser-view.mjs +289 -0
- package/cli/lookup-browser.mjs +3 -288
- package/cli/mail-review.mjs +2 -6
- package/cli/offline-artifact-validation.mjs +19 -0
- package/cli/review-command-runner.mjs +269 -0
- package/cli/runner.mjs +15 -972
- package/cli/saved-lookup.mjs +19 -2
- package/cli/support-command-runner.mjs +229 -0
- package/cli/workflow-command-runner.mjs +129 -0
- package/docs/cli-reference.md +17 -10
- package/docs/cli.md +5 -3
- package/lib/availability.mjs +12 -9
- package/lib/bounded-contract-normalizers.mjs +2 -9
- package/lib/browser-library-profile.mjs +1 -2
- package/lib/client-behavior-profile.mjs +1 -2
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/domain-posture-analysis.mjs +10 -1
- package/lib/domain-posture.mjs +51 -23
- package/lib/evidence-export.mjs +46 -5
- package/lib/evidence-report-markdown.mjs +4 -0
- package/lib/evidence-report.mjs +50 -0
- package/lib/generated/cisa-kev-catalog.mjs +34 -4
- package/lib/generated/registrar-standing-catalogue.mjs +367 -0
- package/lib/json-record.mjs +12 -0
- package/lib/lookup-child-profile-contract.mjs +858 -0
- package/lib/lookup-contract-primitives.mjs +98 -0
- package/lib/lookup-diagnostics.mjs +96 -0
- package/lib/lookup-network-evidence-bounds.mjs +1 -0
- package/lib/lookup-response.mjs +367 -0
- package/lib/lookup.mjs +28 -443
- package/lib/page-role-profile.mjs +1 -3
- package/lib/project-metadata.mjs +1 -1
- package/lib/registrar-standing-catalogue-contract.mjs +70 -0
- package/lib/registrar-standing-contract.mjs +300 -0
- package/lib/registrar-standing.mjs +269 -0
- package/lib/registry-insights.mjs +2 -8
- package/lib/risk-scoring.mjs +26 -10
- package/lib/sslbl-certificates.generated.mjs +138 -136
- package/lib/structured-data-identity.mjs +1 -3
- package/lib/technology-evidence-role.mjs +51 -0
- package/lib/threat-intelligence-runtime.mjs +3 -3
- package/lib/threat-intelligence-types.mjs +13 -0
- package/lib/threatfox-intelligence.mjs +1 -3
- package/lib/tls-intelligence.mjs +1 -2
- package/lib/urlhaus-intelligence.mjs +1 -3
- package/lib/urlscan-intelligence.mjs +1 -3
- package/lib/website-security-posture.mjs +1 -2
- package/lib/website-technology.mjs +72 -23
- package/lib/whois-common-formats.mjs +276 -0
- package/lib/whois-dialects.mjs +534 -0
- package/lib/whois-parser-support.mjs +305 -0
- package/lib/whois-parser.mjs +135 -0
- package/lib/whois.mjs +6 -1187
- package/package.json +3 -3
- package/packages/cases/case-evidence-model.mjs +9 -0
- package/packages/cases/case-migration-model.mjs +17 -11
- package/packages/cases/case-response-model.mjs +13 -9
- package/packages/cases/case-response-packet.mjs +297 -112
- package/packages/comparison/page-similarity.mjs +23 -0
- package/packages/contracts/case-portability.mjs +227 -63
- package/packages/contracts/cli-lookup.mjs +4 -3
- package/packages/contracts/domain-control-manifest.mjs +1 -1
- package/packages/contracts/investigation-portability.mjs +1 -1
- package/packages/contracts/risk-calibration.mjs +2 -2
- package/packages/contracts/workspace-portability.mjs +34 -6
- package/packages/investigation/evidence-coverage-ledger.mjs +21 -0
- package/packages/investigation/lookup-claim-readiness.mjs +2 -2
- package/packages/workspace/brand-profile-model.mjs +1 -1
- package/packages/workspace/website-snapshot-model.mjs +79 -6
- package/third-party-notices.txt +1 -1
- 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 };
|