@slicedearth/whoisleuth-cli 2.0.1 → 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 (95) 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 +16 -9
  37. package/docs/cli.md +4 -2
  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/registrar-standing-catalogue-contract.mjs +70 -0
  59. package/lib/registrar-standing-contract.mjs +300 -0
  60. package/lib/registrar-standing.mjs +269 -0
  61. package/lib/registry-insights.mjs +2 -8
  62. package/lib/risk-scoring.mjs +26 -10
  63. package/lib/sslbl-certificates.generated.mjs +138 -136
  64. package/lib/structured-data-identity.mjs +1 -3
  65. package/lib/technology-evidence-role.mjs +51 -0
  66. package/lib/threat-intelligence-runtime.mjs +3 -3
  67. package/lib/threat-intelligence-types.mjs +13 -0
  68. package/lib/threatfox-intelligence.mjs +1 -3
  69. package/lib/tls-intelligence.mjs +1 -2
  70. package/lib/urlhaus-intelligence.mjs +1 -3
  71. package/lib/urlscan-intelligence.mjs +1 -3
  72. package/lib/website-security-posture.mjs +1 -2
  73. package/lib/website-technology.mjs +72 -23
  74. package/lib/whois-common-formats.mjs +276 -0
  75. package/lib/whois-dialects.mjs +534 -0
  76. package/lib/whois-parser-support.mjs +305 -0
  77. package/lib/whois-parser.mjs +135 -0
  78. package/lib/whois.mjs +6 -1187
  79. package/package.json +2 -2
  80. package/packages/cases/case-evidence-model.mjs +9 -0
  81. package/packages/cases/case-migration-model.mjs +17 -11
  82. package/packages/cases/case-response-model.mjs +13 -9
  83. package/packages/cases/case-response-packet.mjs +297 -112
  84. package/packages/comparison/page-similarity.mjs +23 -0
  85. package/packages/contracts/case-portability.mjs +225 -61
  86. package/packages/contracts/cli-lookup.mjs +4 -3
  87. package/packages/contracts/domain-control-manifest.mjs +1 -1
  88. package/packages/contracts/investigation-portability.mjs +1 -1
  89. package/packages/contracts/risk-calibration.mjs +2 -2
  90. package/packages/contracts/workspace-portability.mjs +34 -6
  91. package/packages/investigation/evidence-coverage-ledger.mjs +21 -0
  92. package/packages/investigation/lookup-claim-readiness.mjs +2 -2
  93. package/packages/workspace/website-snapshot-model.mjs +79 -6
  94. package/third-party-notices.txt +1 -1
  95. 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 };