@slicedearth/whoisleuth-cli 2.6.0 → 2.7.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 (84) hide show
  1. package/DISCLOSURE +8 -5
  2. package/NOTICE +3 -2
  3. package/README.md +60 -67
  4. package/cli/artifact-validation/case-response.mjs +6 -857
  5. package/cli/artifact-validation/lookup-evidence.mjs +15 -2
  6. package/cli/artifact-verify.mjs +32 -1
  7. package/cli/case-command.mjs +17 -17
  8. package/cli/case-pack.mjs +7 -1
  9. package/cli/ci-report.mjs +17 -5
  10. package/cli/command-definition.mjs +5 -5
  11. package/cli/doctor.mjs +37 -3
  12. package/cli/formatters/html.mjs +3 -0
  13. package/cli/formatters/json.mjs +8 -1
  14. package/cli/formatters/terminal-lookup.mjs +24 -1
  15. package/cli/history-command-definitions.mjs +1 -1
  16. package/cli/investigation-plan.mjs +15 -0
  17. package/cli/lookup-brief.mjs +11 -5
  18. package/cli/lookup-command-runner.mjs +20 -13
  19. package/cli/review-command-definitions.mjs +11 -11
  20. package/cli/saved-lookup.mjs +22 -7
  21. package/cli/source-reliability.mjs +12 -10
  22. package/cli/support-arguments.mjs +1 -1
  23. package/cli/support-command-definitions.mjs +5 -4
  24. package/cli/support-command-runner.mjs +6 -4
  25. package/cli/workflow-command-definitions.mjs +2 -1
  26. package/docs/cli-reference.md +49 -11
  27. package/docs/cli.md +109 -32
  28. package/docs/contextual-reviews.md +10 -0
  29. package/docs/infrastructure-observations.md +9 -0
  30. package/lib/dmarc-discovery.mjs +4 -2
  31. package/lib/dns-inheritance-review.mjs +14 -1
  32. package/lib/evidence-export.mjs +10 -4
  33. package/lib/evidence-report.mjs +2 -1
  34. package/lib/generated/cisa-kev-catalog.mjs +17 -4
  35. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  36. package/lib/generated/retire-browser-catalog.mjs +178 -5
  37. package/lib/idn-confusables.mjs +5 -1
  38. package/lib/lookup-collection-receipt.mjs +52 -0
  39. package/lib/lookup-diagnostics.mjs +2 -12
  40. package/lib/lookup-progress.mjs +29 -31
  41. package/lib/lookup-response-contract.mjs +9 -0
  42. package/lib/lookup-response.mjs +15 -14
  43. package/lib/lookup-source-progress.mjs +4 -2
  44. package/lib/lookup.mjs +33 -2
  45. package/lib/rdap-capabilities.mjs +43 -9
  46. package/lib/registry-insights.mjs +17 -6
  47. package/lib/sslbl-certificates.generated.mjs +143 -142
  48. package/lib/whois-authority.mjs +56 -1
  49. package/lib/whois-chain.mjs +2 -1
  50. package/lib/whois-contracts.mjs +2 -0
  51. package/lib/whois-normalization.mjs +4 -4
  52. package/package.json +6 -6
  53. package/packages/analysis/bounded-json.mjs +12 -2
  54. package/packages/cases/case-attachment-model.mjs +36 -1
  55. package/packages/cases/case-checkpoint-receipts.mjs +68 -0
  56. package/packages/cases/case-evidence-links.mjs +28 -0
  57. package/packages/cases/case-evidence-model.mjs +39 -6
  58. package/packages/cases/case-migration-model.mjs +9 -1
  59. package/packages/cases/case-object-coverage.mjs +106 -20
  60. package/packages/cases/case-record-operations.mjs +13 -0
  61. package/packages/cases/case-record-projection.mjs +28 -2
  62. package/packages/cases/case-report.mjs +5 -4
  63. package/packages/cases/case-response-packet-validation.mjs +880 -0
  64. package/packages/cases/case-response-packet-vocabulary.mjs +12 -1
  65. package/packages/cases/case-response-packet.mjs +2 -15
  66. package/packages/cases/case-response-review-inputs.mjs +18 -1
  67. package/packages/cases/case-type-presentation.mjs +10 -0
  68. package/packages/cases/case-workflow-metadata.mjs +0 -8
  69. package/packages/comparison/capture-context.mjs +3 -2
  70. package/packages/contracts/analyst-review-state-contract.mjs +1 -1
  71. package/packages/contracts/case-portability.mjs +59 -28
  72. package/packages/contracts/cli-doctor.mjs +4 -0
  73. package/packages/contracts/cli-lookup.mjs +57 -46
  74. package/packages/contracts/lookup-evidence.mjs +12 -2
  75. package/packages/contracts/registry-insights.mjs +6 -0
  76. package/packages/evidence/dmarc-policy-context.mjs +80 -0
  77. package/packages/evidence/lookup-collection-receipt.mjs +140 -0
  78. package/packages/evidence/lookup-collection-review.mjs +74 -0
  79. package/packages/interchange/external-findings-import.mjs +8 -4
  80. package/packages/investigation/bulk-mail-exposure.mjs +2 -2
  81. package/packages/investigation/domain-history-review.mjs +7 -1
  82. package/packages/workspace/brand-keyword-campaign.mjs +11 -1
  83. package/packages/workspace/bulk-session-model.mjs +8 -0
  84. package/third-party-notices.txt +29 -29
@@ -76,12 +76,16 @@ async function buildUnifiedLookupResponse(context) {
76
76
  ? { skipped: true, detail: 'RDAP is disabled by deployment policy.' }
77
77
  : rdapRecord
78
78
  ? { ...rdapRecord, ...(registrarRdap ? { registrarRdap } : {}) }
79
- : {
80
- error: rdapResult.status === 'rejected'
81
- ? boundedSourceDetail(rdapResult.reason, 'RDAP lookup failed')
82
- : 'No RDAP registry found for this query via IANA bootstrap',
83
- attempts: rdapAttempts,
84
- };
79
+ : rdapResult.status === 'rejected'
80
+ ? { error: boundedSourceDetail(rdapResult.reason, 'RDAP lookup failed'), attempts: rdapAttempts }
81
+ : { status: 'unsupported', detail: 'No RDAP service is listed for this target in the IANA bootstrap.', attempts: rdapAttempts };
82
+ const whoisStatus = !whoisEnabled
83
+ ? 'disabled'
84
+ : skipWhois
85
+ ? 'skipped'
86
+ : whoisResult.status === 'rejected'
87
+ ? 'error'
88
+ : whoisCollectionStatus(whoisChain, classified.type === 'domain' ? classified.registrableDomain ?? classified.value : undefined);
85
89
  let whois;
86
90
  if (!whoisEnabled) {
87
91
  whois = { skipped: true, detail: 'WHOIS is disabled by deployment policy.' };
@@ -90,7 +94,11 @@ async function buildUnifiedLookupResponse(context) {
90
94
  whois = { skipped: true, detail: 'WHOIS is omitted in fast RDAP-only mode.' };
91
95
  }
92
96
  else if (Array.isArray(whoisChain)) {
93
- whois = { chain: whoisChain, parsed: parseWhoisChain(whoisChain, classified.type === 'domain' ? classified.registrableDomain ?? classified.value : undefined) };
97
+ whois = whoisStatus === 'unsupported' || whoisStatus === 'skipped'
98
+ ? { status: whoisStatus, chain: whoisChain,
99
+ detail: whoisChain[0]?.detail ?? 'IANA did not publish a domain WHOIS referral for this target.' }
100
+ : { chain: whoisChain, parsed: parseWhoisChain(whoisChain, classified.type === 'domain' ? classified.registrableDomain ?? classified.value : undefined),
101
+ ...(whoisStatus === 'error' ? { error: boundedSourceDetail({ message: whoisChain[0]?.error }, 'WHOIS lookup failed') } : {}) };
94
102
  }
95
103
  else {
96
104
  whois = {
@@ -138,13 +146,6 @@ async function buildUnifiedLookupResponse(context) {
138
146
  : !rdapRecord
139
147
  ? 'unsupported'
140
148
  : rdapRecord.upstreamStatus === 404 ? 'not_found' : 'success';
141
- const whoisStatus = !whoisEnabled
142
- ? 'disabled'
143
- : skipWhois
144
- ? 'skipped'
145
- : whoisResult.status === 'rejected'
146
- ? 'error'
147
- : whoisCollectionStatus(whoisChain, classified.type === 'domain' ? classified.registrableDomain ?? classified.value : undefined);
148
149
  const availabilityStatus = classified.type !== 'domain'
149
150
  ? 'not_applicable'
150
151
  : !availabilityEnabled ? 'disabled'
@@ -100,9 +100,11 @@ function normalizeLookupSourceSettlement(source, outcome, value, requestedDomain
100
100
  const limitation = outcome === 'rejected'
101
101
  ? 'This source did not complete. No absence or safety conclusion was inferred.'
102
102
  : state === 'skipped'
103
- ? 'This source was not requested or was not applicable to the target.'
103
+ ? 'This source was not queried: it was not requested, did not apply to the target, or was excluded by an access policy.'
104
104
  : state === 'unsupported'
105
- ? 'This source could not provide authoritative evidence for this target.'
105
+ ? source === 'rdap' || source === 'whois'
106
+ ? 'No service is published through the applicable registry discovery source for this target; this is not a failed lookup or an absence finding.'
107
+ : 'This source could not provide authoritative evidence for this target.'
106
108
  : state === 'partial'
107
109
  ? 'This source returned incomplete evidence; missing fields remain unknown.'
108
110
  : null;
package/lib/lookup.mjs CHANGED
@@ -22,6 +22,7 @@ import { normalizeLookupSourceSettlement, plannedLookupProgressSources, } from "
22
22
  import { buildUnifiedLookupResponse } from "./lookup-response.mjs";
23
23
  import { createLookupTimingTracker } from "./lookup-diagnostics.mjs";
24
24
  import { prepareSelectedLookupUrl } from "../packages/evidence/lookup-target.mjs";
25
+ import { createLookupCollectionReceiptTracker } from "./lookup-collection-receipt.mjs";
25
26
  async function runUnifiedLookup(classified, options = {}) {
26
27
  const fetchRdap = options.fetchRdapRecord || fetchRdapRecord;
27
28
  const fetchRegistrarRdap = options.fetchRegistrarRdapRecord || fetchRegistrarRdapRecord;
@@ -45,18 +46,42 @@ async function runUnifiedLookup(classified, options = {}) {
45
46
  const securityTxtRequested = options.securityTxt === true;
46
47
  const featurePolicy = options.featurePolicy || networkFeaturePolicy();
47
48
  const timing = createLookupTimingTracker(!fast && !compact, options.now || Date.now);
49
+ const receipt = !compact && options.onCollectionReceipt
50
+ ? createLookupCollectionReceiptTracker(classified, options) : null;
48
51
  const startedCollectors = [];
49
52
  const measure = (source, operation) => (timing.measure(source, () => abortable(() => {
53
+ receipt?.started(source);
50
54
  const running = Promise.resolve(operation());
51
55
  if (options.waitForStartedCollectors)
52
56
  startedCollectors.push(running.catch(() => { }));
53
57
  return running;
54
- }, options.signal)));
58
+ }, options.signal)).then(value => {
59
+ receipt?.settled(source, 'fulfilled', value);
60
+ return value;
61
+ }, error => {
62
+ receipt?.settled(source, 'rejected', error);
63
+ throw error;
64
+ }));
55
65
  const rdapEnabled = featureDecision('rdap', featurePolicy).enabled;
56
66
  const whoisEnabled = featureDecision('whois', featurePolicy).enabled;
57
67
  const availabilityEnabled = featureDecision('availability', featurePolicy).enabled;
58
68
  const websiteProbeEnabled = featureDecision('website_probe', featurePolicy).enabled;
59
69
  const dnsIntelligenceEnabled = featureDecision('dns_intelligence', featurePolicy).enabled;
70
+ if (!rdapEnabled) {
71
+ receipt?.admission('rdap', 'disabled');
72
+ receipt?.admission('registrar_rdap', 'disabled');
73
+ receipt?.admission('network_context', 'disabled');
74
+ }
75
+ if (!whoisEnabled)
76
+ receipt?.admission('whois', 'disabled');
77
+ if (!availabilityEnabled) {
78
+ receipt?.admission('domain_evidence', 'disabled');
79
+ receipt?.admission('network_context', 'disabled');
80
+ }
81
+ if (!dnsIntelligenceEnabled)
82
+ receipt?.admission('reverse_dns', 'disabled');
83
+ if (!websiteProbeEnabled)
84
+ receipt?.admission('security_txt', 'disabled');
60
85
  const skipWhois = fast || !whoisEnabled;
61
86
  const selectedUrl = options.selectedUrl === undefined ? undefined
62
87
  : prepareSelectedLookupUrl(options.selectedUrl, classified.inputHostname ?? classified.value);
@@ -72,6 +97,8 @@ async function runUnifiedLookup(classified, options = {}) {
72
97
  // Registrar RDAP is a separately attributed deep-lookup enrichment. It may
73
98
  // overlap the WHOIS chain, but it never joins the promises used to decide
74
99
  // availability and can add up to its own bounded timeout to a deep lookup.
100
+ if (classified.type === 'domain' && rdapEnabled && !fast && !compact)
101
+ receipt?.admission('registrar_rdap', 'prerequisite_unavailable');
75
102
  const registrarRdapPromise = classified.type === 'domain' && rdapEnabled && !fast && !compact
76
103
  ? rdapPromise.then((record) => record && record.upstreamStatus === 200 && record.parsed
77
104
  ? measure('registrar_rdap', () => fetchRegistrarRdap(classified.value, record))
@@ -187,7 +214,7 @@ async function runUnifiedLookup(classified, options = {}) {
187
214
  }
188
215
  }
189
216
  try {
190
- return await buildUnifiedLookupResponse({
217
+ const result = await buildUnifiedLookupResponse({
191
218
  availabilityEnabled,
192
219
  availabilityPromise,
193
220
  classified,
@@ -211,6 +238,10 @@ async function runUnifiedLookup(classified, options = {}) {
211
238
  whoisEnabled,
212
239
  whoisPromise,
213
240
  });
241
+ options.signal?.throwIfAborted();
242
+ if (receipt)
243
+ options.onCollectionReceipt?.(receipt.finish());
244
+ return result;
214
245
  }
215
246
  finally {
216
247
  if (options.waitForStartedCollectors)
@@ -3,14 +3,13 @@
3
3
  // search and does not treat an absent extension declaration as proof that a
4
4
  // server cannot support the related operation.
5
5
  export const RDAP_CAPABILITY_INSPECTION_VERSION = 1;
6
- export const RDAP_EXTENSION_CATALOG_REVIEWED_AT = '2026-07-31';
6
+ export const RDAP_EXTENSION_CATALOG_REVIEWED_AT = '2026-10-14';
7
7
  export const MAX_RDAP_CAPABILITY_DECLARATIONS = 50;
8
8
  export const MAX_RDAP_UNKNOWN_CAPABILITIES = 20;
9
9
  const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
10
- // Reviewed against the protocol registry on 2026-07-31. The registry-drift
11
- // audit owns freshness checks; runtime lookup behavior never depends on this
12
- // catalogue being exhaustive.
13
- const EXTENSION_CATALOG = Object.freeze({
10
+ // Published interpretation snapshot. Saved evidence selects this exact revision;
11
+ // later catalogue revisions extend or replace entries without changing it.
12
+ const JULY_2026_EXTENSION_CATALOG = Object.freeze({
14
13
  rdap_level_0: {
15
14
  label: 'Core RDAP response',
16
15
  category: 'core',
@@ -164,8 +163,39 @@ const EXTENSION_CATALOG = Object.freeze({
164
163
  capability: 'DNS time-to-live values in RDAP responses.',
165
164
  },
166
165
  });
166
+ const RDAP_EXTENSION_CATALOGS = Object.freeze({
167
+ '2026-07-31': JULY_2026_EXTENSION_CATALOG,
168
+ '2026-10-14': Object.freeze({
169
+ ...JULY_2026_EXTENSION_CATALOG,
170
+ reliabilityassessment: {
171
+ label: 'Reliability assessment metadata',
172
+ category: 'operator',
173
+ capability: 'Operator-declared assessment metadata; no assessment is retrieved or verified.',
174
+ },
175
+ }),
176
+ });
177
+ const CATALOG_EPOCHS = Object.keys(RDAP_EXTENSION_CATALOGS).sort();
178
+ export function resolveRdapCapabilityCatalogRevision(value) {
179
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/u.test(value)
180
+ || value < CATALOG_EPOCHS[0] || value > RDAP_EXTENSION_CATALOG_REVIEWED_AT)
181
+ return null;
182
+ const time = Date.parse(`${value}T00:00:00.000Z`);
183
+ if (!Number.isFinite(time) || new Date(time).toISOString().slice(0, 10) !== value)
184
+ return null;
185
+ // Review-only renewals retain the same semantic epoch and the recorded date.
186
+ for (let index = CATALOG_EPOCHS.length - 1; index >= 0; index -= 1) {
187
+ const epoch = CATALOG_EPOCHS[index];
188
+ if (epoch <= value)
189
+ return epoch;
190
+ }
191
+ return null;
192
+ }
167
193
  export function reviewedRdapExtensionCatalog() {
168
- return Object.entries(EXTENSION_CATALOG)
194
+ const revision = resolveRdapCapabilityCatalogRevision(RDAP_EXTENSION_CATALOG_REVIEWED_AT);
195
+ if (revision === null)
196
+ throw new TypeError('Unsupported RDAP capability catalogue revision.');
197
+ const catalog = RDAP_EXTENSION_CATALOGS[revision];
198
+ return Object.entries(catalog)
169
199
  .filter(([, entry]) => entry.category !== 'core')
170
200
  .map(([identifier, entry]) => Object.freeze({
171
201
  identifier,
@@ -232,7 +262,11 @@ function reverseSearchCapability(state, declared) {
232
262
  ],
233
263
  };
234
264
  }
235
- export function inspectRdapCapabilities(parsedValue, status) {
265
+ export function inspectRdapCapabilities(parsedValue, status, catalogReviewedAt = RDAP_EXTENSION_CATALOG_REVIEWED_AT) {
266
+ const revision = resolveRdapCapabilityCatalogRevision(catalogReviewedAt);
267
+ if (revision === null)
268
+ throw new TypeError('Unsupported RDAP capability catalogue revision.');
269
+ const catalog = RDAP_EXTENSION_CATALOGS[revision];
236
270
  const parsed = record(parsedValue);
237
271
  const state = sourceState(parsed, status);
238
272
  const rawDeclarations = Array.isArray(parsed?.conformance) ? parsed.conformance : [];
@@ -245,7 +279,7 @@ export function inspectRdapCapabilities(parsedValue, status) {
245
279
  break;
246
280
  }
247
281
  const declarations = [...unique].sort().map((value) => {
248
- const entry = Object.hasOwn(EXTENSION_CATALOG, value) ? EXTENSION_CATALOG[value] : undefined;
282
+ const entry = Object.hasOwn(catalog, value) ? catalog[value] : undefined;
249
283
  return entry
250
284
  ? {
251
285
  identifier: value,
@@ -281,7 +315,7 @@ export function inspectRdapCapabilities(parsedValue, status) {
281
315
  }
282
316
  return {
283
317
  version: RDAP_CAPABILITY_INSPECTION_VERSION,
284
- catalogReviewedAt: RDAP_EXTENSION_CATALOG_REVIEWED_AT,
318
+ catalogReviewedAt,
285
319
  state,
286
320
  declarations,
287
321
  unknownIdentifiers,
@@ -9,7 +9,8 @@ import { inspectRdapCapabilities } from "./rdap-capabilities.mjs";
9
9
  import { isRecord, recordOrEmpty } from "./json-record.mjs";
10
10
  import { MAX_RDAP_REDACTIONS, MAX_RDAP_STATUSES } from "./rdap-types.mjs";
11
11
  import { MAX_WHOIS_STATUSES } from "./whois-contracts.mjs";
12
- export const REGISTRY_INSIGHTS_VERSION = 1;
12
+ import { LEGACY_REGISTRY_INSIGHTS_VERSION, REGISTRY_INSIGHTS_VERSION, isSupportedRegistryInsightsVersion, } from "../packages/contracts/registry-insights.mjs";
13
+ export { REGISTRY_INSIGHTS_VERSION } from "../packages/contracts/registry-insights.mjs";
13
14
  export const MAX_REGISTRY_INSIGHT_STATUSES = 40;
14
15
  export const MAX_REGISTRY_INSIGHT_ISSUES = 12;
15
16
  export const MAX_REGISTRY_ABUSE_ROUTES = 8;
@@ -288,7 +289,10 @@ function contactRoute(kind, contact, source) {
288
289
  ],
289
290
  }));
290
291
  }
291
- export function buildRegistryInsights(input) {
292
+ export function buildRegistryInsights(input, options = {}) {
293
+ const version = options.version ?? REGISTRY_INSIGHTS_VERSION;
294
+ if (!isSupportedRegistryInsightsVersion(version))
295
+ throw new TypeError('Unsupported registry insight version.');
292
296
  const rdap = record(input.rdapParsed);
293
297
  const whois = record(input.whoisParsed);
294
298
  const registrarRdap = record(input.registrarRdapParsed);
@@ -322,7 +326,14 @@ export function buildRegistryInsights(input) {
322
326
  ];
323
327
  const lifecycleCandidates = [
324
328
  { statuses: rdapStatuses.values, state: publications[0].state },
325
- { statuses: whoisStatuses.values, state: publications[1].state },
329
+ {
330
+ statuses: whoisStatuses.values,
331
+ // A completed response describes collection, not positive registration.
332
+ // Preserve the old derivation only when verifying a historical export.
333
+ state: version === LEGACY_REGISTRY_INSIGHTS_VERSION || whois.registrationStatus === 'registered'
334
+ ? publications[1].state
335
+ : 'unavailable',
336
+ },
326
337
  ].filter((candidate) => candidate.state !== 'unavailable');
327
338
  const lifecycleSource = lifecycleCandidates.find((candidate) => candidate.statuses.length)
328
339
  ?? lifecycleCandidates[0]
@@ -343,7 +354,7 @@ export function buildRegistryInsights(input) {
343
354
  break;
344
355
  }
345
356
  return {
346
- version: REGISTRY_INSIGHTS_VERSION,
357
+ version,
347
358
  contactDisclosure: {
348
359
  registryRdap: disclosure('registry_rdap', input.rdapParsed, input.rdapStatus),
349
360
  whois: disclosure('whois', input.whoisParsed, input.whoisStatus),
@@ -369,8 +380,8 @@ export function buildRegistryInsights(input) {
369
380
  },
370
381
  publications,
371
382
  rdapCapabilities: {
372
- registry: inspectRdapCapabilities(input.rdapParsed, input.rdapStatus),
373
- registrar: inspectRdapCapabilities(input.registrarRdapParsed, input.registrarRdapStatus),
383
+ registry: inspectRdapCapabilities(input.rdapParsed, input.rdapStatus, options.rdapCapabilityCatalogReviewedAt),
384
+ registrar: inspectRdapCapabilities(input.registrarRdapParsed, input.registrarRdapStatus, options.rdapCapabilityCatalogReviewedAt),
374
385
  },
375
386
  abuseRouting: [...uniqueRoutes.values()],
376
387
  };