@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,276 @@
|
|
|
1
|
+
// Common contact, bracketed-record, nameserver and status formats applied
|
|
2
|
+
// after generic fields and marker-gated registry dialects.
|
|
3
|
+
import { parseIndentedContactBlock, resolveFredContact, } from "./whois-contacts.mjs";
|
|
4
|
+
import { MAX_WHOIS_NAMESERVERS, MAX_WHOIS_STATUSES, addBoundedWhoisSetValue, } from "./whois-parser-support.mjs";
|
|
5
|
+
import { boundedWhoisValue } from "./whois-values.mjs";
|
|
6
|
+
function applyWhoisCommonFormats(text, isRootHop, dialects, context) {
|
|
7
|
+
const { expandedStreetFields, fields, nameservers, statuses, truncatedFields, } = context;
|
|
8
|
+
const { isAtiTn, isDnsBelgium, isEif, isHkirc, isIsocIl, isNicKz, isNicMd, isPunktum, isRnids, isTwnic, } = dialects;
|
|
9
|
+
if (!isRootHop) {
|
|
10
|
+
for (const [prefix, label] of [
|
|
11
|
+
['registrant', 'Registrant'], ['admin', 'Admin(?:istrative)?'],
|
|
12
|
+
['tech', 'Tech(?:nical)?'], ['billing', 'Billing'],
|
|
13
|
+
]) {
|
|
14
|
+
const key = `${prefix}Street`;
|
|
15
|
+
if (expandedStreetFields.has(key))
|
|
16
|
+
continue;
|
|
17
|
+
const streetRe = new RegExp(`^[ \\t*]*${label} (?:Contact )?Street[ \\t.]*:[ \\t]*(.+)$`, 'gim');
|
|
18
|
+
const lines = [];
|
|
19
|
+
for (const match of text.matchAll(streetRe)) {
|
|
20
|
+
const bounded = boundedWhoisValue(match[1], 300);
|
|
21
|
+
if (bounded.truncated)
|
|
22
|
+
truncatedFields.add(key);
|
|
23
|
+
if (!bounded.value || lines.includes(bounded.value))
|
|
24
|
+
continue;
|
|
25
|
+
if (lines.length >= 4) {
|
|
26
|
+
truncatedFields.add(key);
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
lines.push(bounded.value);
|
|
30
|
+
}
|
|
31
|
+
if (lines.length)
|
|
32
|
+
fields[key] = lines.join(', ');
|
|
33
|
+
expandedStreetFields.add(key);
|
|
34
|
+
}
|
|
35
|
+
// The documented .nz protocol numbers up to two address lines using
|
|
36
|
+
// underscore field names. Aggregate only those exact fields, keeping
|
|
37
|
+
// the same four-line/300-character bounds as repeated ICANN-style
|
|
38
|
+
// street fields and leaving city/province/postcode separately typed.
|
|
39
|
+
for (const [prefix, nzPrefix] of [
|
|
40
|
+
['registrant', 'registrant'], ['admin', 'admin'], ['tech', 'technical'],
|
|
41
|
+
]) {
|
|
42
|
+
const key = `${prefix}Street`;
|
|
43
|
+
if (fields[key])
|
|
44
|
+
continue;
|
|
45
|
+
const addressRe = new RegExp(`^[ \\t]*${nzPrefix}_contact_address(?:1|2)[ \\t]*:[ \\t]*(.+)$`, 'gim');
|
|
46
|
+
const lines = [];
|
|
47
|
+
for (const match of text.matchAll(addressRe)) {
|
|
48
|
+
const bounded = boundedWhoisValue(match[1], 300);
|
|
49
|
+
if (bounded.truncated)
|
|
50
|
+
truncatedFields.add(key);
|
|
51
|
+
if (!bounded.value || lines.includes(bounded.value))
|
|
52
|
+
continue;
|
|
53
|
+
if (lines.length >= 4) {
|
|
54
|
+
truncatedFields.add(key);
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
lines.push(bounded.value);
|
|
58
|
+
}
|
|
59
|
+
if (lines.length)
|
|
60
|
+
fields[key] = lines.join(', ');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// If registrantName looks like it's actually a handle (a matching
|
|
64
|
+
// "contact: <handle>" block exists in this same hop), resolve it for
|
|
65
|
+
// the real name/org/email/phone/address. Harmless no-op otherwise -
|
|
66
|
+
// registries where "Registrant:" is already the real name (e.g. .kr)
|
|
67
|
+
// simply won't have a matching contact block to find.
|
|
68
|
+
if (!isRootHop && fields.registrantName) {
|
|
69
|
+
const resolved = resolveFredContact(text, fields.registrantName);
|
|
70
|
+
if (resolved) {
|
|
71
|
+
if (resolved.name)
|
|
72
|
+
fields.registrantName = resolved.name;
|
|
73
|
+
if (resolved.org && !fields.registrantOrg)
|
|
74
|
+
fields.registrantOrg = resolved.org;
|
|
75
|
+
if (resolved.email && !fields.registrantEmail)
|
|
76
|
+
fields.registrantEmail = resolved.email;
|
|
77
|
+
if (resolved.phone && !fields.registrantPhone)
|
|
78
|
+
fields.registrantPhone = resolved.phone;
|
|
79
|
+
if (resolved.address && !fields.registrantAddress)
|
|
80
|
+
fields.registrantAddress = resolved.address;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// EDUCAUSE (.edu) and similar legacy registries list registrant/admin/
|
|
84
|
+
// technical contacts as indented blocks rather than "Field: value"
|
|
85
|
+
// pairs - see parseIndentedContactBlock. The plain "Registrant:" header
|
|
86
|
+
// has no separate person name on .edu (registrants are institutions),
|
|
87
|
+
// so its block's first line maps to the org, not a name.
|
|
88
|
+
if (!isRootHop && !fields.registrantOrg && !fields.registrantName) {
|
|
89
|
+
const block = parseIndentedContactBlock(text, /^[ \t]*Registrant:[ \t]*$/im);
|
|
90
|
+
if (block) {
|
|
91
|
+
if (block.name)
|
|
92
|
+
fields.registrantOrg = block.name;
|
|
93
|
+
if (block.address)
|
|
94
|
+
fields.registrantAddress = block.address;
|
|
95
|
+
if (block.phone)
|
|
96
|
+
fields.registrantPhone = block.phone;
|
|
97
|
+
if (block.email)
|
|
98
|
+
fields.registrantEmail = block.email;
|
|
99
|
+
if (block.truncated)
|
|
100
|
+
truncatedFields.add('registrantAddress');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (!isRootHop && !fields.adminName) {
|
|
104
|
+
const block = parseIndentedContactBlock(text, /^[ \t]*Administrative Contact:[ \t]*$/im);
|
|
105
|
+
if (block) {
|
|
106
|
+
if (block.name)
|
|
107
|
+
fields.adminName = block.name;
|
|
108
|
+
if (block.address)
|
|
109
|
+
fields.adminAddress = block.address;
|
|
110
|
+
if (block.phone)
|
|
111
|
+
fields.adminPhone = block.phone;
|
|
112
|
+
if (block.email)
|
|
113
|
+
fields.adminEmail = block.email;
|
|
114
|
+
if (block.truncated)
|
|
115
|
+
truncatedFields.add('adminAddress');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (!isRootHop && !fields.techName) {
|
|
119
|
+
const block = parseIndentedContactBlock(text, /^[ \t]*Technical Contact:[ \t]*$/im);
|
|
120
|
+
if (block) {
|
|
121
|
+
if (block.name)
|
|
122
|
+
fields.techName = block.name;
|
|
123
|
+
if (block.address)
|
|
124
|
+
fields.techAddress = block.address;
|
|
125
|
+
if (block.phone)
|
|
126
|
+
fields.techPhone = block.phone;
|
|
127
|
+
if (block.email)
|
|
128
|
+
fields.techEmail = block.email;
|
|
129
|
+
if (block.truncated)
|
|
130
|
+
truncatedFields.add('techAddress');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!isRootHop && !fields.billingName) {
|
|
134
|
+
const block = parseIndentedContactBlock(text, /^[ \t]*Billing Contact:[ \t]*$/im);
|
|
135
|
+
if (block) {
|
|
136
|
+
if (block.name)
|
|
137
|
+
fields.billingName = block.name;
|
|
138
|
+
if (block.address)
|
|
139
|
+
fields.billingAddress = block.address;
|
|
140
|
+
if (block.phone)
|
|
141
|
+
fields.billingPhone = block.phone;
|
|
142
|
+
if (block.email)
|
|
143
|
+
fields.billingEmail = block.email;
|
|
144
|
+
if (block.truncated)
|
|
145
|
+
truncatedFields.add('billingAddress');
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// .jp (JPRS) uses a bracketed dual-language format instead of
|
|
149
|
+
// "Label: value" - e.g. "[Domain Name] GOO.JP", with Japanese-only
|
|
150
|
+
// labels alongside English ones for the same field. The bracket syntax
|
|
151
|
+
// is distinctive enough to not need root-hop gating.
|
|
152
|
+
if (!fields.domainName) {
|
|
153
|
+
const m = text.match(/\[Domain Name\][ \t]*(.+)/i);
|
|
154
|
+
if (m?.[1])
|
|
155
|
+
fields.domainName = m[1].trim();
|
|
156
|
+
}
|
|
157
|
+
if (!fields.registrantName) {
|
|
158
|
+
const m = text.match(/\[Registrant\][ \t]*(.+)/i);
|
|
159
|
+
if (m?.[1])
|
|
160
|
+
fields.registrantName = m[1].trim();
|
|
161
|
+
}
|
|
162
|
+
if (!fields.createdDate) {
|
|
163
|
+
const m = text.match(/\[登録年月日\][ \t]*(.+)/);
|
|
164
|
+
if (m?.[1])
|
|
165
|
+
fields.createdDate = m[1].trim();
|
|
166
|
+
}
|
|
167
|
+
if (!fields.expiryDate) {
|
|
168
|
+
const m = text.match(/\[有効期限\][ \t]*(.+)/);
|
|
169
|
+
if (m?.[1])
|
|
170
|
+
fields.expiryDate = m[1].trim();
|
|
171
|
+
}
|
|
172
|
+
for (const m of text.matchAll(/\[状態\][ \t]*(.+)/g)) {
|
|
173
|
+
if (addBoundedWhoisSetValue(statuses, m[1], {
|
|
174
|
+
maxEntries: MAX_WHOIS_STATUSES, maxLength: 160,
|
|
175
|
+
field: 'statuses', truncatedFields,
|
|
176
|
+
}) === 'capped')
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
for (const m of text.matchAll(/\[Name Server\][ \t]*([a-zA-Z0-9.\-]+)/gi)) {
|
|
180
|
+
if (addBoundedWhoisSetValue(nameservers, m[1], {
|
|
181
|
+
maxEntries: MAX_WHOIS_NAMESERVERS, maxLength: 253,
|
|
182
|
+
field: 'nameservers', truncatedFields,
|
|
183
|
+
}) === 'capped')
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
// "Name Server:" never collides with the IANA root hop (which uses
|
|
187
|
+
// lowercase "nserver:" for the TLD's own root nameservers) so it's safe
|
|
188
|
+
// on every hop; "nserver:"/"Host Name:"/"DNS:" are real per-domain
|
|
189
|
+
// labels on some registries (e.g. .ru, .kr, .mx) but only once we're
|
|
190
|
+
// past the root hop, for the same reason as the field patterns above.
|
|
191
|
+
const nsLinePatterns = [/^[ \t*]*Name Server[ \t.]*:[ \t]*([a-zA-Z0-9.\-]+)/gim];
|
|
192
|
+
if (!isRootHop) {
|
|
193
|
+
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);
|
|
194
|
+
// Punktum dk uses `DNS: example.dk` for the queried domain, then
|
|
195
|
+
// `Hostname:` inside its nameserver section. Other supported
|
|
196
|
+
// registries use `DNS:` for an actual nameserver, so switch aliases
|
|
197
|
+
// only when the full .dk marker set is present.
|
|
198
|
+
nsLinePatterns.push(isPunktum
|
|
199
|
+
? /^[ \t*]*Hostname[ \t.]*:[ \t]*([a-zA-Z0-9.\-]+)/gim
|
|
200
|
+
: /^[ \t*]*DNS[ \t.]*:[ \t]*([a-zA-Z0-9.\-]+)/gim);
|
|
201
|
+
}
|
|
202
|
+
for (const re of nsLinePatterns) {
|
|
203
|
+
for (const m of text.matchAll(re)) {
|
|
204
|
+
if (addBoundedWhoisSetValue(nameservers, m[1], {
|
|
205
|
+
maxEntries: MAX_WHOIS_NAMESERVERS, maxLength: 253,
|
|
206
|
+
field: 'nameservers', truncatedFields,
|
|
207
|
+
}) === 'capped')
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// Same root-hop exclusion as above: bare "status:" is also how IANA
|
|
212
|
+
// reports the TLD's own delegation status (e.g. "status: ACTIVE" for
|
|
213
|
+
// .gt itself), not the queried domain's status. Deliberately not adding
|
|
214
|
+
// "state:" as an alternate here (some registries, e.g. .ru/.se, use it
|
|
215
|
+
// for domain status) - it's also the standard label for a postal
|
|
216
|
+
// address's state/province in Name/City/State/Country contact blocks
|
|
217
|
+
// (seen on .mx), and there's no reliable way to tell those apart from
|
|
218
|
+
// the label alone - a missing status is safer than a wrong one.
|
|
219
|
+
const statusRe = isRootHop
|
|
220
|
+
? /^[ \t*]*Domain Status[ \t.]*:[ \t]*([a-zA-Z][a-zA-Z0-9_-]*)/gim
|
|
221
|
+
: (isDnsBelgium || isPunktum || isIsocIl || isTwnic || isEif
|
|
222
|
+
|| isHkirc || isNicKz || isNicMd || isAtiTn)
|
|
223
|
+
? /^[ \t*]*(?:Domain Status|Status)[ \t.]*:[ \t]*([^\r\n]+)/gim
|
|
224
|
+
: /^[ \t*]*(?:Domain Status|Status)[ \t.]*:[ \t]*([a-zA-Z][a-zA-Z0-9_-]*)/gim;
|
|
225
|
+
if (!isRnids) {
|
|
226
|
+
for (const m of text.matchAll(statusRe)) {
|
|
227
|
+
if (addBoundedWhoisSetValue(statuses, m[1], {
|
|
228
|
+
maxEntries: MAX_WHOIS_STATUSES, maxLength: 160,
|
|
229
|
+
field: 'statuses', truncatedFields,
|
|
230
|
+
}) === 'capped')
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (!isRootHop) {
|
|
235
|
+
for (const m of text.matchAll(/^[ \t]*query_status[ \t]*:[ \t]*(\d{3}(?:[ \t]+[^\r\n]+)?)/gim)) {
|
|
236
|
+
if (addBoundedWhoisSetValue(statuses, m[1], {
|
|
237
|
+
maxEntries: MAX_WHOIS_STATUSES, maxLength: 160,
|
|
238
|
+
field: 'statuses', truncatedFields,
|
|
239
|
+
}) === 'capped')
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// Some registries (e.g. .it, .tr) list nameservers as a bare header
|
|
244
|
+
// ("Nameservers", "Domain Servers") followed by unlabeled lines -
|
|
245
|
+
// sometimes just a hostname, sometimes "hostname ip.addr" - rather
|
|
246
|
+
// than a per-line "Name Server:" label. Only meaningful on non-root hops.
|
|
247
|
+
if (!isRootHop && nameservers.size === 0) {
|
|
248
|
+
const headerMatch = text.match(/^[ \t*]*(?:Name ?[Ss]ervers|Domain Servers|DNS servers)[ \t.]*:?[ \t]*$/mi);
|
|
249
|
+
if (headerMatch) {
|
|
250
|
+
let found = 0;
|
|
251
|
+
for (const line of text.slice((headerMatch.index ?? 0) + headerMatch[0].length).split('\n')) {
|
|
252
|
+
const trimmed = line.trim();
|
|
253
|
+
if (!trimmed) {
|
|
254
|
+
if (found > 0)
|
|
255
|
+
break; // blank line after >=1 hostname ends the section
|
|
256
|
+
continue; // the header line's own line break - not a real gap yet
|
|
257
|
+
}
|
|
258
|
+
const hostMatch = trimmed.match(/^([a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})(?:\s|$)/);
|
|
259
|
+
if (hostMatch) {
|
|
260
|
+
const result = addBoundedWhoisSetValue(nameservers, hostMatch[1], {
|
|
261
|
+
maxEntries: MAX_WHOIS_NAMESERVERS, maxLength: 253,
|
|
262
|
+
field: 'nameservers', truncatedFields,
|
|
263
|
+
});
|
|
264
|
+
if (result === 'capped')
|
|
265
|
+
break;
|
|
266
|
+
if (result === 'added')
|
|
267
|
+
found += 1;
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
export { applyWhoisCommonFormats };
|