@slicedearth/whoisleuth-cli 2.3.0 → 2.4.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 (264) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +19 -15
  3. package/bin/whoisleuth.mjs +8 -1
  4. package/cli/archive-content-digest.mjs +14 -0
  5. package/cli/archive-inspect.mjs +20 -53
  6. package/cli/arguments.mjs +68 -6
  7. package/cli/artifact-validation/case-response.mjs +47 -41
  8. package/cli/artifact-validation/investigation-capsule.mjs +52 -31
  9. package/cli/artifact-validation/lookup-evidence.mjs +12 -6
  10. package/cli/artifact-validation/signed-review.mjs +83 -61
  11. package/cli/artifact-validation/structure-primitives.mjs +1 -105
  12. package/cli/artifact-verify.mjs +90 -24
  13. package/cli/assurance-command-runner.mjs +39 -4
  14. package/cli/bagit-folder.mjs +57 -0
  15. package/cli/bagit-review.mjs +23 -0
  16. package/cli/bounded-json.mjs +1 -1
  17. package/cli/bulk-command-runner.mjs +2 -0
  18. package/cli/bulk-output.mjs +29 -3
  19. package/cli/case-command.mjs +195 -0
  20. package/cli/case-pack.mjs +39 -31
  21. package/cli/command-argument-grammar.mjs +4 -0
  22. package/cli/command-reference.mjs +145 -75
  23. package/cli/completion.mjs +298 -79
  24. package/cli/config-profile.mjs +7 -5
  25. package/cli/ct-event-intake.mjs +12 -12
  26. package/cli/discover.mjs +4 -3
  27. package/cli/discovery-observation-snapshot.mjs +44 -16
  28. package/cli/discovery-scan-command-runner.mjs +2 -0
  29. package/cli/discovery-scan.mjs +7 -4
  30. package/cli/discovery-workflow.mjs +1 -1
  31. package/cli/domain-control-monitor.mjs +50 -20
  32. package/cli/domain-control-observations.mjs +149 -102
  33. package/cli/errors.mjs +33 -1
  34. package/cli/evidence-command-runner.mjs +16 -5
  35. package/cli/evidence-signing.mjs +2 -2
  36. package/cli/external-observation-mapping.mjs +7 -8
  37. package/cli/formatters/terminal-lookup.mjs +18 -46
  38. package/cli/formatters/terminal-shared.mjs +5 -3
  39. package/cli/formatters/terminal-technology.mjs +114 -0
  40. package/cli/inline-command-families.mjs +1 -0
  41. package/cli/interactive-launcher.mjs +2 -160
  42. package/cli/interchange-report.mjs +4 -8
  43. package/cli/investigation-artifacts.mjs +176 -0
  44. package/cli/investigation-checkpoint.mjs +10 -0
  45. package/cli/investigation-folder.mjs +114 -0
  46. package/cli/investigation-manifest.mjs +1 -112
  47. package/cli/investigation-package-review.mjs +103 -0
  48. package/cli/investigation-plan.mjs +32 -19
  49. package/cli/investigation-run.mjs +188 -33
  50. package/cli/local-document-checkpoint.mjs +170 -0
  51. package/cli/lookup-browser.mjs +5 -12
  52. package/cli/lookup-command-runner.mjs +17 -6
  53. package/cli/lookup-diff.mjs +37 -3
  54. package/cli/lookup-plan.mjs +11 -5
  55. package/cli/lookup-reconcile.mjs +10 -8
  56. package/cli/mail-review.mjs +66 -14
  57. package/cli/manual.mjs +1 -0
  58. package/cli/network-command-runner.mjs +2 -0
  59. package/cli/output-file.mjs +31 -5
  60. package/cli/registry-doctor.mjs +3 -1
  61. package/cli/registry-support.mjs +1 -1
  62. package/cli/retained-artifact-diff.mjs +2 -2
  63. package/cli/review-command-runner.mjs +45 -0
  64. package/cli/runner.mjs +55 -6
  65. package/cli/sharing-review.mjs +21 -34
  66. package/cli/signer-trust.mjs +104 -0
  67. package/cli/source-reliability.mjs +74 -83
  68. package/cli/terminal-input.mjs +168 -0
  69. package/cli/workflow-command-runner.mjs +68 -13
  70. package/docs/cli-reference.md +76 -30
  71. package/docs/cli.md +289 -25
  72. package/lib/abort.mjs +4 -1
  73. package/lib/availability.mjs +161 -112
  74. package/lib/bounded-file.mjs +3 -1
  75. package/lib/bounded-html-document.mjs +125 -0
  76. package/lib/bounded-json.mjs +43 -3
  77. package/lib/browser-library-profile.mjs +42 -51
  78. package/lib/browser-library-worker.mjs +112 -0
  79. package/lib/client-behavior-profile.mjs +3 -3
  80. package/lib/ct-search.mjs +11 -4
  81. package/lib/dns-delegation-health.mjs +24 -14
  82. package/lib/dns-inheritance-review.mjs +217 -0
  83. package/lib/dns-intelligence.mjs +36 -23
  84. package/lib/dns-mx.mjs +1 -1
  85. package/lib/dnssec-evidence-validation.mjs +11 -3
  86. package/lib/domain-change-packet.mjs +7 -2
  87. package/lib/domain-change-review.mjs +14 -8
  88. package/lib/domain-control-flight-recorder.mjs +219 -118
  89. package/lib/domain-control-manifest.mjs +117 -57
  90. package/lib/domain-posture-analysis.mjs +20 -7
  91. package/lib/domain-posture-parsers.mjs +88 -6
  92. package/lib/domain-posture.mjs +113 -26
  93. package/lib/evidence-export-privacy.mjs +561 -0
  94. package/lib/evidence-export.mjs +11 -592
  95. package/lib/evidence-report.mjs +3 -1
  96. package/lib/favicon.mjs +117 -41
  97. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  98. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  99. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  100. package/lib/hostname.mjs +2 -38
  101. package/lib/html-signals.mjs +82 -85
  102. package/lib/http-evidence-bounds.mjs +2 -1
  103. package/lib/http-intelligence.mjs +39 -26
  104. package/lib/lookup-cache.mjs +34 -11
  105. package/lib/lookup-child-profile-contract.mjs +24 -15
  106. package/lib/lookup-diagnostics.mjs +24 -1
  107. package/lib/lookup-progress.mjs +30 -13
  108. package/lib/lookup-response-contract.mjs +1048 -0
  109. package/lib/lookup-response.mjs +7 -10
  110. package/lib/lookup-source-progress.mjs +17 -14
  111. package/lib/lookup.mjs +48 -27
  112. package/lib/observed-network-context.mjs +43 -27
  113. package/lib/outbound-request-bounds.mjs +4 -1
  114. package/lib/page-fingerprints.mjs +69 -133
  115. package/lib/page-role-profile.mjs +12 -8
  116. package/lib/perceptual-hash.mjs +43 -14
  117. package/lib/rdap-bootstrap.mjs +72 -22
  118. package/lib/rdap-client.mjs +15 -7
  119. package/lib/rdap-normalization.mjs +33 -18
  120. package/lib/rdap-search-workbench.mjs +1 -1
  121. package/lib/rdap-transport.mjs +47 -20
  122. package/lib/rdap-types.mjs +2 -1
  123. package/lib/rdap.mjs +18 -3
  124. package/lib/registrable-domain.mjs +21 -1
  125. package/lib/registrar-standing-contract.mjs +1 -0
  126. package/lib/registry-capabilities.mjs +5 -1
  127. package/lib/registry-capability-catalogue.mjs +2 -1
  128. package/lib/registry-dates.mjs +2 -190
  129. package/lib/registry-insights.mjs +56 -18
  130. package/lib/response-policy.mjs +86 -48
  131. package/lib/risk-calibration-summary.mjs +17 -3
  132. package/lib/rpki-evidence.mjs +8 -5
  133. package/lib/safe-fetch.mjs +54 -47
  134. package/lib/security-txt.mjs +10 -7
  135. package/lib/service-binding-dns.mjs +8 -0
  136. package/lib/smtp-transport-review.mjs +27 -15
  137. package/lib/sslbl-certificates.generated.mjs +136 -135
  138. package/lib/static-html-analysis.mjs +117 -146
  139. package/lib/structured-data-identity.mjs +1 -0
  140. package/lib/technology-markup.mjs +100 -0
  141. package/lib/threat-intelligence-manifests.mjs +11 -139
  142. package/lib/threat-intelligence-runtime.mjs +24 -52
  143. package/lib/threat-intelligence-targets.mjs +6 -34
  144. package/lib/threat-intelligence-types.mjs +39 -0
  145. package/lib/typosquat-generator.mjs +19 -15
  146. package/lib/urlhaus-intelligence.mjs +8 -3
  147. package/lib/urlscan-intelligence.mjs +1 -1
  148. package/lib/website-security-posture.mjs +30 -6
  149. package/lib/website-technology.mjs +111 -71
  150. package/lib/whois-authority.mjs +14 -2
  151. package/lib/whois-chain.mjs +9 -2
  152. package/lib/whois-common-formats.mjs +5 -5
  153. package/lib/whois-contracts.mjs +2 -1
  154. package/lib/whois-dialects.mjs +2 -2
  155. package/lib/whois-parser-support.mjs +8 -1
  156. package/lib/whois-transport.mjs +23 -7
  157. package/package.json +2 -1
  158. package/packages/cases/case-attachment-model.mjs +83 -0
  159. package/packages/cases/case-evidence-model.mjs +156 -62
  160. package/packages/cases/case-export-input.mjs +53 -0
  161. package/packages/cases/case-migration-model.mjs +59 -25
  162. package/packages/cases/case-model.mjs +1 -0
  163. package/packages/cases/case-recheck-model.mjs +91 -0
  164. package/packages/cases/case-record-core.mjs +3 -2
  165. package/packages/cases/case-record-operations.mjs +89 -27
  166. package/packages/cases/case-record-projection.mjs +23 -20
  167. package/packages/cases/case-report.mjs +63 -52
  168. package/packages/cases/case-response-actions.mjs +654 -0
  169. package/packages/cases/case-response-model.mjs +41 -1129
  170. package/packages/cases/case-response-outcomes.mjs +317 -0
  171. package/packages/cases/case-response-packet-render.mjs +153 -0
  172. package/packages/cases/case-response-packet-types.mjs +1 -0
  173. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  174. package/packages/cases/case-response-packet.mjs +38 -587
  175. package/packages/cases/case-response-records.mjs +94 -0
  176. package/packages/cases/case-response-review-inputs.mjs +371 -0
  177. package/packages/cases/case-response-values.mjs +85 -0
  178. package/packages/cases/case-review-package.mjs +45 -0
  179. package/packages/cases/case-selection.mjs +46 -0
  180. package/packages/cases/case-storage-model.mjs +25 -0
  181. package/packages/cases/response-route-freshness.mjs +16 -0
  182. package/packages/comparison/capture-context.mjs +61 -0
  183. package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
  184. package/packages/comparison/page-similarity.mjs +2 -1
  185. package/packages/comparison/relationship-evidence.mjs +85 -8
  186. package/packages/comparison/relationship-provenance.mjs +93 -0
  187. package/packages/contracts/analyst-interchange.mjs +216 -0
  188. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  189. package/packages/contracts/analyst-review-state.mjs +5 -4
  190. package/packages/contracts/case-portability.mjs +279 -116
  191. package/packages/contracts/case-views-contract.mjs +12 -0
  192. package/packages/contracts/case-views.mjs +26 -0
  193. package/packages/contracts/cli-command-semantics.mjs +1 -0
  194. package/packages/contracts/cli-lookup.mjs +0 -1
  195. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  196. package/packages/contracts/domain-control-manifest.mjs +127 -73
  197. package/packages/contracts/domain-control-monitor.mjs +48 -45
  198. package/packages/contracts/domain-control-review.mjs +179 -121
  199. package/packages/contracts/domain-name.mjs +21 -0
  200. package/packages/contracts/external-observation-interchange.mjs +2 -1
  201. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  202. package/packages/contracts/http-url.mjs +2 -0
  203. package/packages/contracts/investigation-package-limits.mjs +24 -0
  204. package/packages/contracts/investigation-portability.mjs +56 -27
  205. package/packages/contracts/investigation-run.mjs +16 -0
  206. package/packages/contracts/ip-address.mjs +34 -0
  207. package/packages/contracts/lookup-evidence.mjs +35 -0
  208. package/packages/contracts/offline-comparison.mjs +1 -2
  209. package/packages/contracts/page-fingerprints.mjs +15 -0
  210. package/packages/contracts/risk-calibration.mjs +0 -1
  211. package/packages/contracts/schema-compatibility.mjs +1 -1
  212. package/packages/contracts/schema-lifecycle.mjs +83 -153
  213. package/packages/contracts/selected-file-limits.mjs +6 -0
  214. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  215. package/packages/contracts/web-capture.mjs +10 -0
  216. package/packages/contracts/workspace-portability.mjs +160 -225
  217. package/packages/evidence/artifact-integrity.mjs +8 -2
  218. package/packages/evidence/artifact-structure.mjs +105 -0
  219. package/packages/evidence/decision-fact.mjs +4 -1
  220. package/packages/evidence/domain-control-runtime.mjs +155 -27
  221. package/packages/evidence/domain-posture-context.mjs +79 -0
  222. package/packages/evidence/image-regions.mjs +56 -0
  223. package/packages/evidence/latest-observations.mjs +28 -0
  224. package/packages/evidence/lookup-target.mjs +104 -0
  225. package/packages/evidence/observation.mjs +16 -1
  226. package/packages/evidence/passphrase-encryption.mjs +44 -0
  227. package/packages/evidence/record-identity.mjs +72 -0
  228. package/packages/evidence/registry-dates.mjs +190 -0
  229. package/packages/evidence/retained-file.mjs +69 -0
  230. package/packages/evidence/review-age.mjs +14 -0
  231. package/packages/interchange/bagit.mjs +342 -0
  232. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  233. package/packages/interchange/crc32.mjs +17 -0
  234. package/packages/interchange/external-findings-import.mjs +89 -49
  235. package/packages/interchange/web-capture-import.mjs +382 -0
  236. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  237. package/packages/investigation/investigation-bagit.mjs +14 -0
  238. package/packages/investigation/investigation-capsule.mjs +33 -5
  239. package/packages/investigation/investigation-manifest.mjs +200 -0
  240. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  241. package/packages/investigation/investigation-package.mjs +200 -0
  242. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  243. package/packages/investigation/lookup-asset-graph.mjs +161 -141
  244. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  245. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  246. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  247. package/packages/monitoring/analyst-review-state.mjs +38 -90
  248. package/packages/workspace/brand-profile-model.mjs +158 -20
  249. package/packages/workspace/bulk-columns.mjs +17 -0
  250. package/packages/workspace/bulk-review-model.mjs +11 -5
  251. package/packages/workspace/bulk-session-model.mjs +95 -20
  252. package/packages/workspace/bulk-sort.mjs +1 -0
  253. package/packages/workspace/case-views.mjs +102 -0
  254. package/packages/workspace/hostile-input.mjs +29 -8
  255. package/packages/workspace/investigation-template-model.mjs +19 -4
  256. package/packages/workspace/page-baseline.mjs +18 -15
  257. package/packages/workspace/relationship-observation-model.mjs +41 -26
  258. package/packages/workspace/watchlist-history.mjs +2 -2
  259. package/packages/workspace/watchlist-store.mjs +8 -2
  260. package/packages/workspace/website-snapshot-model.mjs +70 -30
  261. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  262. package/packages/workspace/workspace-archive.mjs +85 -21
  263. package/third-party-notices.txt +29 -2
  264. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -1,10 +1,11 @@
1
1
  // Bounded delegation-health evidence for deep single-domain Lookup. Registry
2
- // publication, recursive parent-view answers, and direct nameserver queries
2
+ // publication, recursive resolver answers, and direct nameserver queries
3
3
  // remain separately attributed. The result never participates in registration
4
4
  // availability and never treats a failed query as record absence.
5
5
  import { promises as dns } from 'node:dns';
6
6
  import * as net from 'node:net';
7
7
  import { createObservation } from "../packages/evidence/observation.mjs";
8
+ import { normalizeResolverMxRecord } from "../packages/evidence/domain-control-runtime.mjs";
8
9
  import { isValidAsciiHostname } from "./hostname.mjs";
9
10
  import { isPrivateAddress } from "./safe-fetch.mjs";
10
11
  const DNS_DELEGATION_HEALTH_VERSION = 1;
@@ -148,10 +149,9 @@ function normaliseAuthorityValueSet(type, value) {
148
149
  }
149
150
  const source = record(item);
150
151
  if (type === 'MX') {
151
- const exchange = hostname(source.exchange);
152
- const priority = Number(source.priority);
153
- if (exchange && Number.isSafeInteger(priority) && priority >= 0 && priority <= 65_535)
154
- return [`${priority} ${exchange}`];
152
+ const mx = normalizeResolverMxRecord(item);
153
+ if (mx)
154
+ return [`${mx.priority} ${mx.exchange}`];
155
155
  rejected += 1;
156
156
  return [];
157
157
  }
@@ -291,6 +291,7 @@ function finding(id, label, state, summary, detail, remediation) {
291
291
  function skippedDnsDelegationHealth(detail = 'Authoritative delegation checks were not collected for this lookup.') {
292
292
  return {
293
293
  delegationHealthVersion: DNS_DELEGATION_HEALTH_VERSION,
294
+ domain: null,
294
295
  ...createObservation({
295
296
  status: 'skipped',
296
297
  scanMode: 'deep',
@@ -326,9 +327,9 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
326
327
  ? parentQuery.status
327
328
  : 'error';
328
329
  const parentNameservers = hostnames(parentQuery.records);
329
- const candidates = [...new Set([...parentNameservers, ...registry.nameservers])]
330
- .sort()
331
- .slice(0, MAX_AUTHORITIES);
330
+ const eligibleAuthorities = [...new Set([...parentNameservers, ...registry.nameservers])].sort();
331
+ const candidates = eligibleAuthorities.slice(0, MAX_AUTHORITIES);
332
+ const omittedAuthorityCount = eligibleAuthorities.length - candidates.length;
332
333
  const resolve4 = options.resolve4 || dns.resolve4;
333
334
  const resolve6 = options.resolve6 || dns.resolve6;
334
335
  const queryAuthority = options.queryAuthority || defaultAuthorityQuery;
@@ -473,13 +474,13 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
473
474
  const recordPartial = recordMatrix.filter((row) => row.state === 'partial');
474
475
  const recordInsufficient = recordMatrix.filter((row) => row.state === 'insufficient');
475
476
  const findings = [
476
- finding('parent_registry_ns', 'Parent and registry nameservers', !parentNameservers.length || !registry.nameservers.length
477
+ finding('parent_registry_ns', 'Recursive and registry nameservers', !parentNameservers.length || !registry.nameservers.length
477
478
  ? 'unknown'
478
479
  : sameSet(parentNameservers, registry.nameservers) ? 'healthy' : 'warning', !parentNameservers.length || !registry.nameservers.length
479
480
  ? 'Comparison is incomplete'
480
481
  : sameSet(parentNameservers, registry.nameservers)
481
- ? 'Parent view and registry publication agree'
482
- : 'Parent view and registry publication differ', `Parent view: ${parentNameservers.join(', ') || 'unavailable'}. Registry publication: ${registry.nameservers.join(', ') || 'unavailable'}.`, 'Confirm the intended delegation with the registry and DNS operator before changing nameservers.'),
482
+ ? 'Recursive observation and registry publication agree'
483
+ : 'Recursive observation and registry publication differ', `Recursive resolver: ${parentNameservers.join(', ') || 'unavailable'}. Registry publication: ${registry.nameservers.join(', ') || 'unavailable'}.`, 'Confirm the intended delegation with the registry and DNS operator before changing nameservers.'),
483
484
  finding('authority_reachability', 'Direct nameserver reachability', lameAuthorities.length ? 'danger'
484
485
  : partialAuthorities.length || unreachableAuthorities.length ? 'warning'
485
486
  : successfulAuthorities.length ? 'healthy' : 'unknown', lameAuthorities.length
@@ -496,7 +497,7 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
496
497
  ? 'No direct authority answer was available'
497
498
  : inconsistentAuthoritySets
498
499
  ? 'Direct nameserver answers are inconsistent'
499
- : 'Direct nameserver answers agree with the observed parent view', successfulAuthorities.map((authority) => `${authority.nameserver}: ${authority.nameservers.join(', ') || 'no NS answer'}`).join(' | ') || 'No direct answers.', 'Align the NS set served by every authority with the intended parent delegation before cutover.'),
500
+ : 'Direct nameserver answers agree with the recursive observation', successfulAuthorities.map((authority) => `${authority.nameserver}: ${authority.nameservers.join(', ') || 'no NS answer'}`).join(' | ') || 'No direct answers.', 'Align the NS set served by every authority with the intended parent delegation before cutover.'),
500
501
  finding('authority_soa_consistency', 'Authoritative SOA consistency', retainedSoa.length < 2 || soaSerials.length < 1 || soaPrimaries.length < 1
501
502
  ? 'unknown'
502
503
  : soaSerialConflict || soaPrimaryConflict ? 'warning' : 'healthy', retainedSoa.length < 2
@@ -530,6 +531,7 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
530
531
  ];
531
532
  const collectionIncomplete = parentStatus !== 'success'
532
533
  || candidates.length === 0
534
+ || omittedAuthorityCount > 0
533
535
  || parentQuery.truncated === true
534
536
  || registry.truncated
535
537
  || authorities.some((authority) => authority.state !== 'success')
@@ -540,6 +542,7 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
540
542
  const observedAt = (options.observedAt || (() => new Date().toISOString()))();
541
543
  return {
542
544
  delegationHealthVersion: DNS_DELEGATION_HEALTH_VERSION,
545
+ domain,
543
546
  ...createObservation({
544
547
  status,
545
548
  observedAt,
@@ -549,9 +552,13 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
549
552
  complete: !collectionIncomplete,
550
553
  truncated: parentQuery.truncated === true
551
554
  || registry.truncated
555
+ || omittedAuthorityCount > 0
552
556
  || (recordCollectionAttempted && recordMatrix.some((row) => row.observations.some((item) => item.truncated))),
553
557
  limitations: [
554
- 'The parent nameserver set is a point-in-time recursive resolver view; registry nameservers and glue come from the separately attributed RDAP publication.',
558
+ 'The initial nameserver set comes from a recursive resolver, not a direct parent-server query; registry nameservers and glue come from the separately attributed RDAP publication.',
559
+ ...(omittedAuthorityCount > 0
560
+ ? [`${candidates.length} of ${eligibleAuthorities.length} eligible nameservers were selected; ${omittedAuthorityCount} were not queried.`]
561
+ : []),
555
562
  'Direct queries use at most four selected nameservers and two validated public addresses per nameserver.',
556
563
  'Extended A, AAAA, CAA, and MX comparison uses only one selected validated public address per nameserver and caps each record set at sixteen values.',
557
564
  'A direct answer does not prove global reachability, and a failed query is not evidence that the record is absent.',
@@ -563,6 +570,9 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
563
570
  ],
564
571
  diagnostics: {
565
572
  authorityCount: authorities.length,
573
+ eligibleAuthorityCount: eligibleAuthorities.length,
574
+ queriedAuthorityCount: authorities.filter((authority) => authority.attempts.length > 0).length,
575
+ omittedAuthorityCount,
566
576
  queriedAddressCount: authorities.reduce((sum, authority) => sum + authority.attempts.length, 0),
567
577
  successfulAuthorityCount: successfulAuthorities.length,
568
578
  partialAuthorityCount: partialAuthorities.length,
@@ -576,7 +586,7 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
576
586
  ? 'The bounded delegation-health collection completed.'
577
587
  : status === 'partial'
578
588
  ? 'The delegation-health collection is partial; review each source state before changing DNS.'
579
- : 'The delegation-health collection could not confirm a parent or direct authority view.',
589
+ : 'The delegation-health collection could not confirm a recursive or direct authority observation.',
580
590
  parent: {
581
591
  state: parentStatus,
582
592
  nameservers: parentNameservers,
@@ -0,0 +1,217 @@
1
+ import { promises as dns } from 'node:dns';
2
+ import { parse as parseDomain } from 'tldts';
3
+ import { parseDmarcRecords } from "./domain-posture-parsers.mjs";
4
+ import { buildDnssecQuery, DNS_TYPE_NS, normalizeResolverEndpoint, parseDnssecResponse } from "./dnssec-chain-validation.mjs";
5
+ import { defaultTcpExchange } from "./service-binding-dns.mjs";
6
+ import { isValidAsciiHostname } from "./hostname.mjs";
7
+ import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
8
+ const MAX_PARENT_SERVERS = 2;
9
+ const MAX_NS = 16;
10
+ const QUERY_TIMEOUT_MS = 2200;
11
+ const REVIEW_TIMEOUT_MS = 10_000;
12
+ const MISSING = new Set(['ENODATA', 'ENOTFOUND', 'ENONAME']);
13
+ function requireDomain(domain) {
14
+ if (typeof domain !== 'string' || domain.length > 253 || !isValidAsciiHostname(domain) || domain !== domain.toLowerCase()) {
15
+ throw new TypeError('The additional DNS review requires a normalised domain.');
16
+ }
17
+ }
18
+ /** Explicit query parameter; malformed opt-ins never broaden collection. */
19
+ export function parseInheritedDnsSelection(value) {
20
+ if (value === undefined || value === '')
21
+ return undefined;
22
+ if (value === '1')
23
+ return true;
24
+ throw new TypeError('includeInheritedDns must be 1 when requested.');
25
+ }
26
+ function check(id, label, status, summary, detail, records) {
27
+ return { id, label, status, summary, detail, records, remediation: '' };
28
+ }
29
+ function policyObservation(owner, observation) {
30
+ const records = observation.records;
31
+ let bytes = 0;
32
+ const admitted = Array.isArray(records) && records.length <= 64 && records.every(raw => {
33
+ const parts = typeof raw === 'string' ? [raw] : raw;
34
+ return Array.isArray(parts) && parts.length <= 256 && parts.every(part => {
35
+ if (typeof part !== 'string' || part.length > 4096)
36
+ return false;
37
+ bytes += Buffer.byteLength(part, 'utf8');
38
+ return bytes <= 65_536;
39
+ });
40
+ });
41
+ const parsed = parseDmarcRecords(admitted ? records : []);
42
+ const unavailable = Boolean(observation.error) || !admitted;
43
+ const valid = !unavailable && parsed.valid && ['u', 'n', 'y'].includes(parsed.tags.psd?.toLowerCase() ?? 'u');
44
+ const state = unavailable ? 'unavailable' : valid ? 'observed' : parsed.records.length ? 'invalid' : 'not found';
45
+ const description = valid
46
+ ? `p=${parsed.policy}; sp=${parsed.subdomainPolicy}; np=${parsed.nonexistentSubdomainPolicy}; psd=${parsed.tags.psd?.toLowerCase() ?? 'u'}; test=${parsed.testMode ? 'yes' : 'no'}`
47
+ : state;
48
+ const time = typeof observation.observedAt === 'string' && observation.observedAt.length <= 64
49
+ ? normalizeExplicitIsoTimestamp(observation.observedAt) : null;
50
+ return { owner, parsed, valid, unavailable, record: `Recursive DNS TXT _dmarc.${owner} · ${time ?? 'time unavailable'} · ${description}` };
51
+ }
52
+ /** RFC 9989 sections 4.10–4.10.2; at most eight owners including the exact name. */
53
+ export async function reviewInheritedDmarc(domain, exact, resolve) {
54
+ requireDomain(domain);
55
+ const rows = [policyObservation(domain, exact)];
56
+ const labels = domain.split('.');
57
+ let next = labels.length >= 8 ? labels.slice(-7) : labels.slice(1);
58
+ if (!rows[0].valid && !rows[0].unavailable) {
59
+ while (next.length) {
60
+ const owner = next.join('.');
61
+ const row = policyObservation(owner, await resolve(`_dmarc.${owner}`));
62
+ rows.push(row);
63
+ if (row.unavailable || (row.valid && ['n', 'y'].includes(row.parsed.tags.psd?.toLowerCase() ?? '')))
64
+ break;
65
+ next = next.slice(1);
66
+ }
67
+ }
68
+ const available = rows.filter(row => row.valid);
69
+ const explicit = rows[0].valid ? rows[0] : null;
70
+ const boundary = available.find(row => row.parsed.tags.psd?.toLowerCase() === 'n' || row.parsed.tags.psd?.toLowerCase() === 'y');
71
+ const organisational = boundary?.parsed.tags.psd?.toLowerCase() === 'y'
72
+ ? domain.split('.').slice(-(boundary.owner.split('.').length + 1)).join('.')
73
+ : boundary?.owner ?? available.at(-1)?.owner ?? null;
74
+ const selected = explicit ?? available.find(row => row.owner === organisational) ?? (boundary?.parsed.tags.psd?.toLowerCase() === 'y' ? boundary : null);
75
+ const incomplete = rows.some(row => row.unavailable || (!row.valid && row.parsed.records.length === 1));
76
+ const summary = incomplete ? 'Inherited policy could not be determined from the collected records.'
77
+ : selected ? `${selected.owner === domain ? 'Exact-name' : 'Inherited'} policy published at _dmarc.${selected.owner}.`
78
+ : 'No applicable DMARC policy was found in the completed tree walk.';
79
+ const detail = !incomplete && selected
80
+ ? selected.owner === domain
81
+ ? `Published policy: ${selected.parsed.policy}. Test mode: ${selected.parsed.testMode ? 'yes' : 'no'}.`
82
+ : `For an existing name: ${selected.parsed.subdomainPolicy}. For a nonexistent name: ${selected.parsed.nonexistentSubdomainPolicy}. Test mode: ${selected.parsed.testMode ? 'yes' : 'no'}. Name existence is not inferred from this review.`
83
+ : 'Invalid or unavailable records do not establish absence or a usable inherited policy.';
84
+ return check('dmarc_inheritance', 'Inherited DMARC policy', incomplete || !selected ? 'warning' : 'info', summary, `${detail} Discovery follows RFC 9989 with at most eight owners. This is publication evidence, not message authentication or a guarantee of receiver behaviour.`, rows.map(row => row.record));
85
+ }
86
+ function nameservers(value) {
87
+ if (!Array.isArray(value) || value.length > MAX_NS)
88
+ return null;
89
+ const names = value.map(raw => typeof raw === 'string' && raw.length <= 254 ? raw.toLowerCase().replace(/\.$/u, '') : '');
90
+ return names.every(name => name.length <= 253 && isValidAsciiHostname(name)) ? [...new Set(names)].sort() : null;
91
+ }
92
+ export async function reviewParentDelegation(domain, recursive, dependencies, signal) {
93
+ requireDomain(domain);
94
+ const parsed = parseDomain(domain, { allowPrivateDomains: false });
95
+ const child = parsed.domain;
96
+ const parent = parsed.publicSuffix;
97
+ const records = [];
98
+ let referrals = 0;
99
+ let differs = false;
100
+ let parentDiffers = false;
101
+ let firstReferral = null;
102
+ let incomplete = false;
103
+ const observed = recursive.error || domain !== child ? null : nameservers(recursive.records);
104
+ const finish = () => check('parent_delegation', 'Direct parent delegation', differs || parentDiffers || incomplete || !referrals ? 'warning' : 'info', parentDiffers ? 'Sampled parent servers returned different delegation referrals.'
105
+ : differs ? 'Parent referral and recursive nameserver observations differ.'
106
+ : referrals ? `${referrals} parent server${referrals === 1 ? '' : 's'} returned a delegation referral for ${child}.`
107
+ : 'No direct parent referral was established.', `Registration-domain scope: ${child ?? 'unavailable'}; parent candidate: ${parent ?? 'unavailable'}. At most two discovered parent servers are queried over pinned public-address DNS/TCP, without recursion. This is a sample, not full delegation or DNSSEC validation.${incomplete ? ' Some discovery or direct observations were unavailable or incomplete.' : ''}${!observed ? ' No complete same-scope recursive comparison is available.' : ''}`, records);
108
+ if (!child || !parent || child === parent)
109
+ return finish();
110
+ signal?.throwIfAborted();
111
+ let discovered;
112
+ try {
113
+ discovered = nameservers(await dependencies.resolveNs(parent));
114
+ }
115
+ catch {
116
+ signal?.throwIfAborted();
117
+ incomplete = true;
118
+ return finish();
119
+ }
120
+ if (!discovered?.length) {
121
+ incomplete = true;
122
+ return finish();
123
+ }
124
+ records.push(`Recursive DNS NS ${parent} · ${dependencies.now().toISOString()} · ${discovered.length} discovered; ${Math.min(MAX_PARENT_SERVERS, discovered.length)} selected.`);
125
+ for (const server of discovered.slice(0, MAX_PARENT_SERVERS)) {
126
+ signal?.throwIfAborted();
127
+ const resolutions = await Promise.allSettled([dependencies.resolve4(server), dependencies.resolve6(server)]);
128
+ signal?.throwIfAborted();
129
+ const failed = resolutions.some(result => result.status === 'fulfilled'
130
+ ? !Array.isArray(result.value) || result.value.length > 16
131
+ : !MISSING.has(String(result.reason?.code)));
132
+ if (failed) {
133
+ incomplete = true;
134
+ records.push(`${server}: public-address discovery unavailable or rejected.`);
135
+ continue;
136
+ }
137
+ const addresses = resolutions.flatMap(result => result.status === 'fulfilled' ? result.value : []);
138
+ const endpoints = addresses.map(normalizeResolverEndpoint);
139
+ if (!addresses.length || endpoints.some(endpoint => !endpoint)) {
140
+ incomplete = true;
141
+ records.push(`${server}: public-address discovery unavailable or rejected.`);
142
+ continue;
143
+ }
144
+ const endpoint = endpoints.filter((value) => value !== null)
145
+ .sort((a, b) => a.address < b.address ? -1 : a.address > b.address ? 1 : 0)[0];
146
+ const query = buildDnssecQuery(child, DNS_TYPE_NS);
147
+ query.writeUInt16BE(0, 2); // No recursion or authenticated-data assertion is requested.
148
+ try {
149
+ const responseBytes = await dependencies.exchange(query, endpoint, { timeoutMs: QUERY_TIMEOUT_MS, ...(signal ? { signal } : {}) });
150
+ signal?.throwIfAborted();
151
+ if (responseBytes.byteLength > 65_535)
152
+ throw new RangeError('The DNS response exceeds the wire limit.');
153
+ const response = parseDnssecResponse(responseBytes, { transactionId: query.readUInt16BE(0), name: child, type: DNS_TYPE_NS });
154
+ const referred = nameservers(response.records.filter(row => row.section === 'authority' && row.owner === child && row.type === DNS_TYPE_NS && row.class === 1)
155
+ .flatMap(row => row.data.kind === 'NS' ? [row.data.name] : []));
156
+ records.push(`Direct DNS/TCP ${server} [${endpoint.address}] · ${dependencies.now().toISOString()} · ${child} NS · response ${response.rcode}.`);
157
+ if (response.rcode !== 0 || !referred?.length) {
158
+ incomplete = true;
159
+ continue;
160
+ }
161
+ referrals += 1;
162
+ if (firstReferral && JSON.stringify(firstReferral) !== JSON.stringify(referred))
163
+ parentDiffers = true;
164
+ firstReferral ??= referred;
165
+ for (const name of referred)
166
+ records.push(`${server} authority section: ${child} NS ${name}`);
167
+ if (observed && JSON.stringify(observed) !== JSON.stringify(referred))
168
+ differs = true;
169
+ }
170
+ catch {
171
+ signal?.throwIfAborted();
172
+ incomplete = true;
173
+ records.push(`${server}: bounded direct referral query failed.`);
174
+ }
175
+ }
176
+ return finish();
177
+ }
178
+ export async function collectDnsInheritanceChecks(domain, exact, recursive, options = {}, injected) {
179
+ requireDomain(domain);
180
+ options.signal?.throwIfAborted();
181
+ const timeout = AbortSignal.timeout(REVIEW_TIMEOUT_MS);
182
+ const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
183
+ const resolver = injected ? null : new dns.Resolver({ timeout: QUERY_TIMEOUT_MS, tries: 1 });
184
+ const cancel = () => resolver?.cancel();
185
+ signal.addEventListener('abort', cancel, { once: true });
186
+ const dependencies = injected ?? {
187
+ resolveTxt: (name) => resolver.resolveTxt(name), resolveNs: (name) => resolver.resolveNs(name),
188
+ resolve4: (name) => resolver.resolve4(name), resolve6: (name) => resolver.resolve6(name),
189
+ exchange: defaultTcpExchange, now: () => new Date(),
190
+ };
191
+ const resolve = async (owner) => {
192
+ signal.throwIfAborted();
193
+ if (owner.length > 253)
194
+ return { records: [], error: 'The DNS question exceeds the name limit.' };
195
+ try {
196
+ return { records: await dependencies.resolveTxt(owner), error: null, observedAt: dependencies.now().toISOString() };
197
+ }
198
+ catch (error) {
199
+ options.signal?.throwIfAborted();
200
+ return { records: [], error: MISSING.has(String(error?.code)) ? null : 'DNS observation unavailable.', observedAt: dependencies.now().toISOString() };
201
+ }
202
+ };
203
+ const operations = [reviewInheritedDmarc(domain, exact, resolve), reviewParentDelegation(domain, recursive, dependencies, signal)];
204
+ try {
205
+ return await Promise.all(operations);
206
+ }
207
+ catch {
208
+ options.signal?.throwIfAborted();
209
+ const settled = await Promise.allSettled(operations);
210
+ return settled.map((result, index) => result.status === 'fulfilled' ? result.value : check(index === 0 ? 'dmarc_inheritance' : 'parent_delegation', index === 0 ? 'Inherited DMARC policy' : 'Direct parent delegation', 'warning', 'The additional review could not complete.', 'No absence or effective-policy conclusion is available from the incomplete review.', []));
211
+ }
212
+ finally {
213
+ await Promise.allSettled(operations);
214
+ signal.removeEventListener('abort', cancel);
215
+ cancel();
216
+ }
217
+ }
@@ -5,11 +5,13 @@
5
5
  import { promises as dns } from 'node:dns';
6
6
  import * as net from 'node:net';
7
7
  import { classifyMxRecords } from "./dns-mx.mjs";
8
+ import { normalizeResolverMxRecord } from "../packages/evidence/domain-control-runtime.mjs";
8
9
  import { collectDnsDelegationHealth, skippedDnsDelegationHealth, } from "./dns-delegation-health.mjs";
9
10
  import { createObservation } from "../packages/evidence/observation.mjs";
10
11
  import { MAX_LOOKUP_DNS_RECORDS_PER_TYPE, MAX_LOOKUP_REVERSE_DNS_PTR_RECORDS, } from "./lookup-network-evidence-bounds.mjs";
11
12
  import { isPrivateAddress } from "./safe-fetch.mjs";
12
13
  import { resolveServiceBindingRecords } from "./service-binding-dns.mjs";
14
+ import { canonicalRegistrableDomain } from "./registrable-domain.mjs";
13
15
  const DNS_TIMEOUT_MS = 5000;
14
16
  const MAX_RECORDS_PER_TYPE = MAX_LOOKUP_DNS_RECORDS_PER_TYPE;
15
17
  const MAX_PTR_RECORDS = MAX_LOOKUP_REVERSE_DNS_PTR_RECORDS;
@@ -123,7 +125,7 @@ function boundedUnique(values, limit = MAX_RECORDS_PER_TYPE) {
123
125
  }
124
126
  function normalizeAddresses(records, family) {
125
127
  const values = [];
126
- let discarded = 0;
128
+ let discarded = Array.isArray(records) ? 0 : 1;
127
129
  for (const record of Array.isArray(records) ? records : []) {
128
130
  const value = typeof record === 'string' ? record : record && typeof record === 'object' ? record.address : null;
129
131
  if (typeof value !== 'string' || net.isIP(value) !== family)
@@ -135,7 +137,7 @@ function normalizeAddresses(records, family) {
135
137
  }
136
138
  function normalizeHostnames(records) {
137
139
  const values = [];
138
- let discarded = 0;
140
+ let discarded = Array.isArray(records) ? 0 : 1;
139
141
  for (const record of Array.isArray(records) ? records : []) {
140
142
  const value = normalizeHostname(record);
141
143
  if (value)
@@ -147,19 +149,15 @@ function normalizeHostnames(records) {
147
149
  }
148
150
  function normalizeMx(records) {
149
151
  const byKey = new Map();
150
- let discarded = 0;
152
+ let discarded = Array.isArray(records) ? 0 : 1;
151
153
  for (const record of Array.isArray(records) ? records : []) {
152
- if (!record || typeof record !== 'object') {
153
- discarded += 1;
154
- continue;
155
- }
156
- const value = record;
157
- const exchange = value.exchange === '' || value.exchange === '.' ? '' : normalizeHostname(value.exchange);
158
- const priority = Number(value.priority);
159
- if (exchange === null || !Number.isInteger(priority) || priority < 0 || priority > 65535) {
154
+ const value = normalizeResolverMxRecord(record);
155
+ if (!value) {
160
156
  discarded += 1;
161
157
  continue;
162
158
  }
159
+ const { priority } = value;
160
+ const exchange = value.exchange === '.' ? '' : value.exchange;
163
161
  byKey.set(`${priority}:${exchange}`, { priority, exchange });
164
162
  }
165
163
  const values = [...byKey.values()].sort((a, b) => a.priority - b.priority || a.exchange.localeCompare(b.exchange));
@@ -167,7 +165,7 @@ function normalizeMx(records) {
167
165
  }
168
166
  function normalizeTxtPolicies(records, prefix) {
169
167
  const values = [];
170
- let discarded = 0;
168
+ let discarded = Array.isArray(records) ? 0 : 1;
171
169
  for (const chunks of Array.isArray(records) ? records : []) {
172
170
  if (!Array.isArray(chunks) || chunks.some((chunk) => typeof chunk !== 'string')) {
173
171
  discarded += 1;
@@ -185,7 +183,7 @@ function normalizeTxtPolicies(records, prefix) {
185
183
  }
186
184
  function normalizeCaa(records) {
187
185
  const byKey = new Map();
188
- let discarded = 0;
186
+ let discarded = Array.isArray(records) ? 0 : 1;
189
187
  for (const record of Array.isArray(records) ? records : []) {
190
188
  const entry = record && typeof record === 'object' ? record : {};
191
189
  const critical = Number(entry.critical);
@@ -395,10 +393,11 @@ function withTimeout(factory, timeoutMs) {
395
393
  async function query(factory, normalize, timeoutMs) {
396
394
  try {
397
395
  const normalized = normalize(await withTimeout(factory, timeoutMs));
396
+ const unusable = !normalized.records.length && (normalized.truncated || normalized.discarded > 0);
398
397
  return {
399
- status: normalized.records.length ? 'success' : 'not_found',
398
+ status: unusable ? 'error' : normalized.records.length ? 'success' : 'not_found',
400
399
  records: normalized.records,
401
- error: null,
400
+ error: unusable ? 'The returned record family could not be retained completely; absence is unknown.' : null,
402
401
  truncated: normalized.truncated,
403
402
  discarded: normalized.discarded || 0,
404
403
  };
@@ -409,6 +408,12 @@ async function query(factory, normalize, timeoutMs) {
409
408
  return { status: 'error', records: [], error: boundedError(error), truncated: false, discarded: 0 };
410
409
  }
411
410
  }
411
+ function completeQuery(result) {
412
+ return result.status !== 'error' && !result.truncated && result.discarded === 0;
413
+ }
414
+ function observedPresence(result) {
415
+ return result.records.length > 0 ? true : completeQuery(result) ? false : null;
416
+ }
412
417
  async function collectEffectiveCaaPolicy(domain, options = {}) {
413
418
  const normalizedDomain = normalizeHostname(domain);
414
419
  const queryLimit = Math.max(1, Math.min(MAX_CAA_TREE_QUERIES, Math.floor(options.queryLimit || MAX_CAA_TREE_QUERIES)));
@@ -459,7 +464,7 @@ async function collectEffectiveCaaPolicy(domain, options = {}) {
459
464
  truncated: result.truncated,
460
465
  discarded: result.discarded,
461
466
  });
462
- if (result.status === 'error') {
467
+ if (result.status === 'error' && !result.truncated && result.discarded === 0) {
463
468
  return {
464
469
  ...createObservation({
465
470
  status: index === 0 ? 'error' : 'partial',
@@ -576,6 +581,10 @@ async function collectEffectiveCaaPolicy(domain, options = {}) {
576
581
  };
577
582
  }
578
583
  async function collectDnsIntelligence(domain, options = {}) {
584
+ const registrationDomain = options.registrationDomain ?? domain;
585
+ if (options.registrationDomain !== undefined && canonicalRegistrableDomain(domain) !== registrationDomain) {
586
+ throw new TypeError('DNS registration context must match the hostname being observed.');
587
+ }
579
588
  const resolvers = options.resolvers || {};
580
589
  const includeExtendedContext = options.includeExtendedContext === true;
581
590
  const includeInheritedCaa = options.includeInheritedCaa === true;
@@ -606,8 +615,11 @@ async function collectDnsIntelligence(domain, options = {}) {
606
615
  const httpsPromise = includeExtendedContext
607
616
  ? query(invoke('resolveHttps', (value) => resolveServiceBindingRecords(value, 'HTTPS', { timeoutMs })), normalizeServiceBindings, timeoutMs)
608
617
  : Promise.resolve(null);
618
+ const registrationNsPromise = includeExtendedContext && registrationDomain !== domain
619
+ ? query(invoke('resolveNs', dns.resolveNs, registrationDomain), normalizeHostnames, timeoutMs)
620
+ : nsPromise;
609
621
  const delegationPromise = includeExtendedContext
610
- ? nsPromise.then((parentNameservers) => collectDnsDelegationHealth(domain, parentNameservers, {
622
+ ? registrationNsPromise.then((parentNameservers) => collectDnsDelegationHealth(registrationDomain, parentNameservers, {
611
623
  registryEvidence: options.registryEvidence,
612
624
  resolve4: (resolvers.resolve4 || dns.resolve4),
613
625
  resolve6: (resolvers.resolve6 || dns.resolve6),
@@ -645,7 +657,8 @@ async function collectDnsIntelligence(domain, options = {}) {
645
657
  || discardedCount > 0
646
658
  || (caaPolicy !== null && caaPolicy.complete !== true)
647
659
  || (delegation !== null && delegation.status !== 'success');
648
- const classifiedMx = mx.status === 'error' ? null : classifyMxRecords(mx.records);
660
+ const classifiedMx = classifyMxRecords(mx.records);
661
+ const mxComplete = completeQuery(mx);
649
662
  return {
650
663
  ...createObservation({
651
664
  status: errorCount === values.length ? 'error' : incomplete ? 'partial' : 'success',
@@ -703,11 +716,11 @@ async function collectDnsIntelligence(domain, options = {}) {
703
716
  ...(soa ? { soa: soa.records } : {}),
704
717
  ...(https ? { https: https.records } : {}),
705
718
  },
706
- hasMx: classifiedMx ? classifiedMx.hasMx : null,
707
- hasNullMx: classifiedMx ? classifiedMx.hasNullMx : null,
708
- mxHosts: classifiedMx ? classifiedMx.mxHosts : [],
709
- hasSpf: spf.status === 'error' ? null : spf.records.length > 0,
710
- hasDmarc: dmarc.status === 'error' ? null : dmarc.records.length > 0,
719
+ hasMx: classifiedMx.hasMx ? true : mxComplete ? false : null,
720
+ hasNullMx: mxComplete ? classifiedMx.hasNullMx : null,
721
+ mxHosts: classifiedMx.mxHosts,
722
+ hasSpf: observedPresence(spf),
723
+ hasDmarc: observedPresence(dmarc),
711
724
  delegation,
712
725
  ...(caaPolicy ? { caaPolicy } : {}),
713
726
  };
package/lib/dns-mx.mjs CHANGED
@@ -14,7 +14,7 @@ function classifyMxRecords(records) {
14
14
  // of configuration. Not currently used for scoring (both cases already
15
15
  // correctly score as hasMx: false); exposed for callers that want to tell
16
16
  // the two apart.
17
- const hasNullMx = records.length > 0 && realRecords.length === 0;
17
+ const hasNullMx = records.length === 1 && records[0]?.priority === 0 && realRecords.length === 0;
18
18
  return {
19
19
  hasMx: realRecords.length > 0,
20
20
  hasNullMx,
@@ -130,6 +130,9 @@ function signatureTimeState(value, observedAt) {
130
130
  }
131
131
  function validateDnssecEvidence(input) {
132
132
  const normalizedOwner = ownerName(input.ownerName);
133
+ const missingDs = input.dsRecords === undefined;
134
+ const malformedContainers = [input.dsRecords, input.dnskeyRecords, input.rrSigRecords]
135
+ .filter((value) => value !== undefined && !Array.isArray(value)).length;
133
136
  const rawDs = Array.isArray(input.dsRecords) ? input.dsRecords : [];
134
137
  const rawDnskeys = Array.isArray(input.dnskeyRecords) ? input.dnskeyRecords : [];
135
138
  const rawRrsigs = Array.isArray(input.rrSigRecords) ? input.rrSigRecords : [];
@@ -138,7 +141,8 @@ function validateDnssecEvidence(input) {
138
141
  const rrsigs = rawRrsigs.slice(0, MAX_DNSSEC_RECORDS).map(normalizeRrsig).filter((item) => item !== null);
139
142
  const rejectedCount = Math.min(rawDs.length, MAX_DNSSEC_RECORDS) - dsRecords.length
140
143
  + Math.min(rawDnskeys.length, MAX_DNSSEC_RECORDS) - dnskeys.length
141
- + Math.min(rawRrsigs.length, MAX_DNSSEC_RECORDS) - rrsigs.length;
144
+ + Math.min(rawRrsigs.length, MAX_DNSSEC_RECORDS) - rrsigs.length
145
+ + malformedContainers;
142
146
  const truncated = rawDs.length > MAX_DNSSEC_RECORDS
143
147
  || rawDnskeys.length > MAX_DNSSEC_RECORDS
144
148
  || rawRrsigs.length > MAX_DNSSEC_RECORDS;
@@ -166,7 +170,11 @@ function validateDnssecEvidence(input) {
166
170
  limitations: Object.freeze(limitations),
167
171
  });
168
172
  }
169
- const incompleteInput = rejectedCount > 0 || truncated;
173
+ const incompleteInput = missingDs || rejectedCount > 0 || truncated;
174
+ if (missingDs)
175
+ findings.push('No DS record collection was supplied. Omitted input is not a verified empty collection.');
176
+ if (malformedContainers)
177
+ findings.push(`${malformedContainers} record collection container(s) had an invalid type; their contents are unknown.`);
170
178
  if (input.delegationSigned === false && dsRecords.length === 0 && !incompleteInput) {
171
179
  findings.push('The supplied delegation state was unsigned and no DS records were supplied.');
172
180
  return Object.freeze({
@@ -217,7 +225,7 @@ function validateDnssecEvidence(input) {
217
225
  findings.push('The retained subset contains no usable DS record, but omitted or rejected records prevent a definitive unsigned conclusion.');
218
226
  }
219
227
  if (rejectedCount > 0)
220
- findings.push(`${rejectedCount} malformed record(s) were rejected.`);
228
+ findings.push(`${rejectedCount} malformed record(s) or collection container(s) were rejected.`);
221
229
  if (truncated)
222
230
  findings.push('The supplied record set exceeded the review bound and was truncated.');
223
231
  const completeLocalRelationship = dsRecords.length > 0
@@ -1,11 +1,16 @@
1
1
  import { exactKeys, requireBoundedString, requireDomainName, requireIsoTimestamp, requireRecord, } from "./bounded-contract-normalizers.mjs";
2
2
  import { DOMAIN_ASSURANCE_INPUT_SCHEMA, buildDomainAssurance, } from "./domain-assurance.mjs";
3
- import { DOMAIN_CHANGE_INPUT_SCHEMA, reviewDomainChange, } from "./domain-change-review.mjs";
3
+ import { DOMAIN_CHANGE_INPUT_SCHEMA, DOMAIN_CHANGE_REVIEW_VERSION, reviewDomainChange, } from "./domain-change-review.mjs";
4
4
  import { SORTED_JSON_V2, sha256ArtifactDigestV2 } from "../packages/evidence/artifact-integrity.mjs";
5
5
  export const DOMAIN_CHANGE_PACKET_INPUT_SCHEMA = 'whoisleuth.domain-change-packet.input';
6
6
  export const DOMAIN_CHANGE_PACKET_SCHEMA = 'whoisleuth.domain-change-packet';
7
7
  export const DOMAIN_CHANGE_PACKET_INPUT_VERSION = 1;
8
- export const DOMAIN_CHANGE_PACKET_VERSION = 2;
8
+ export const DOMAIN_CHANGE_PACKET_VERSION = 3;
9
+ export const DOMAIN_CHANGE_PACKET_REVIEW_VERSIONS = Object.freeze({
10
+ 2: 1,
11
+ [DOMAIN_CHANGE_PACKET_VERSION]: DOMAIN_CHANGE_REVIEW_VERSION,
12
+ });
13
+ export const SUPPORTED_DOMAIN_CHANGE_PACKET_VERSIONS = Object.freeze(Object.keys(DOMAIN_CHANGE_PACKET_REVIEW_VERSIONS).map(Number));
9
14
  export const MAX_DOMAIN_CHANGE_PACKET_INPUT_BYTES = 6 * 1024 * 1024;
10
15
  const ROOT_KEYS = new Set([
11
16
  'schema',
@@ -1,8 +1,9 @@
1
1
  import { exactKeys, requireBoundedString, requireDomainName, requireIsoTimestamp, requireRecord, } from "./bounded-contract-normalizers.mjs";
2
2
  import { normaliseRdata } from "./zone-intent-review.mjs";
3
3
  export const DOMAIN_CHANGE_INPUT_SCHEMA = 'whoisleuth.domain-change.input';
4
+ export const DOMAIN_CHANGE_INPUT_VERSION = 1;
4
5
  export const DOMAIN_CHANGE_REVIEW_SCHEMA = 'whoisleuth.domain-change.review';
5
- export const DOMAIN_CHANGE_REVIEW_VERSION = 1;
6
+ export const DOMAIN_CHANGE_REVIEW_VERSION = 2;
6
7
  export const MAX_DOMAIN_CHANGE_VANTAGES = 16;
7
8
  export const MAX_DOMAIN_CHANGE_RECORDS = 500;
8
9
  const ROOT_KEYS = new Set(['schema', 'version', 'domain', 'authoritySnapshots', 'resolverSnapshots', 'acmeDependencies', 'certificate', 'hsts']);
@@ -89,6 +90,9 @@ function normaliseSnapshots(value, apex, label) {
89
90
  throw new TypeError(`${label} labels must be unique.`);
90
91
  return Object.freeze(snapshots);
91
92
  }
93
+ function snapshotProvenance(snapshot) {
94
+ return Object.freeze({ label: snapshot.label, source: snapshot.source, state: snapshot.state, observedAt: snapshot.observedAt });
95
+ }
92
96
  function recordMatrix(snapshots) {
93
97
  const keys = [...new Set(snapshots.flatMap((snapshot) => snapshot.records.map((item) => `${item.owner}\u0000${item.type}`)))].sort();
94
98
  return Object.freeze(keys.map((key) => {
@@ -97,9 +101,7 @@ function recordMatrix(snapshots) {
97
101
  const matches = snapshot.records.filter((item) => item.owner === recordOwner && item.type === type);
98
102
  const ttls = matches.map((item) => item.ttl).filter((ttl) => ttl !== null);
99
103
  return Object.freeze({
100
- label: snapshot.label,
101
- source: snapshot.source,
102
- state: snapshot.state,
104
+ ...snapshotProvenance(snapshot),
103
105
  values: Object.freeze([...new Set(matches.map((item) => item.value))].sort()),
104
106
  ttlRange: ttls.length ? Object.freeze({
105
107
  minimum: Math.min(...ttls),
@@ -200,7 +202,7 @@ function normaliseCertificate(value) {
200
202
  }
201
203
  function reviewCertificate(certificate) {
202
204
  if (!certificate)
203
- return Object.freeze({ state: 'not_supplied', continuity: 'unknown', findings: Object.freeze([]) });
205
+ return Object.freeze({ state: 'not_supplied', observedAt: null, continuity: 'unknown', findings: Object.freeze([]) });
204
206
  const continuity = !certificate.currentSpkiSha256 || !certificate.plannedSpkiSha256
205
207
  ? 'unknown'
206
208
  : certificate.currentSpkiSha256 === certificate.plannedSpkiSha256 ? 'retained' : 'changes';
@@ -210,7 +212,7 @@ function reviewCertificate(certificate) {
210
212
  ...(certificate.embeddedSctCount === 0
211
213
  ? ['No embedded certificate-transparency timestamp was observed; this does not establish that the certificate was not logged by another mechanism.'] : []),
212
214
  ];
213
- return Object.freeze({ state: certificate.state, continuity, findings: Object.freeze(findings) });
215
+ return Object.freeze({ state: certificate.state, observedAt: certificate.observedAt, continuity, findings: Object.freeze(findings) });
214
216
  }
215
217
  function normaliseHsts(value) {
216
218
  if (value === null || value === undefined)
@@ -236,8 +238,8 @@ function serviceInventory(snapshots) {
236
238
  }
237
239
  export function reviewDomainChange(inputRaw, generatedAtValue = new Date().toISOString()) {
238
240
  const input = requireRecord(inputRaw, 'Domain change input');
239
- if (input.schema !== DOMAIN_CHANGE_INPUT_SCHEMA || input.version !== 1)
240
- throw new TypeError(`Domain change input must use ${DOMAIN_CHANGE_INPUT_SCHEMA} version 1.`);
241
+ if (input.schema !== DOMAIN_CHANGE_INPUT_SCHEMA || input.version !== DOMAIN_CHANGE_INPUT_VERSION)
242
+ throw new TypeError(`Domain change input must use ${DOMAIN_CHANGE_INPUT_SCHEMA} version ${DOMAIN_CHANGE_INPUT_VERSION}.`);
241
243
  exactKeys(input, ROOT_KEYS, 'Domain change input');
242
244
  const apex = domain(input.domain, 'domain');
243
245
  const authoritySnapshots = normaliseSnapshots(input.authoritySnapshots ?? [], apex, 'authoritySnapshots');
@@ -285,6 +287,10 @@ export function reviewDomainChange(inputRaw, generatedAtValue = new Date().toISO
285
287
  generatedAt: timestamp(generatedAtValue, 'generatedAt'),
286
288
  domain: apex,
287
289
  state: reviewRequired ? 'review' : 'ready',
290
+ sourceObservations: Object.freeze({
291
+ authorities: Object.freeze(authoritySnapshots.map(snapshotProvenance)),
292
+ resolvers: Object.freeze(resolverSnapshots.map(snapshotProvenance)),
293
+ }),
288
294
  authoritativeRecordMatrix: authorityMatrix,
289
295
  resolverDivergenceMatrix: resolverMatrix,
290
296
  dnssecAutomation: reviewDnssecAutomation(authorityMatrix),