@slicedearth/whoisleuth-cli 2.2.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 (282) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +20 -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 +454 -1748
  7. package/cli/artifact-validation/case-response.mjs +66 -35
  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 +112 -72
  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-checkpoint.mjs +34 -3
  18. package/cli/bulk-command-runner.mjs +2 -0
  19. package/cli/bulk-output.mjs +29 -3
  20. package/cli/case-command.mjs +195 -0
  21. package/cli/case-pack.mjs +66 -111
  22. package/cli/command-argument-grammar.mjs +215 -0
  23. package/cli/command-reference.mjs +342 -285
  24. package/cli/completion.mjs +303 -80
  25. package/cli/config-profile.mjs +7 -5
  26. package/cli/ct-event-intake.mjs +12 -12
  27. package/cli/discover.mjs +4 -3
  28. package/cli/discovery-observation-snapshot.mjs +59 -18
  29. package/cli/discovery-scan-command-runner.mjs +21 -0
  30. package/cli/discovery-scan.mjs +13 -4
  31. package/cli/discovery-workflow.mjs +1 -1
  32. package/cli/domain-control-monitor.mjs +50 -20
  33. package/cli/domain-control-observations.mjs +149 -102
  34. package/cli/errors.mjs +33 -1
  35. package/cli/evidence-command-runner.mjs +16 -5
  36. package/cli/evidence-signing.mjs +2 -2
  37. package/cli/external-observation-mapping.mjs +7 -8
  38. package/cli/formatters/terminal-lookup.mjs +18 -46
  39. package/cli/formatters/terminal-shared.mjs +5 -3
  40. package/cli/formatters/terminal-technology.mjs +114 -0
  41. package/cli/inline-command-families.mjs +2 -0
  42. package/cli/input.mjs +116 -0
  43. package/cli/interactive-launcher.mjs +2 -160
  44. package/cli/interchange-report.mjs +18 -8
  45. package/cli/investigation-artifacts.mjs +176 -0
  46. package/cli/investigation-checkpoint.mjs +10 -0
  47. package/cli/investigation-folder.mjs +114 -0
  48. package/cli/investigation-manifest.mjs +1 -112
  49. package/cli/investigation-package-review.mjs +103 -0
  50. package/cli/investigation-plan.mjs +32 -19
  51. package/cli/investigation-run.mjs +188 -33
  52. package/cli/local-document-checkpoint.mjs +170 -0
  53. package/cli/lookalike-calibration.mjs +7 -2
  54. package/cli/lookup-brief.mjs +4 -2
  55. package/cli/lookup-browser-view.mjs +1 -1
  56. package/cli/lookup-browser.mjs +5 -12
  57. package/cli/lookup-command-runner.mjs +17 -6
  58. package/cli/lookup-diff.mjs +37 -3
  59. package/cli/lookup-plan.mjs +11 -5
  60. package/cli/lookup-reconcile.mjs +10 -8
  61. package/cli/mail-header-review.mjs +467 -0
  62. package/cli/mail-review.mjs +66 -14
  63. package/cli/manual.mjs +1 -0
  64. package/cli/network-command-runner.mjs +2 -0
  65. package/cli/output-file.mjs +31 -5
  66. package/cli/registry-doctor.mjs +3 -1
  67. package/cli/registry-support.mjs +1 -1
  68. package/cli/retained-artifact-diff.mjs +2 -2
  69. package/cli/review-command-runner.mjs +70 -0
  70. package/cli/risk-calibration.mjs +19 -17
  71. package/cli/runner.mjs +62 -7
  72. package/cli/sharing-review.mjs +21 -34
  73. package/cli/signer-trust.mjs +104 -0
  74. package/cli/source-reliability.mjs +74 -83
  75. package/cli/terminal-input.mjs +168 -0
  76. package/cli/workflow-command-runner.mjs +68 -13
  77. package/docs/cli-reference.md +77 -24
  78. package/docs/cli.md +300 -25
  79. package/lib/abort.mjs +4 -1
  80. package/lib/availability.mjs +161 -112
  81. package/lib/bounded-file.mjs +3 -1
  82. package/lib/bounded-html-document.mjs +125 -0
  83. package/lib/bounded-json.mjs +51 -8
  84. package/lib/browser-library-profile.mjs +42 -51
  85. package/lib/browser-library-worker.mjs +112 -0
  86. package/lib/client-behavior-profile.mjs +3 -3
  87. package/lib/ct-search.mjs +11 -4
  88. package/lib/dns-convergence-review.mjs +1 -0
  89. package/lib/dns-delegation-health.mjs +24 -14
  90. package/lib/dns-inheritance-review.mjs +217 -0
  91. package/lib/dns-intelligence.mjs +73 -26
  92. package/lib/dns-mx.mjs +1 -1
  93. package/lib/dnssec-chain-validation.mjs +6 -0
  94. package/lib/dnssec-evidence-validation.mjs +11 -3
  95. package/lib/domain-change-packet.mjs +49 -16
  96. package/lib/domain-change-review.mjs +14 -8
  97. package/lib/domain-control-flight-recorder.mjs +221 -117
  98. package/lib/domain-control-manifest.mjs +129 -69
  99. package/lib/domain-posture-analysis.mjs +20 -7
  100. package/lib/domain-posture-parsers.mjs +201 -8
  101. package/lib/domain-posture.mjs +147 -29
  102. package/lib/evidence-export-privacy.mjs +561 -0
  103. package/lib/evidence-export.mjs +11 -543
  104. package/lib/evidence-report.mjs +5 -3
  105. package/lib/favicon.mjs +117 -41
  106. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  107. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  108. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  109. package/lib/hostname.mjs +2 -38
  110. package/lib/html-signals.mjs +111 -108
  111. package/lib/http-evidence-bounds.mjs +2 -1
  112. package/lib/http-intelligence.mjs +39 -26
  113. package/lib/local-geoip-evidence.mjs +19 -3
  114. package/lib/lookup-cache.mjs +34 -11
  115. package/lib/lookup-child-profile-contract.mjs +24 -15
  116. package/lib/lookup-diagnostics.mjs +24 -1
  117. package/lib/lookup-progress.mjs +30 -13
  118. package/lib/lookup-response-contract.mjs +1048 -0
  119. package/lib/lookup-response.mjs +8 -10
  120. package/lib/lookup-source-progress.mjs +40 -14
  121. package/lib/lookup.mjs +48 -27
  122. package/lib/nameserver-preflight-review.mjs +1 -0
  123. package/lib/observed-network-context.mjs +43 -27
  124. package/lib/outbound-request-bounds.mjs +4 -1
  125. package/lib/page-fingerprints.mjs +69 -133
  126. package/lib/page-role-profile.mjs +12 -8
  127. package/lib/perceptual-hash.mjs +43 -14
  128. package/lib/rdap-bootstrap.mjs +72 -22
  129. package/lib/rdap-capabilities.mjs +1 -1
  130. package/lib/rdap-client.mjs +15 -7
  131. package/lib/rdap-normalization.mjs +33 -18
  132. package/lib/rdap-search-workbench.mjs +1 -1
  133. package/lib/rdap-transport.mjs +47 -20
  134. package/lib/rdap-types.mjs +2 -1
  135. package/lib/rdap.mjs +18 -3
  136. package/lib/registrable-domain.mjs +21 -1
  137. package/lib/registrar-standing-contract.mjs +1 -0
  138. package/lib/registry-capabilities.mjs +5 -1
  139. package/lib/registry-capability-catalogue.mjs +2 -1
  140. package/lib/registry-dates.mjs +2 -190
  141. package/lib/registry-insights.mjs +85 -34
  142. package/lib/response-policy.mjs +86 -48
  143. package/lib/risk-calibration-summary.mjs +18 -6
  144. package/lib/rpki-evidence.mjs +8 -5
  145. package/lib/safe-fetch.mjs +54 -47
  146. package/lib/security-txt.mjs +42 -7
  147. package/lib/service-binding-dns.mjs +8 -0
  148. package/lib/smtp-transport-review.mjs +27 -15
  149. package/lib/sslbl-certificates.generated.mjs +136 -135
  150. package/lib/static-html-analysis.mjs +178 -151
  151. package/lib/structured-data-identity.mjs +1 -0
  152. package/lib/technology-markup.mjs +100 -0
  153. package/lib/threat-intelligence-manifests.mjs +11 -139
  154. package/lib/threat-intelligence-runtime.mjs +24 -52
  155. package/lib/threat-intelligence-targets.mjs +6 -34
  156. package/lib/threat-intelligence-types.mjs +39 -0
  157. package/lib/typosquat-generator.mjs +19 -15
  158. package/lib/urlhaus-intelligence.mjs +8 -3
  159. package/lib/urlscan-intelligence.mjs +1 -1
  160. package/lib/website-security-posture.mjs +30 -6
  161. package/lib/website-technology.mjs +153 -45
  162. package/lib/whois-authority.mjs +14 -2
  163. package/lib/whois-chain.mjs +9 -2
  164. package/lib/whois-common-formats.mjs +5 -5
  165. package/lib/whois-contracts.mjs +2 -1
  166. package/lib/whois-dialects.mjs +2 -2
  167. package/lib/whois-parser-support.mjs +8 -1
  168. package/lib/whois-transport.mjs +23 -7
  169. package/lib/zone-intent-review.mjs +16 -6
  170. package/package.json +2 -1
  171. package/packages/cases/case-attachment-model.mjs +83 -0
  172. package/packages/cases/case-evidence-model.mjs +156 -62
  173. package/packages/cases/case-export-input.mjs +53 -0
  174. package/packages/cases/case-migration-model.mjs +127 -44
  175. package/packages/cases/case-model.mjs +1 -0
  176. package/packages/cases/case-recheck-model.mjs +91 -0
  177. package/packages/cases/case-record-contracts.mjs +5 -20
  178. package/packages/cases/case-record-core.mjs +12 -22
  179. package/packages/cases/case-record-decisions.mjs +72 -0
  180. package/packages/cases/case-record-model.mjs +1 -0
  181. package/packages/cases/case-record-operations.mjs +95 -31
  182. package/packages/cases/case-record-projection.mjs +221 -0
  183. package/packages/cases/case-report.mjs +70 -54
  184. package/packages/cases/case-response-actions.mjs +654 -0
  185. package/packages/cases/case-response-model.mjs +64 -1115
  186. package/packages/cases/case-response-outcomes.mjs +317 -0
  187. package/packages/cases/case-response-packet-render.mjs +153 -0
  188. package/packages/cases/case-response-packet-types.mjs +1 -0
  189. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  190. package/packages/cases/case-response-packet.mjs +74 -572
  191. package/packages/cases/case-response-records.mjs +94 -0
  192. package/packages/cases/case-response-review-inputs.mjs +371 -0
  193. package/packages/cases/case-response-values.mjs +85 -0
  194. package/packages/cases/case-review-package.mjs +45 -0
  195. package/packages/cases/case-selection.mjs +46 -0
  196. package/packages/cases/case-storage-model.mjs +31 -2
  197. package/packages/cases/response-route-freshness.mjs +16 -0
  198. package/packages/comparison/capture-context.mjs +61 -0
  199. package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
  200. package/packages/comparison/page-similarity.mjs +5 -2
  201. package/packages/comparison/relationship-evidence.mjs +85 -8
  202. package/packages/comparison/relationship-provenance.mjs +93 -0
  203. package/packages/contracts/analyst-interchange.mjs +216 -0
  204. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  205. package/packages/contracts/analyst-review-state.mjs +5 -4
  206. package/packages/contracts/case-portability.mjs +366 -83
  207. package/packages/contracts/case-views-contract.mjs +12 -0
  208. package/packages/contracts/case-views.mjs +26 -0
  209. package/packages/contracts/cli-command-semantics.mjs +94 -0
  210. package/packages/contracts/cli-lookup.mjs +0 -1
  211. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  212. package/packages/contracts/domain-control-manifest.mjs +127 -73
  213. package/packages/contracts/domain-control-monitor.mjs +48 -45
  214. package/packages/contracts/domain-control-review.mjs +179 -121
  215. package/packages/contracts/domain-name.mjs +21 -0
  216. package/packages/contracts/external-observation-interchange.mjs +2 -1
  217. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  218. package/packages/contracts/http-url.mjs +2 -0
  219. package/packages/contracts/investigation-package-limits.mjs +24 -0
  220. package/packages/contracts/investigation-portability.mjs +56 -27
  221. package/packages/contracts/investigation-run.mjs +16 -0
  222. package/packages/contracts/ip-address.mjs +34 -0
  223. package/packages/contracts/lookup-evidence.mjs +35 -0
  224. package/packages/contracts/offline-comparison.mjs +1 -2
  225. package/packages/contracts/page-fingerprints.mjs +15 -0
  226. package/packages/contracts/risk-calibration.mjs +15 -11
  227. package/packages/contracts/schema-compatibility.mjs +1 -1
  228. package/packages/contracts/schema-lifecycle.mjs +83 -153
  229. package/packages/contracts/selected-file-limits.mjs +6 -0
  230. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  231. package/packages/contracts/web-capture.mjs +10 -0
  232. package/packages/contracts/workspace-portability.mjs +183 -211
  233. package/packages/evidence/artifact-integrity.mjs +8 -2
  234. package/packages/evidence/artifact-structure.mjs +105 -0
  235. package/packages/evidence/decision-fact.mjs +4 -1
  236. package/packages/evidence/domain-control-runtime.mjs +201 -33
  237. package/packages/evidence/domain-posture-context.mjs +79 -0
  238. package/packages/evidence/image-regions.mjs +56 -0
  239. package/packages/evidence/latest-observations.mjs +28 -0
  240. package/packages/evidence/lookup-target.mjs +104 -0
  241. package/packages/evidence/observation.mjs +16 -1
  242. package/packages/evidence/passphrase-encryption.mjs +44 -0
  243. package/packages/evidence/record-identity.mjs +72 -0
  244. package/packages/evidence/registry-dates.mjs +190 -0
  245. package/packages/evidence/retained-file.mjs +69 -0
  246. package/packages/evidence/review-age.mjs +14 -0
  247. package/packages/interchange/bagit.mjs +342 -0
  248. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  249. package/packages/interchange/crc32.mjs +17 -0
  250. package/packages/interchange/external-findings-import.mjs +92 -51
  251. package/packages/interchange/web-capture-import.mjs +382 -0
  252. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  253. package/packages/investigation/investigation-bagit.mjs +14 -0
  254. package/packages/investigation/investigation-capsule.mjs +33 -5
  255. package/packages/investigation/investigation-manifest.mjs +200 -0
  256. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  257. package/packages/investigation/investigation-package.mjs +200 -0
  258. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  259. package/packages/investigation/lookup-asset-graph.mjs +193 -165
  260. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  261. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  262. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  263. package/packages/monitoring/analyst-review-state.mjs +38 -90
  264. package/packages/workspace/brand-profile-model.mjs +276 -22
  265. package/packages/workspace/bulk-columns.mjs +17 -0
  266. package/packages/workspace/bulk-review-model.mjs +23 -11
  267. package/packages/workspace/bulk-session-model.mjs +100 -24
  268. package/packages/workspace/bulk-sort.mjs +1 -0
  269. package/packages/workspace/case-views.mjs +102 -0
  270. package/packages/workspace/detection-rule-model.mjs +70 -22
  271. package/packages/workspace/hostile-input.mjs +29 -8
  272. package/packages/workspace/investigation-template-model.mjs +19 -4
  273. package/packages/workspace/page-baseline.mjs +18 -15
  274. package/packages/workspace/relationship-observation-model.mjs +41 -23
  275. package/packages/workspace/watchlist-history.mjs +2 -2
  276. package/packages/workspace/watchlist-store.mjs +8 -2
  277. package/packages/workspace/website-snapshot-model.mjs +164 -26
  278. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  279. package/packages/workspace/workspace-archive.mjs +87 -21
  280. package/third-party-notices.txt +29 -2
  281. package/cli/evidence-command-arguments.mjs +0 -156
  282. 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,12 +183,17 @@ 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);
192
- const tag = String(entry.tag || '').trim().toLowerCase();
193
- const value = String(entry.value || '').trim();
190
+ const nodeTags = ['issue', 'issuewild', 'iodef'].filter((tag) => Object.hasOwn(entry, tag));
191
+ const hasPair = Object.hasOwn(entry, 'tag') || Object.hasOwn(entry, 'value');
192
+ const tag = hasPair
193
+ ? String(entry.tag || '').trim().toLowerCase()
194
+ : nodeTags.length === 1 ? nodeTags[0] : '';
195
+ const rawValue = hasPair ? entry.value : tag ? entry[tag] : undefined;
196
+ const value = typeof rawValue === 'string' ? rawValue.trim() : '';
194
197
  if (!Number.isInteger(critical) || critical < 0 || critical > 255 || !/^[a-z0-9-]{1,15}$/.test(tag) || !value || value.length > MAX_POLICY_LENGTH || /[\u0000-\u001f\u007f]/.test(value)) {
195
198
  discarded += 1;
196
199
  continue;
@@ -390,10 +393,11 @@ function withTimeout(factory, timeoutMs) {
390
393
  async function query(factory, normalize, timeoutMs) {
391
394
  try {
392
395
  const normalized = normalize(await withTimeout(factory, timeoutMs));
396
+ const unusable = !normalized.records.length && (normalized.truncated || normalized.discarded > 0);
393
397
  return {
394
- status: normalized.records.length ? 'success' : 'not_found',
398
+ status: unusable ? 'error' : normalized.records.length ? 'success' : 'not_found',
395
399
  records: normalized.records,
396
- error: null,
400
+ error: unusable ? 'The returned record family could not be retained completely; absence is unknown.' : null,
397
401
  truncated: normalized.truncated,
398
402
  discarded: normalized.discarded || 0,
399
403
  };
@@ -404,6 +408,12 @@ async function query(factory, normalize, timeoutMs) {
404
408
  return { status: 'error', records: [], error: boundedError(error), truncated: false, discarded: 0 };
405
409
  }
406
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
+ }
407
417
  async function collectEffectiveCaaPolicy(domain, options = {}) {
408
418
  const normalizedDomain = normalizeHostname(domain);
409
419
  const queryLimit = Math.max(1, Math.min(MAX_CAA_TREE_QUERIES, Math.floor(options.queryLimit || MAX_CAA_TREE_QUERIES)));
@@ -454,7 +464,7 @@ async function collectEffectiveCaaPolicy(domain, options = {}) {
454
464
  truncated: result.truncated,
455
465
  discarded: result.discarded,
456
466
  });
457
- if (result.status === 'error') {
467
+ if (result.status === 'error' && !result.truncated && result.discarded === 0) {
458
468
  return {
459
469
  ...createObservation({
460
470
  status: index === 0 ? 'error' : 'partial',
@@ -511,6 +521,35 @@ async function collectEffectiveCaaPolicy(domain, options = {}) {
511
521
  records: result.records,
512
522
  };
513
523
  }
524
+ if (result.truncated || result.discarded > 0) {
525
+ return {
526
+ ...createObservation({
527
+ status: 'partial',
528
+ observedAt: (options.observedAt || (() => new Date().toISOString()))(),
529
+ scanMode: 'deep',
530
+ source: 'dns',
531
+ durationMs: Math.max(0, now() - started),
532
+ complete: false,
533
+ truncated: result.truncated,
534
+ limitations,
535
+ diagnostics: {
536
+ tree: {
537
+ status: 'partial',
538
+ detail: `The CAA answer at ${owner} contained discarded or truncated records, so parent inheritance was not evaluated.`,
539
+ count: queries.length,
540
+ truncated: result.truncated,
541
+ discarded: result.discarded,
542
+ },
543
+ },
544
+ }),
545
+ policyVersion: 1,
546
+ queryLimit,
547
+ queriedOwners: queries,
548
+ effectiveOwner: null,
549
+ inherited: null,
550
+ records: [],
551
+ };
552
+ }
514
553
  }
515
554
  return {
516
555
  ...createObservation({
@@ -542,6 +581,10 @@ async function collectEffectiveCaaPolicy(domain, options = {}) {
542
581
  };
543
582
  }
544
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
+ }
545
588
  const resolvers = options.resolvers || {};
546
589
  const includeExtendedContext = options.includeExtendedContext === true;
547
590
  const includeInheritedCaa = options.includeInheritedCaa === true;
@@ -572,8 +615,11 @@ async function collectDnsIntelligence(domain, options = {}) {
572
615
  const httpsPromise = includeExtendedContext
573
616
  ? query(invoke('resolveHttps', (value) => resolveServiceBindingRecords(value, 'HTTPS', { timeoutMs })), normalizeServiceBindings, timeoutMs)
574
617
  : Promise.resolve(null);
618
+ const registrationNsPromise = includeExtendedContext && registrationDomain !== domain
619
+ ? query(invoke('resolveNs', dns.resolveNs, registrationDomain), normalizeHostnames, timeoutMs)
620
+ : nsPromise;
575
621
  const delegationPromise = includeExtendedContext
576
- ? nsPromise.then((parentNameservers) => collectDnsDelegationHealth(domain, parentNameservers, {
622
+ ? registrationNsPromise.then((parentNameservers) => collectDnsDelegationHealth(registrationDomain, parentNameservers, {
577
623
  registryEvidence: options.registryEvidence,
578
624
  resolve4: (resolvers.resolve4 || dns.resolve4),
579
625
  resolve6: (resolvers.resolve6 || dns.resolve6),
@@ -611,7 +657,8 @@ async function collectDnsIntelligence(domain, options = {}) {
611
657
  || discardedCount > 0
612
658
  || (caaPolicy !== null && caaPolicy.complete !== true)
613
659
  || (delegation !== null && delegation.status !== 'success');
614
- const classifiedMx = mx.status === 'error' ? null : classifyMxRecords(mx.records);
660
+ const classifiedMx = classifyMxRecords(mx.records);
661
+ const mxComplete = completeQuery(mx);
615
662
  return {
616
663
  ...createObservation({
617
664
  status: errorCount === values.length ? 'error' : incomplete ? 'partial' : 'success',
@@ -669,11 +716,11 @@ async function collectDnsIntelligence(domain, options = {}) {
669
716
  ...(soa ? { soa: soa.records } : {}),
670
717
  ...(https ? { https: https.records } : {}),
671
718
  },
672
- hasMx: classifiedMx ? classifiedMx.hasMx : null,
673
- hasNullMx: classifiedMx ? classifiedMx.hasNullMx : null,
674
- mxHosts: classifiedMx ? classifiedMx.mxHosts : [],
675
- hasSpf: spf.status === 'error' ? null : spf.records.length > 0,
676
- 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),
677
724
  delegation,
678
725
  ...(caaPolicy ? { caaPolicy } : {}),
679
726
  };
@@ -706,7 +753,7 @@ async function collectReverseDnsIntelligence(address, options = {}) {
706
753
  const timeoutMs = options.timeoutMs || DNS_TIMEOUT_MS;
707
754
  const now = options.now || Date.now;
708
755
  const started = now();
709
- const ptr = await query(() => (options.resolver || dns.resolvePtr)(address), normalizePtr, timeoutMs);
756
+ const ptr = await query(() => (options.resolver || dns.reverse)(address), normalizePtr, timeoutMs);
710
757
  const incomplete = ptr.status === 'error' || ptr.truncated || ptr.discarded > 0;
711
758
  return {
712
759
  ...createObservation({
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,
@@ -718,6 +718,9 @@ function validateNegativeProof(response, name, missingType, keys, zone, now, req
718
718
  const verification = verifyRrset(response, exactNsec.owner, DNS_TYPE_NSEC, keys, zone, now);
719
719
  if (verification.state !== 'valid')
720
720
  return verification;
721
+ if (exactNsec.data.types.has(DNS_TYPE_CNAME)) {
722
+ return { state: 'unsupported', detail: 'The validated NSEC owner is an alias; the target RRset cannot be treated as authenticated absence without a validated alias walk.' };
723
+ }
721
724
  const delegation = exactNsec.data.types.has(DNS_TYPE_NS) && !exactNsec.data.types.has(DNS_TYPE_SOA);
722
725
  if (!exactNsec.data.types.has(missingType) && (!requireDelegation || delegation)) {
723
726
  return { state: 'valid', detail: 'A validated NSEC record proved the requested RRset absent.' };
@@ -758,6 +761,9 @@ function validateNegativeProof(response, name, missingType, keys, zone, now, req
758
761
  sawBogus = true;
759
762
  continue;
760
763
  }
764
+ if (exact && record.data.types.has(DNS_TYPE_CNAME)) {
765
+ return { state: 'unsupported', detail: 'The validated NSEC3 owner is an alias; the target RRset cannot be treated as authenticated absence without a validated alias walk.' };
766
+ }
761
767
  if (exact && record.data.types.has(missingType))
762
768
  return { state: 'bogus', detail: 'The validated NSEC3 bitmap contains the RR type reported as absent.' };
763
769
  if (requireDelegation && exact && (!record.data.types.has(DNS_TYPE_NS) || record.data.types.has(DNS_TYPE_SOA)))
@@ -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