@slicedearth/whoisleuth-cli 2.5.1 → 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 (170) hide show
  1. package/DISCLOSURE +9 -6
  2. package/NOTICE +3 -2
  3. package/README.md +60 -67
  4. package/cli/artifact-validation/case-response.mjs +6 -814
  5. package/cli/artifact-validation/lookup-evidence.mjs +30 -3
  6. package/cli/artifact-validation/signed-review.mjs +2 -51
  7. package/cli/artifact-verify.mjs +41 -3
  8. package/cli/bulk-command-runner.mjs +4 -6
  9. package/cli/case-command.mjs +92 -16
  10. package/cli/case-pack.mjs +14 -3
  11. package/cli/ci-report.mjs +17 -5
  12. package/cli/command-definition.mjs +7 -6
  13. package/cli/discovery-scan-command-runner.mjs +4 -6
  14. package/cli/doctor.mjs +37 -3
  15. package/cli/domain-feed.mjs +61 -0
  16. package/cli/formatters/html.mjs +3 -0
  17. package/cli/formatters/json.mjs +9 -14
  18. package/cli/formatters/terminal-lookup.mjs +24 -1
  19. package/cli/history-command-definitions.mjs +1 -1
  20. package/cli/indicator-set-command.mjs +1 -2
  21. package/cli/intake-command.mjs +43 -3
  22. package/cli/investigation-artifacts.mjs +2 -3
  23. package/cli/investigation-package-review.mjs +2 -0
  24. package/cli/investigation-plan.mjs +15 -0
  25. package/cli/local-mmdb-review.mjs +27 -4
  26. package/cli/lookup-brief.mjs +11 -5
  27. package/cli/lookup-command-runner.mjs +21 -13
  28. package/cli/offline-evidence-review.mjs +51 -5
  29. package/cli/review-arguments.mjs +3 -2
  30. package/cli/review-command-definitions.mjs +23 -17
  31. package/cli/review-command-runner.mjs +6 -3
  32. package/cli/risk-calibration.mjs +6 -4
  33. package/cli/saved-lookup.mjs +22 -7
  34. package/cli/selected-input-worker.mjs +12 -2
  35. package/cli/source-reliability.mjs +12 -10
  36. package/cli/support-arguments.mjs +1 -1
  37. package/cli/support-command-definitions.mjs +5 -4
  38. package/cli/support-command-runner.mjs +9 -7
  39. package/cli/watchlist-review.mjs +43 -0
  40. package/cli/workflow-arguments.mjs +14 -0
  41. package/cli/workflow-command-definitions.mjs +27 -1
  42. package/cli/workflow-command-runner.mjs +32 -0
  43. package/docs/cli-reference.md +49 -11
  44. package/docs/cli.md +200 -26
  45. package/docs/contextual-reviews.md +38 -5
  46. package/docs/infrastructure-observations.md +135 -0
  47. package/docs/offline-intake-context.md +213 -0
  48. package/lib/credential-form-attribution.mjs +136 -0
  49. package/lib/credential-surface-profile.mjs +6 -2
  50. package/lib/dmarc-discovery.mjs +4 -2
  51. package/lib/dns-delegation-health.mjs +23 -7
  52. package/lib/dns-inheritance-review.mjs +14 -1
  53. package/lib/dns-intelligence.mjs +18 -4
  54. package/lib/domain-posture-analysis.mjs +53 -2
  55. package/lib/domain-posture-parsers.mjs +9 -0
  56. package/lib/domain-posture.mjs +29 -10
  57. package/lib/evidence-export-privacy.mjs +5 -2
  58. package/lib/evidence-export.mjs +10 -4
  59. package/lib/evidence-report.mjs +3 -1
  60. package/lib/favicon.mjs +13 -4
  61. package/lib/generated/cisa-kev-catalog.mjs +17 -4
  62. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  63. package/lib/generated/retire-browser-catalog.mjs +178 -5
  64. package/lib/html-signals.mjs +6 -0
  65. package/lib/idn-confusables.mjs +5 -1
  66. package/lib/lookup-child-profile-contract.mjs +28 -11
  67. package/lib/lookup-collection-receipt.mjs +52 -0
  68. package/lib/lookup-diagnostics.mjs +2 -12
  69. package/lib/lookup-progress.mjs +29 -31
  70. package/lib/lookup-response-contract.mjs +10 -0
  71. package/lib/lookup-response.mjs +15 -14
  72. package/lib/lookup-source-progress.mjs +4 -2
  73. package/lib/lookup.mjs +33 -2
  74. package/lib/operation-budget.mjs +4 -0
  75. package/lib/rdap-capabilities.mjs +43 -9
  76. package/lib/registry-insights.mjs +17 -6
  77. package/lib/service-binding-dns.mjs +19 -2
  78. package/lib/sslbl-certificates.generated.mjs +142 -140
  79. package/lib/static-html-analysis.mjs +32 -4
  80. package/lib/technology-evidence-role.mjs +1 -51
  81. package/lib/tls-intelligence.mjs +30 -5
  82. package/lib/website-enrichment.mjs +39 -23
  83. package/lib/whois-authority.mjs +56 -1
  84. package/lib/whois-chain.mjs +14 -4
  85. package/lib/whois-common-formats.mjs +3 -1
  86. package/lib/whois-contacts.mjs +13 -2
  87. package/lib/whois-contracts.mjs +2 -0
  88. package/lib/whois-normalization.mjs +4 -4
  89. package/package.json +9 -7
  90. package/packages/analysis/bounded-json.mjs +12 -2
  91. package/packages/analysis/risk-scoring.mjs +12 -9
  92. package/packages/cases/case-attachment-model.mjs +36 -1
  93. package/packages/cases/case-checkpoint-receipts.mjs +68 -0
  94. package/packages/cases/case-containment-handoff.mjs +195 -0
  95. package/packages/cases/case-evidence-links.mjs +44 -0
  96. package/packages/cases/case-evidence-model.mjs +51 -7
  97. package/packages/cases/case-migration-model.mjs +9 -1
  98. package/packages/cases/case-object-coverage.mjs +119 -0
  99. package/packages/cases/case-packet-correction.mjs +106 -0
  100. package/packages/cases/case-recheck-model.mjs +35 -5
  101. package/packages/cases/case-record-operations.mjs +87 -9
  102. package/packages/cases/case-record-projection.mjs +43 -5
  103. package/packages/cases/case-record-version-input.mjs +23 -1
  104. package/packages/cases/case-report.mjs +35 -4
  105. package/packages/cases/case-requested-evidence.mjs +1 -1
  106. package/packages/cases/case-response-actions.mjs +57 -1
  107. package/packages/cases/case-response-model.mjs +7 -0
  108. package/packages/cases/case-response-object.mjs +119 -0
  109. package/packages/cases/case-response-outcomes.mjs +81 -14
  110. package/packages/cases/case-response-packet-render.mjs +22 -3
  111. package/packages/cases/case-response-packet-validation.mjs +880 -0
  112. package/packages/cases/case-response-packet-vocabulary.mjs +12 -1
  113. package/packages/cases/case-response-packet.mjs +32 -17
  114. package/packages/cases/case-response-review-inputs.mjs +66 -8
  115. package/packages/cases/case-type-presentation.mjs +10 -0
  116. package/packages/cases/case-workflow-metadata.mjs +1 -17
  117. package/packages/comparison/capture-context.mjs +3 -2
  118. package/packages/contracts/analyst-interchange.mjs +7 -5
  119. package/packages/contracts/analyst-review-state-contract.mjs +1 -1
  120. package/packages/contracts/candidate-watch-review.mjs +54 -0
  121. package/packages/contracts/case-portability.mjs +292 -43
  122. package/packages/contracts/cli-command-semantics.mjs +3 -0
  123. package/packages/contracts/cli-doctor.mjs +4 -0
  124. package/packages/contracts/cli-lookup.mjs +57 -46
  125. package/packages/contracts/context-review.mjs +2 -1
  126. package/packages/contracts/external-observation-interchange.mjs +96 -28
  127. package/packages/contracts/lookup-evidence.mjs +13 -2
  128. package/packages/contracts/message-intake.mjs +7 -1
  129. package/packages/contracts/registry-insights.mjs +6 -0
  130. package/packages/contracts/risk-calibration.mjs +44 -29
  131. package/packages/contracts/schema-lifecycle.mjs +3 -1
  132. package/packages/contracts/web-capture.mjs +1 -1
  133. package/packages/contracts/workspace-portability.mjs +86 -16
  134. package/packages/evidence/collection-quality.mjs +1 -1
  135. package/packages/evidence/credential-form-attribution.mjs +60 -0
  136. package/packages/evidence/dmarc-policy-context.mjs +80 -0
  137. package/packages/evidence/image-regions.mjs +15 -0
  138. package/packages/evidence/lookup-collection-receipt.mjs +140 -0
  139. package/packages/evidence/lookup-collection-review.mjs +74 -0
  140. package/packages/evidence/record-identity.mjs +60 -16
  141. package/packages/evidence/technology-evidence-role.mjs +51 -0
  142. package/packages/interchange/external-findings-import.mjs +40 -21
  143. package/packages/interchange/json-output.mjs +14 -0
  144. package/packages/interchange/managed-indicator-export.mjs +3 -2
  145. package/packages/interchange/managed-indicator-set.mjs +13 -0
  146. package/packages/investigation/acquisition-decision-packet.mjs +78 -0
  147. package/packages/investigation/bulk-mail-exposure.mjs +2 -2
  148. package/packages/investigation/context-review.mjs +3 -3
  149. package/packages/investigation/document-intake.mjs +9 -3
  150. package/packages/investigation/domain-history-review.mjs +50 -12
  151. package/packages/investigation/domain-transition-review.mjs +2 -0
  152. package/packages/investigation/infrastructure-observation.mjs +234 -0
  153. package/packages/investigation/intake-context.mjs +284 -0
  154. package/packages/investigation/intake-indicators.mjs +116 -0
  155. package/packages/investigation/intake-phones.mjs +155 -0
  156. package/packages/investigation/intake-report.mjs +2 -1
  157. package/packages/investigation/investigation-manifest.mjs +33 -7
  158. package/packages/investigation/link-intake.mjs +38 -8
  159. package/packages/investigation/message-intake.mjs +55 -15
  160. package/packages/monitoring/domain-feed.mjs +321 -0
  161. package/packages/workspace/brand-candidate-workflow.mjs +351 -0
  162. package/packages/workspace/brand-keyword-campaign.mjs +241 -0
  163. package/packages/workspace/brand-profile-model.mjs +46 -2
  164. package/packages/workspace/bulk-session-model.mjs +12 -0
  165. package/packages/workspace/candidate-watch-handoff.mjs +195 -0
  166. package/packages/workspace/detection-rule-model.mjs +10 -2
  167. package/packages/workspace/shortlist-model.mjs +10 -2
  168. package/packages/workspace/watchlist-history.mjs +31 -4
  169. package/packages/workspace/watchlist-store.mjs +136 -7
  170. package/third-party-notices.txt +30 -30
@@ -0,0 +1,52 @@
1
+ import { LOOKUP_SOURCE_IDS, LOOKUP_CHOICE_KEYS, LOOKUP_CHOICE_SOURCES, LOOKUP_SOURCE_STATES, LOOKUP_COLLECTION_RECEIPT_VERSION, lookupCollectionChoices, parseLookupCollectionReceipt, } from "../packages/evidence/lookup-collection-receipt.mjs";
2
+ import { plannedLookupSources } from "./lookup-progress.mjs";
3
+ import { normalizeLookupSourceSettlement } from "./lookup-source-progress.mjs";
4
+ /** Operation-local observer. It neither admits nor starts collection. */
5
+ export function createLookupCollectionReceiptTracker(classified, options) {
6
+ const requestedDepth = options.fast ? 'fast' : 'deep';
7
+ const originalChoices = lookupCollectionChoices(options);
8
+ const eligible = new Set(plannedLookupSources(classified.type, requestedDepth, originalChoices));
9
+ const requested = new Set(eligible);
10
+ for (const choice of LOOKUP_CHOICE_KEYS)
11
+ if (originalChoices[choice])
12
+ requested.add(LOOKUP_CHOICE_SOURCES[choice]);
13
+ const sources = new Map(LOOKUP_SOURCE_IDS.map(source => [source, {
14
+ source,
15
+ intent: requested.has(source) ? 'requested' : 'not_requested',
16
+ admission: eligible.has(source) ? 'unknown' : 'not_applicable',
17
+ attempt: 'not_started', settlement: 'unknown', complete: null, truncated: null,
18
+ }]));
19
+ function admission(source, value) {
20
+ const current = sources.get(source);
21
+ if (current.attempt !== 'started')
22
+ sources.set(source, { ...current, admission: value });
23
+ }
24
+ function started(source) {
25
+ sources.set(source, { ...sources.get(source), admission: 'admitted', attempt: 'started' });
26
+ }
27
+ function settled(source, outcome, value) {
28
+ const current = sources.get(source);
29
+ if (current.attempt !== 'started')
30
+ return;
31
+ const summary = normalizeLookupSourceSettlement(source, outcome, value, classified.type === 'domain' ? classified.registrableDomain ?? classified.value : undefined);
32
+ const raw = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
33
+ // Preserve final distinctions that the provisional progress vocabulary folds
34
+ // into skipped. The current source's explicit quality still takes precedence.
35
+ const status = typeof raw.status === 'string'
36
+ && LOOKUP_SOURCE_STATES.includes(raw.status) ? raw.status : null;
37
+ const settlement = outcome === 'fulfilled' && status && ['disabled', 'not_applicable', 'stale'].includes(status)
38
+ ? status : summary.state;
39
+ sources.set(source, { ...current, settlement,
40
+ complete: value == null ? null : ['success', 'complete', 'not_found'].includes(settlement) && summary.complete && !summary.truncated,
41
+ truncated: value == null || outcome === 'rejected' ? null : summary.truncated,
42
+ });
43
+ }
44
+ function finish() {
45
+ return parseLookupCollectionReceipt({
46
+ version: LOOKUP_COLLECTION_RECEIPT_VERSION, requestedDepth, targetClass: classified.type,
47
+ webSelection: classified.type !== 'domain' ? 'not_applicable' : options.selectedUrl ? 'selected_url' : 'hostname',
48
+ originalChoices, sources: [...sources.values()],
49
+ });
50
+ }
51
+ return Object.freeze({ admission, started, settled, finish });
52
+ }
@@ -2,22 +2,12 @@
2
2
  import { OPERATION_BUDGET_ERROR_CODE } from "./operation-budget.mjs";
3
3
  import { FEATURE_DISABLED_ERROR_CODE } from "./feature-policy.mjs";
4
4
  import { LOOKUP_SOURCE_STATES, lookupDiagnosticStates } from "./lookup-progress.mjs";
5
+ import { LOOKUP_SOURCE_IDS } from "../packages/evidence/lookup-collection-receipt.mjs";
5
6
  const LOOKUP_DIAGNOSTICS_VERSION = 8;
6
7
  const LOOKUP_LEGACY_DIAGNOSTICS_VERSION = 7;
7
8
  const LOOKUP_TIMING_VERSION = 1;
8
9
  const MAX_LOOKUP_TIMING_MS = 120_000;
9
- const LOOKUP_TIMING_SOURCE_ORDER = Object.freeze([
10
- 'rdap',
11
- 'whois',
12
- 'domain_evidence',
13
- 'reverse_dns',
14
- 'registrar_rdap',
15
- 'network_context',
16
- 'security_txt',
17
- 'external_intelligence',
18
- 'malware_host_intelligence',
19
- 'malware_ioc_intelligence',
20
- ]);
10
+ const LOOKUP_TIMING_SOURCE_ORDER = LOOKUP_SOURCE_IDS;
21
11
  const LOOKUP_ERROR_CODES = Object.freeze({
22
12
  AUTH_REQUIRED: 'AUTH_REQUIRED',
23
13
  RATE_LIMITED: 'RATE_LIMITED',
@@ -2,6 +2,8 @@
2
2
  // presentation-only; only a validated final ordinary Lookup result may cross
3
3
  // the persistence boundary.
4
4
  import { scanBoundedJson, MAX_BOUNDED_JSON_DEPTH, MAX_BOUNDED_JSON_KEYS, MAX_BOUNDED_JSON_VALUES } from "../packages/analysis/bounded-json.mjs";
5
+ import { LOOKUP_SOURCE_IDS as SOURCE_IDS, LOOKUP_SOURCE_STATES } from "../packages/evidence/lookup-collection-receipt.mjs";
6
+ export { LOOKUP_SOURCE_STATES } from "../packages/evidence/lookup-collection-receipt.mjs";
5
7
  export const LOOKUP_PROGRESS_SCHEMA = 'whoisleuth.lookup-progress';
6
8
  export const LOOKUP_PROGRESS_VERSION = 1;
7
9
  export const MAX_LOOKUP_PROGRESS_SOURCES = 16;
@@ -9,18 +11,6 @@ export const MAX_LOOKUP_PROGRESS_EVENTS = MAX_LOOKUP_PROGRESS_SOURCES + 2;
9
11
  export const MAX_LOOKUP_PROGRESS_FRAGMENT_BYTES = 256 * 1024;
10
12
  export const MAX_LOOKUP_PROGRESS_FINAL_BYTES = 8 * 1024 * 1024;
11
13
  export const MAX_LOOKUP_PROGRESS_STREAM_BYTES = 12 * 1024 * 1024;
12
- const SOURCE_IDS = Object.freeze([
13
- 'rdap',
14
- 'whois',
15
- 'domain_evidence',
16
- 'reverse_dns',
17
- 'registrar_rdap',
18
- 'network_context',
19
- 'security_txt',
20
- 'external_intelligence',
21
- 'malware_host_intelligence',
22
- 'malware_ioc_intelligence',
23
- ]);
24
14
  const SOURCE_ID_SET = new Set(SOURCE_IDS);
25
15
  const SOURCE_STATES = new Set([
26
16
  'success',
@@ -33,28 +23,36 @@ const SOURCE_STATES = new Set([
33
23
  'rate_limited',
34
24
  ]);
35
25
  // Final diagnostic states are distinct from the provisional progress subset.
36
- export const LOOKUP_SOURCE_STATES = Object.freeze([
37
- 'success', 'partial', 'not_found', 'skipped', 'error', 'unsupported',
38
- 'not_applicable', 'unavailable', 'rate_limited', 'complete', 'disabled', 'stale',
39
- ]);
40
26
  const FINAL_SOURCE_STATES = new Set(LOOKUP_SOURCE_STATES);
41
- export function lookupDiagnosticStates(value) {
27
+ const DIAGNOSTIC_SOURCE_PATHS = Object.freeze({
28
+ rdap: ['rdap'], registrar_rdap: ['rdap', 'registrar'], whois: ['whois'],
29
+ availability: ['availability'], reverse_dns: ['reverseDns'], network_context: ['network'],
30
+ security_txt: ['securityTxt'], sslbl: ['sslbl'],
31
+ });
32
+ export function lookupDiagnosticEntries(value) {
42
33
  const record = (input) => input && typeof input === 'object' && !Array.isArray(input)
43
34
  ? input : {};
44
- const diagnostics = record(value);
45
- const candidates = {
46
- rdap: record(diagnostics.rdap).status,
47
- registrar_rdap: record(record(diagnostics.rdap).registrar).status,
48
- whois: record(diagnostics.whois).status,
49
- availability: record(diagnostics.availability).status,
50
- reverse_dns: record(diagnostics.reverseDns).status,
51
- network_context: record(diagnostics.network).status,
52
- security_txt: record(diagnostics.securityTxt).status,
53
- sslbl: record(diagnostics.sslbl).status,
54
- };
55
- return Object.freeze(Object.fromEntries(Object.entries(candidates).map(([source, state]) => [
56
- source, typeof state === 'string' && FINAL_SOURCE_STATES.has(state) ? state : null,
57
- ])));
35
+ return Object.freeze(Object.entries(DIAGNOSTIC_SOURCE_PATHS).map(([source, keys]) => {
36
+ let current = value;
37
+ let present = true;
38
+ for (const key of keys) {
39
+ const parent = record(current);
40
+ if (!Object.hasOwn(parent, key)) {
41
+ present = false;
42
+ break;
43
+ }
44
+ current = parent[key];
45
+ }
46
+ const diagnostic = record(current);
47
+ const state = present && Object.hasOwn(diagnostic, 'status') ? diagnostic.status : undefined;
48
+ return Object.freeze({
49
+ source, path: `diagnostics.${keys.join('.')}.status`, present,
50
+ state: typeof state === 'string' && FINAL_SOURCE_STATES.has(state) ? state : null,
51
+ });
52
+ }));
53
+ }
54
+ export function lookupDiagnosticStates(value) {
55
+ return Object.freeze(Object.fromEntries(lookupDiagnosticEntries(value).map(({ source, state }) => [source, state])));
58
56
  }
59
57
  const SOURCE_ID_RE = /^[a-z][a-z0-9_]{0,39}$/u;
60
58
  /** The existing Lookup recipe, shared by offline plans and source progress.
@@ -13,6 +13,7 @@ import { assertBoundedJsonStructure } from "../packages/analysis/bounded-json.mj
13
13
  import { canonicalRegistrableDomain } from "../packages/analysis/registrable-domain.mjs";
14
14
  import { validLookupObservationScope } from "../packages/evidence/lookup-target.mjs";
15
15
  import { isWebCollectionQuality } from "../packages/evidence/collection-quality.mjs";
16
+ import { parseLookupCollectionReceipt } from "../packages/evidence/lookup-collection-receipt.mjs";
16
17
  import { MAX_LOOKUP_DNS_RECORDS_PER_TYPE, MAX_LOOKUP_REVERSE_DNS_PTR_RECORDS, MAX_LOOKUP_TLS_ALT_NAMES, MAX_LOOKUP_TLS_CERTIFICATE_POLICIES, MAX_LOOKUP_TLS_CHAIN_CERTIFICATES, MAX_LOOKUP_TLS_FINDINGS, MAX_LOOKUP_TLS_NAME_VALUES, } from "./lookup-network-evidence-bounds.mjs";
17
18
  import { MAX_SECURITY_POSTURE_FINDINGS, sanitizeLookupChildProfiles, validSecurityPostureFinding, validTlsChainCertificate, validTlsFinding, } from "./lookup-child-profile-contract.mjs";
18
19
  import { validHttpDeliveryMetadata, validPagePublicationMetadata, } from "./homepage-metadata-contract.mjs";
@@ -646,6 +647,7 @@ function validAvailabilityScalars(value) {
646
647
  && (value.hasNullMx === undefined || value.hasNullMx === null || typeof value.hasNullMx === 'boolean')
647
648
  && (value.hasSpf === undefined || value.hasSpf === null || typeof value.hasSpf === 'boolean')
648
649
  && (value.hasDmarc === undefined || value.hasDmarc === null || typeof value.hasDmarc === 'boolean')
650
+ && (value.hasExternalPasswordForm === undefined || value.hasExternalPasswordForm === null || typeof value.hasExternalPasswordForm === 'boolean')
649
651
  && validOptionalStringArray(value.mxHosts, MAX_LOOKUP_DNS_RECORDS_PER_TYPE, 253);
650
652
  }
651
653
  function validLookupDomainIdentity(value) {
@@ -824,6 +826,14 @@ function parseLookupHttpResponse(value) {
824
826
  return invalidLookupResponse();
825
827
  }
826
828
  const lookupResponse = sanitizeLookupChildProfiles(value);
829
+ try {
830
+ const receipt = parseLookupCollectionReceipt(lookupResponse.collectionReceipt);
831
+ if (receipt && receipt.targetClass !== lookupResponse.type)
832
+ return invalidLookupResponse();
833
+ }
834
+ catch {
835
+ return invalidLookupResponse();
836
+ }
827
837
  if (lookupResponse.registrarStanding !== undefined
828
838
  && (!validRegistrarStanding(lookupResponse.registrarStanding)
829
839
  || lookupResponse.registrarStanding.ianaId !== resolveRegistrarIanaId(lookupResponse.rdap.parsed, lookupResponse.whois.parsed)))
@@ -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)
@@ -34,6 +34,7 @@ const OPERATION_FEATURES = Object.freeze({
34
34
  AVAILABILITY_FAST: 'availability_fast',
35
35
  AVAILABILITY_DEEP: 'availability_deep',
36
36
  CERTIFICATE_TRANSPARENCY: 'certificate_transparency',
37
+ DOMAIN_FEED_SEARCH: 'domain_feed_search',
37
38
  DOMAIN_POSTURE: 'domain_posture',
38
39
  });
39
40
  const OPERATION_FEATURE_CLASSES = Object.freeze({
@@ -47,6 +48,7 @@ const OPERATION_FEATURE_CLASSES = Object.freeze({
47
48
  [OPERATION_FEATURES.AVAILABILITY_FAST]: OPERATION_CLASSES.REGISTRY_LIGHT,
48
49
  [OPERATION_FEATURES.AVAILABILITY_DEEP]: OPERATION_CLASSES.REGISTRY_DEEP,
49
50
  [OPERATION_FEATURES.CERTIFICATE_TRANSPARENCY]: OPERATION_CLASSES.CERTIFICATE_SEARCH,
51
+ [OPERATION_FEATURES.DOMAIN_FEED_SEARCH]: OPERATION_CLASSES.CERTIFICATE_SEARCH,
50
52
  [OPERATION_FEATURES.DOMAIN_POSTURE]: OPERATION_CLASSES.POSTURE_AUDIT,
51
53
  });
52
54
  // The browser currently runs at most 12 fast Bulk workers, 4 deep Bulk
@@ -87,6 +89,8 @@ function operationFeatureFor(feature, { fast = false, compact = false } = {}) {
87
89
  return OPERATION_FEATURES.WHOIS;
88
90
  if (feature === 'certificate_transparency')
89
91
  return OPERATION_FEATURES.CERTIFICATE_TRANSPARENCY;
92
+ if (feature === 'domain_feed_search')
93
+ return OPERATION_FEATURES.DOMAIN_FEED_SEARCH;
90
94
  if (feature === 'domain_posture')
91
95
  return OPERATION_FEATURES.DOMAIN_POSTURE;
92
96
  return null;
@@ -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
  };
@@ -526,6 +526,8 @@ function defaultTcpExchange(query, resolver, options) {
526
526
  }
527
527
  });
528
528
  socket.once('connect', () => {
529
+ if (settled)
530
+ return;
529
531
  const length = Buffer.alloc(2);
530
532
  length.writeUInt16BE(query.length, 0);
531
533
  socket.write(Buffer.concat([length, query]));
@@ -534,6 +536,7 @@ function defaultTcpExchange(query, resolver, options) {
534
536
  }
535
537
  function defaultDnsExchange(query, resolver, options) {
536
538
  return new Promise((resolve, reject) => {
539
+ options.signal?.throwIfAborted();
537
540
  const socket = createSocket(resolver.family === 6 ? 'udp6' : 'udp4');
538
541
  let settled = false;
539
542
  const timer = setTimeout(() => {
@@ -544,6 +547,7 @@ function defaultDnsExchange(query, resolver, options) {
544
547
  return;
545
548
  settled = true;
546
549
  clearTimeout(timer);
550
+ options.signal?.removeEventListener('abort', cancelled);
547
551
  try {
548
552
  socket.close();
549
553
  }
@@ -555,6 +559,12 @@ function defaultDnsExchange(query, resolver, options) {
555
559
  else
556
560
  resolve(response);
557
561
  };
562
+ const cancelled = () => finish(options.signal?.reason ?? new Error('DNS UDP query cancelled'));
563
+ options.signal?.addEventListener('abort', cancelled, { once: true });
564
+ if (options.signal?.aborted) {
565
+ cancelled();
566
+ return;
567
+ }
558
568
  socket.once('error', (error) => finish(error));
559
569
  socket.once('message', (message) => {
560
570
  if (message.length > MAX_DNS_MESSAGE_BYTES) {
@@ -565,6 +575,8 @@ function defaultDnsExchange(query, resolver, options) {
565
575
  }
566
576
  });
567
577
  socket.connect(resolver.port, resolver.address, () => {
578
+ if (settled)
579
+ return;
568
580
  socket.send(query, (error) => {
569
581
  if (error)
570
582
  finish(error);
@@ -592,6 +604,7 @@ function missingResponseError(rcode) {
592
604
  return new ServiceBindingDnsError(`DNS resolver returned ${names[rcode] || `rcode ${rcode}`}`, 'EDNSFAIL');
593
605
  }
594
606
  async function resolveServiceBindingRecords(nameValue, type, options = {}) {
607
+ options.signal?.throwIfAborted();
595
608
  const name = normalizeQueryName(nameValue);
596
609
  const transactionId = options.transactionId ?? randomInt(0x1_0000);
597
610
  const query = buildServiceBindingDnsQuery(name, type, transactionId);
@@ -608,17 +621,20 @@ async function resolveServiceBindingRecords(nameValue, type, options = {}) {
608
621
  const deadline = Date.now() + timeoutMs;
609
622
  let lastError = null;
610
623
  for (const resolver of resolvers) {
624
+ options.signal?.throwIfAborted();
611
625
  const udpTimeoutMs = deadline - Date.now();
612
626
  if (udpTimeoutMs <= 0)
613
627
  break;
614
628
  try {
615
- let response = await exchange(query, resolver, { timeoutMs: udpTimeoutMs });
629
+ let response = await exchange(query, resolver, { timeoutMs: udpTimeoutMs, ...(options.signal ? { signal: options.signal } : {}) });
630
+ options.signal?.throwIfAborted();
616
631
  if (isTruncatedDnsResponse(response, transactionId)) {
617
632
  const tcpTimeoutMs = deadline - Date.now();
618
633
  if (tcpTimeoutMs <= 0) {
619
634
  throw new ServiceBindingDnsError('DNS service-binding query timed out', 'ETIMEOUT');
620
635
  }
621
- response = await (options.tcpExchange || defaultTcpExchange)(query, resolver, { timeoutMs: tcpTimeoutMs });
636
+ response = await (options.tcpExchange || defaultTcpExchange)(query, resolver, { timeoutMs: tcpTimeoutMs, ...(options.signal ? { signal: options.signal } : {}) });
637
+ options.signal?.throwIfAborted();
622
638
  }
623
639
  const parsed = parseServiceBindingDnsResponse(response, { transactionId, name, type });
624
640
  if (parsed.truncatedResponse)
@@ -631,6 +647,7 @@ async function resolveServiceBindingRecords(nameValue, type, options = {}) {
631
647
  return { records: parsed.records, truncated: parsed.truncated };
632
648
  }
633
649
  catch (error) {
650
+ options.signal?.throwIfAborted();
634
651
  lastError = error;
635
652
  const code = error && typeof error === 'object' ? String(error.code || '') : '';
636
653
  if (code === 'ENODATA' || code === 'ENOTFOUND')