@slicedearth/whoisleuth-cli 2.4.0 → 2.6.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 (340) hide show
  1. package/DISCLOSURE +1 -1
  2. package/NOTICE +2 -2
  3. package/cli/argument-values.mjs +55 -0
  4. package/cli/arguments.mjs +24 -470
  5. package/cli/artifact-validation/case-response.mjs +67 -9
  6. package/cli/artifact-validation/lookup-evidence.mjs +18 -4
  7. package/cli/artifact-validation/signed-review.mjs +61 -64
  8. package/cli/artifact-verify.mjs +11 -2
  9. package/cli/assurance-arguments.mjs +56 -0
  10. package/cli/assurance-command-definitions.mjs +179 -0
  11. package/cli/assurance-command-runner.mjs +1 -1
  12. package/cli/bounded-json.mjs +1 -1
  13. package/cli/bulk-checkpoint.mjs +1 -1
  14. package/cli/bulk-command-runner.mjs +6 -8
  15. package/cli/bulk.mjs +2 -3
  16. package/cli/case-command.mjs +99 -6
  17. package/cli/case-pack.mjs +19 -4
  18. package/cli/ci-report.mjs +6 -3
  19. package/cli/collection-arguments.mjs +123 -0
  20. package/cli/collection-command-definitions.mjs +330 -0
  21. package/cli/collection-preflight.mjs +8 -1
  22. package/cli/command-argument-grammar.mjs +8 -210
  23. package/cli/command-definition.mjs +330 -0
  24. package/cli/command-reference.mjs +55 -1388
  25. package/cli/command-seeds.mjs +18 -0
  26. package/cli/compare.mjs +1 -1
  27. package/cli/config-profile.mjs +1 -1
  28. package/cli/ct-event-intake.mjs +4 -4
  29. package/cli/discover.mjs +2 -2
  30. package/cli/discovery-observation-snapshot.mjs +2 -2
  31. package/cli/discovery-scan-command-runner.mjs +7 -9
  32. package/cli/discovery-scan.mjs +1 -1
  33. package/cli/discovery-snapshot.mjs +2 -2
  34. package/cli/discovery-workflow.mjs +4 -4
  35. package/cli/domain-control-monitor.mjs +6 -6
  36. package/cli/domain-control-observations.mjs +2 -1
  37. package/cli/domain-feed.mjs +61 -0
  38. package/cli/errors.mjs +5 -6
  39. package/cli/evidence-arguments.mjs +46 -0
  40. package/cli/evidence-command-definitions.mjs +69 -0
  41. package/cli/evidence-command-runner.mjs +2 -3
  42. package/cli/evidence-signing.mjs +3 -2
  43. package/cli/external-observation-mapping.mjs +2 -2
  44. package/cli/formatters/json.mjs +1 -13
  45. package/cli/formatters/terminal-command-formats.mjs +6 -0
  46. package/cli/formatters/terminal-lookup.mjs +6 -0
  47. package/cli/history-arguments.mjs +39 -0
  48. package/cli/history-command-definitions.mjs +88 -0
  49. package/cli/indicator-set-command.mjs +32 -0
  50. package/cli/inline-command-families.mjs +6 -47
  51. package/cli/inline-command-runner.mjs +4 -31
  52. package/cli/input.mjs +2 -2
  53. package/cli/intake-command.mjs +147 -0
  54. package/cli/interchange-report.mjs +4 -2
  55. package/cli/investigation-artifacts.mjs +3 -4
  56. package/cli/investigation-package-review.mjs +2 -0
  57. package/cli/investigation-plan.mjs +32 -154
  58. package/cli/investigation-recipes.mjs +177 -0
  59. package/cli/investigation-run.mjs +78 -12
  60. package/cli/local-mmdb-review.mjs +220 -17
  61. package/cli/lookalike-calibration.mjs +1 -1
  62. package/cli/lookup-brief.mjs +2 -2
  63. package/cli/lookup-command-runner.mjs +19 -35
  64. package/cli/lookup-diff.mjs +10 -9
  65. package/cli/lookup-plan.mjs +10 -14
  66. package/cli/lookup-reconcile.mjs +6 -5
  67. package/cli/lookup-timeline.mjs +7 -6
  68. package/cli/mail-header-review.mjs +24 -247
  69. package/cli/mail-review.mjs +9 -7
  70. package/cli/network-arguments.mjs +51 -0
  71. package/cli/network-command-definitions.mjs +130 -0
  72. package/cli/network-command-runner.mjs +43 -31
  73. package/cli/offline-evidence-review.mjs +85 -6
  74. package/cli/posture.mjs +1 -1
  75. package/cli/registry-fixture-scaffold.mjs +1 -1
  76. package/cli/retained-artifact-diff.mjs +8 -7
  77. package/cli/review-arguments.mjs +132 -0
  78. package/cli/review-command-definitions.mjs +279 -0
  79. package/cli/review-command-runner.mjs +72 -159
  80. package/cli/risk-calibration.mjs +14 -12
  81. package/cli/runner.mjs +20 -32
  82. package/cli/saved-lookup.mjs +2 -2
  83. package/cli/selected-input-worker.mjs +73 -0
  84. package/cli/sharing-review.mjs +5 -64
  85. package/cli/source-reliability.mjs +2 -2
  86. package/cli/support-arguments.mjs +49 -0
  87. package/cli/support-command-definitions.mjs +209 -0
  88. package/cli/support-command-runner.mjs +4 -4
  89. package/cli/watchlist-review.mjs +43 -0
  90. package/cli/workflow-arguments.mjs +100 -0
  91. package/cli/workflow-command-definitions.mjs +103 -0
  92. package/cli/workflow-command-runner.mjs +40 -1
  93. package/docs/cli-reference.md +27 -0
  94. package/docs/cli.md +322 -4
  95. package/docs/contextual-reviews.md +178 -0
  96. package/docs/infrastructure-observations.md +126 -0
  97. package/docs/offline-intake-context.md +213 -0
  98. package/lib/analyst-taxonomy.mjs +2 -40
  99. package/lib/application-version.mjs +1 -1
  100. package/lib/availability.mjs +13 -789
  101. package/lib/bounded-contract-normalizers.mjs +4 -0
  102. package/lib/bounded-json.mjs +2 -330
  103. package/lib/browser-library-profile.mjs +62 -84
  104. package/lib/candidate-provenance-bounds.mjs +2 -0
  105. package/lib/classify.mjs +1 -1
  106. package/lib/credential-form-attribution.mjs +136 -0
  107. package/lib/credential-surface-profile.mjs +6 -2
  108. package/lib/ct-query.mjs +2 -27
  109. package/lib/ct-response-bounds.mjs +2 -9
  110. package/lib/ct-search.mjs +83 -47
  111. package/lib/dmarc-discovery.mjs +62 -0
  112. package/lib/dns-convergence-review.mjs +21 -8
  113. package/lib/dns-delegation-health.mjs +35 -15
  114. package/lib/dns-inheritance-review.mjs +53 -63
  115. package/lib/dns-intelligence.mjs +27 -11
  116. package/lib/dns-query-coverage.mjs +40 -0
  117. package/lib/domain-assurance.mjs +1 -1
  118. package/lib/domain-change-packet.mjs +12 -13
  119. package/lib/domain-change-review.mjs +32 -14
  120. package/lib/domain-control-flight-recorder.mjs +103 -27
  121. package/lib/domain-control-manifest.mjs +25 -17
  122. package/lib/domain-posture-analysis.mjs +75 -7
  123. package/lib/domain-posture-parsers.mjs +9 -0
  124. package/lib/domain-posture.mjs +45 -25
  125. package/lib/domain-registration.mjs +392 -0
  126. package/lib/evidence-export-privacy.mjs +27 -17
  127. package/lib/evidence-export.mjs +3 -3
  128. package/lib/evidence-report.mjs +5 -1
  129. package/lib/external-intelligence-risk.mjs +2 -124
  130. package/lib/favicon.mjs +13 -4
  131. package/lib/generated/cisa-kev-catalog.mjs +41 -3
  132. package/lib/generated/registrar-standing-catalogue.mjs +18 -10
  133. package/lib/generated/retire-browser-catalog.mjs +332 -19
  134. package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
  135. package/lib/html-signals.mjs +13 -2
  136. package/lib/http-evidence-bounds.mjs +2 -8
  137. package/lib/http-intelligence.mjs +13 -1
  138. package/lib/idn-confusable-policy.mjs +6 -4
  139. package/lib/idn-confusables.mjs +1 -1
  140. package/lib/interchange-fidelity-registry.mjs +10 -0
  141. package/lib/lookup-child-profile-contract.mjs +28 -11
  142. package/lib/lookup-contract-primitives.mjs +1 -1
  143. package/lib/lookup-diagnostics.mjs +1 -23
  144. package/lib/lookup-network-evidence-bounds.mjs +1 -10
  145. package/lib/lookup-progress.mjs +50 -1
  146. package/lib/lookup-response-contract.mjs +70 -40
  147. package/lib/lookup-response.mjs +5 -15
  148. package/lib/lookup-source-progress.mjs +8 -22
  149. package/lib/lookup.mjs +1 -1
  150. package/lib/operation-budget.mjs +4 -0
  151. package/lib/opportunity-scoring.mjs +2 -107
  152. package/lib/outbound-identity.mjs +1 -1
  153. package/lib/page-fingerprints.mjs +18 -1
  154. package/lib/perceptual-hash-comparison.mjs +2 -34
  155. package/lib/perceptual-hash.mjs +36 -10
  156. package/lib/portable-generator.mjs +2 -29
  157. package/lib/project-metadata.mjs +2 -8
  158. package/lib/rdap-client.mjs +11 -7
  159. package/lib/registrable-domain.mjs +2 -56
  160. package/lib/registrar-standing-contract.mjs +1 -1
  161. package/lib/registrar-standing.mjs +9 -13
  162. package/lib/registry-capabilities.mjs +2 -2
  163. package/lib/registry-capability-catalogue.mjs +27 -19
  164. package/lib/risk-calibration-summary.mjs +2 -352
  165. package/lib/risk-scoring.mjs +2 -345
  166. package/lib/scoring-evidence-quality.mjs +2 -107
  167. package/lib/semantic-version.mjs +2 -54
  168. package/lib/service-binding-dns.mjs +46 -2
  169. package/lib/smtp-transport-review.mjs +48 -19
  170. package/lib/sslbl-certificates.generated.mjs +142 -139
  171. package/lib/static-html-analysis.mjs +32 -4
  172. package/lib/structured-data-identity.mjs +1 -1
  173. package/lib/technology-evidence-role.mjs +1 -51
  174. package/lib/threat-intelligence-manifests.mjs +1 -1
  175. package/lib/threat-intelligence-runtime.mjs +1 -1
  176. package/lib/threat-intelligence-targets.mjs +1 -1
  177. package/lib/threat-intelligence-types.mjs +2 -75
  178. package/lib/tls-intelligence.mjs +30 -5
  179. package/lib/typosquat-generator.mjs +11 -2
  180. package/lib/website-enrichment.mjs +404 -0
  181. package/lib/whois-authority.mjs +43 -5
  182. package/lib/whois-chain.mjs +12 -3
  183. package/lib/whois-common-formats.mjs +3 -1
  184. package/lib/whois-contacts.mjs +13 -2
  185. package/lib/whois-normalization.mjs +3 -1
  186. package/lib/whois-parser.mjs +5 -3
  187. package/package.json +15 -14
  188. package/packages/analysis/analyst-taxonomy.mjs +40 -0
  189. package/packages/analysis/bounded-json.mjs +330 -0
  190. package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
  191. package/packages/analysis/cli-argument-grammar.mjs +216 -0
  192. package/packages/analysis/ct-query.mjs +27 -0
  193. package/packages/analysis/ct-response-bounds.mjs +9 -0
  194. package/packages/analysis/external-intelligence-risk.mjs +124 -0
  195. package/packages/analysis/http-evidence-bounds.mjs +8 -0
  196. package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
  197. package/packages/analysis/opportunity-scoring.mjs +107 -0
  198. package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
  199. package/packages/analysis/portable-generator.mjs +29 -0
  200. package/packages/analysis/project-metadata.mjs +8 -0
  201. package/packages/analysis/registrable-domain.mjs +56 -0
  202. package/packages/analysis/risk-calibration-summary.mjs +352 -0
  203. package/packages/analysis/risk-scoring.mjs +348 -0
  204. package/packages/analysis/scoring-evidence-quality.mjs +107 -0
  205. package/packages/analysis/semantic-version.mjs +54 -0
  206. package/packages/analysis/threat-intelligence-types.mjs +75 -0
  207. package/packages/cases/case-containment-handoff.mjs +195 -0
  208. package/packages/cases/case-evidence-links.mjs +151 -0
  209. package/packages/cases/case-evidence-model.mjs +89 -12
  210. package/packages/cases/case-evidence-quality.mjs +5 -0
  211. package/packages/cases/case-export-input.mjs +7 -5
  212. package/packages/cases/case-incident-context.mjs +52 -0
  213. package/packages/cases/case-markdown.mjs +11 -0
  214. package/packages/cases/case-migration-model.mjs +40 -24
  215. package/packages/cases/case-object-coverage.mjs +33 -0
  216. package/packages/cases/case-packet-correction.mjs +106 -0
  217. package/packages/cases/case-recheck-model.mjs +124 -36
  218. package/packages/cases/case-record-contracts.mjs +1 -1
  219. package/packages/cases/case-record-core.mjs +1 -1
  220. package/packages/cases/case-record-operations.mjs +111 -118
  221. package/packages/cases/case-record-projection.mjs +23 -4
  222. package/packages/cases/case-record-version-input.mjs +41 -0
  223. package/packages/cases/case-report.mjs +93 -27
  224. package/packages/cases/case-requested-evidence.mjs +146 -0
  225. package/packages/cases/case-response-actions.mjs +159 -1
  226. package/packages/cases/case-response-model.mjs +16 -1
  227. package/packages/cases/case-response-object.mjs +119 -0
  228. package/packages/cases/case-response-outcomes.mjs +108 -20
  229. package/packages/cases/case-response-packet-render.mjs +35 -12
  230. package/packages/cases/case-response-packet.mjs +55 -13
  231. package/packages/cases/case-response-records.mjs +0 -1
  232. package/packages/cases/case-response-review-inputs.mjs +60 -8
  233. package/packages/cases/case-storage-model.mjs +1 -1
  234. package/packages/cases/case-workflow-metadata.mjs +217 -0
  235. package/packages/cases/case-workflow-migration.mjs +43 -0
  236. package/packages/cases/http-summary.mjs +6 -1
  237. package/packages/comparison/favicon-similarity.mjs +1 -1
  238. package/packages/comparison/page-similarity.mjs +1 -1
  239. package/packages/comparison/relationship-evidence.mjs +21 -9
  240. package/packages/comparison/relationship-provenance.mjs +7 -1
  241. package/packages/contracts/analyst-interchange.mjs +35 -15
  242. package/packages/contracts/candidate-watch-review.mjs +54 -0
  243. package/packages/contracts/case-portability.mjs +360 -49
  244. package/packages/contracts/cli-command-semantics.mjs +5 -0
  245. package/packages/contracts/cli-grammar.mjs +6 -0
  246. package/packages/contracts/cli-lookup-brief.mjs +2 -0
  247. package/packages/contracts/context-review.mjs +25 -0
  248. package/packages/contracts/document-intake.mjs +11 -0
  249. package/packages/contracts/domain-control-flight-recorder.mjs +30 -14
  250. package/packages/contracts/domain-control-manifest.mjs +1 -1
  251. package/packages/contracts/domain-control-monitor.mjs +13 -2
  252. package/packages/contracts/domain-control-review.mjs +36 -15
  253. package/packages/contracts/external-observation-interchange.mjs +96 -28
  254. package/packages/contracts/har-review.mjs +2 -0
  255. package/packages/contracts/identity-events.mjs +4 -0
  256. package/packages/contracts/investigation-run.mjs +2 -0
  257. package/packages/contracts/ip-address.mjs +10 -0
  258. package/packages/contracts/lookup-evidence.mjs +3 -2
  259. package/packages/contracts/mail-authentication.mjs +5 -0
  260. package/packages/contracts/message-intake.mjs +37 -0
  261. package/packages/contracts/offline-comparison.mjs +4 -1
  262. package/packages/contracts/passive-mail.mjs +10 -0
  263. package/packages/contracts/risk-calibration.mjs +44 -29
  264. package/packages/contracts/schema-lifecycle.mjs +3 -1
  265. package/packages/contracts/web-capture.mjs +2 -2
  266. package/packages/contracts/workspace-portability.mjs +144 -28
  267. package/packages/evidence/collection-quality.mjs +67 -0
  268. package/packages/evidence/credential-form-attribution.mjs +60 -0
  269. package/packages/evidence/dns-query-outcome.mjs +72 -0
  270. package/packages/evidence/email-recipient.mjs +18 -0
  271. package/packages/evidence/image-regions.mjs +15 -0
  272. package/packages/evidence/lookup-target.mjs +5 -1
  273. package/packages/evidence/observation.mjs +2 -0
  274. package/packages/evidence/png-pixels.mjs +117 -0
  275. package/packages/evidence/public-address-policy.mjs +83 -0
  276. package/packages/evidence/record-identity.mjs +60 -16
  277. package/packages/evidence/technology-evidence-role.mjs +51 -0
  278. package/packages/interchange/defensive-indicator-export.mjs +264 -0
  279. package/packages/interchange/external-findings-import.mjs +36 -29
  280. package/packages/interchange/json-output.mjs +14 -0
  281. package/packages/interchange/managed-indicator-export.mjs +36 -0
  282. package/packages/interchange/managed-indicator-set.mjs +223 -0
  283. package/packages/interchange/misp-indicator-export.mjs +93 -0
  284. package/packages/interchange/sharing-policy.mjs +56 -0
  285. package/packages/interchange/stix-indicator-export.mjs +129 -0
  286. package/packages/interchange/web-capture-import.mjs +22 -15
  287. package/packages/investigation/acquisition-decision-packet.mjs +79 -1
  288. package/packages/investigation/bulk-mail-exposure.mjs +7 -13
  289. package/packages/investigation/capture-coverage.mjs +57 -0
  290. package/packages/investigation/connector-provenance-review.mjs +100 -0
  291. package/packages/investigation/context-review.mjs +35 -0
  292. package/packages/investigation/document-intake.mjs +87 -0
  293. package/packages/investigation/docx-intake.mjs +133 -0
  294. package/packages/investigation/domain-history-review.mjs +132 -0
  295. package/packages/investigation/domain-transition-review.mjs +49 -0
  296. package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
  297. package/packages/investigation/har-intake.mjs +68 -0
  298. package/packages/investigation/identity-event-intake.mjs +131 -0
  299. package/packages/investigation/identity-incident-review.mjs +39 -0
  300. package/packages/investigation/incident-sequence-review.mjs +58 -0
  301. package/packages/investigation/infrastructure-observation.mjs +234 -0
  302. package/packages/investigation/intake-context.mjs +284 -0
  303. package/packages/investigation/intake-indicators.mjs +116 -0
  304. package/packages/investigation/intake-phones.mjs +155 -0
  305. package/packages/investigation/intake-report.mjs +20 -0
  306. package/packages/investigation/investigation-manifest.mjs +35 -9
  307. package/packages/investigation/link-intake.mjs +209 -0
  308. package/packages/investigation/lookup-asset-graph.mjs +1 -1
  309. package/packages/investigation/mail-authentication-review.mjs +222 -0
  310. package/packages/investigation/mail-header-identity.mjs +209 -0
  311. package/packages/investigation/message-intake.mjs +235 -0
  312. package/packages/investigation/page-behaviour.mjs +113 -0
  313. package/packages/investigation/pdf-intake.mjs +155 -0
  314. package/packages/investigation/platform-continuity-review.mjs +60 -0
  315. package/packages/investigation/qr-intake.mjs +56 -0
  316. package/packages/investigation/requested-action-hints.mjs +11 -0
  317. package/packages/investigation/selected-input-review.mjs +17 -0
  318. package/packages/investigation/storefront-review.mjs +70 -0
  319. package/packages/monitoring/domain-feed.mjs +321 -0
  320. package/packages/workspace/brand-candidate-workflow.mjs +351 -0
  321. package/packages/workspace/brand-keyword-campaign.mjs +231 -0
  322. package/packages/workspace/brand-profile-model.mjs +48 -4
  323. package/packages/workspace/bulk-review-model.mjs +28 -26
  324. package/packages/workspace/bulk-session-model.mjs +27 -13
  325. package/packages/workspace/campaign-model.mjs +1 -1
  326. package/packages/workspace/candidate-watch-handoff.mjs +195 -0
  327. package/packages/workspace/detection-rule-model.mjs +11 -3
  328. package/packages/workspace/hostile-input.mjs +1 -1
  329. package/packages/workspace/investigation-guide.mjs +2 -2
  330. package/packages/workspace/investigation-template-model.mjs +26 -2
  331. package/packages/workspace/page-baseline.mjs +2 -2
  332. package/packages/workspace/relationship-observation-model.mjs +17 -13
  333. package/packages/workspace/shortlist-model.mjs +24 -7
  334. package/packages/workspace/watchlist-history.mjs +59 -16
  335. package/packages/workspace/watchlist-store.mjs +152 -11
  336. package/packages/workspace/website-snapshot-model.mjs +28 -3
  337. package/packages/workspace/workspace-archive-crypto.mjs +1 -1
  338. package/packages/workspace/workspace-archive.mjs +5 -4
  339. package/third-party-notices.txt +549 -16
  340. package/lib/hostname.mjs +0 -2
@@ -6,7 +6,7 @@ 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
8
  import { normalizeResolverMxRecord } from "../packages/evidence/domain-control-runtime.mjs";
9
- import { isValidAsciiHostname } from "./hostname.mjs";
9
+ import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
10
10
  import { isPrivateAddress } from "./safe-fetch.mjs";
11
11
  const DNS_DELEGATION_HEALTH_VERSION = 1;
12
12
  const MAX_AUTHORITIES = 4;
@@ -99,15 +99,19 @@ function withTimeout(promise, timeoutMs) {
99
99
  });
100
100
  }
101
101
  async function defaultAuthorityQuery(input) {
102
+ input.signal?.throwIfAborted();
102
103
  const resolver = new dns.Resolver({
103
104
  timeout: Math.max(250, Math.min(input.timeoutMs, DNS_DELEGATION_TIMEOUT_MS)),
104
105
  tries: 1,
105
106
  });
106
107
  resolver.setServers([net.isIP(input.address) === 6 ? `[${input.address}]:53` : input.address]);
108
+ const cancelled = () => resolver.cancel();
109
+ input.signal?.addEventListener('abort', cancelled, { once: true });
107
110
  const [nameservers, soa] = await Promise.allSettled([
108
111
  resolver.resolveNs(input.domain),
109
112
  resolver.resolveSoa(input.domain),
110
- ]);
113
+ ]).finally(() => { input.signal?.removeEventListener('abort', cancelled); resolver.cancel(); });
114
+ input.signal?.throwIfAborted();
111
115
  const reason = nameservers.status === 'rejected'
112
116
  ? nameservers.reason
113
117
  : soa.status === 'rejected'
@@ -188,18 +192,21 @@ function normaliseAuthorityValues(type, value) {
188
192
  return normaliseAuthorityValueSet(type, value).values;
189
193
  }
190
194
  async function defaultAuthorityRecordQuery(input) {
195
+ input.signal?.throwIfAborted();
191
196
  const resolver = new dns.Resolver({
192
197
  timeout: Math.max(250, Math.min(input.timeoutMs, DNS_DELEGATION_TIMEOUT_MS)),
193
198
  tries: 1,
194
199
  });
195
200
  resolver.setServers([net.isIP(input.address) === 6 ? `[${input.address}]:53` : input.address]);
201
+ const cancelled = () => resolver.cancel();
202
+ input.signal?.addEventListener('abort', cancelled, { once: true });
196
203
  const queries = [
197
204
  ['A', resolver.resolve4(input.domain)],
198
205
  ['AAAA', resolver.resolve6(input.domain)],
199
206
  ['CAA', resolver.resolveCaa(input.domain)],
200
207
  ['MX', resolver.resolveMx(input.domain)],
201
208
  ];
202
- return Promise.all(queries.map(async ([type, promise]) => {
209
+ const result = await Promise.all(queries.map(async ([type, promise]) => {
203
210
  try {
204
211
  const response = await withTimeout(promise, input.timeoutMs);
205
212
  const normalised = normaliseAuthorityValueSet(type, response);
@@ -224,7 +231,9 @@ async function defaultAuthorityRecordQuery(input) {
224
231
  discarded: 0,
225
232
  };
226
233
  }
227
- }));
234
+ })).finally(() => { input.signal?.removeEventListener('abort', cancelled); resolver.cancel(); });
235
+ input.signal?.throwIfAborted();
236
+ return result;
228
237
  }
229
238
  function authorityRecordMatrix(authorities) {
230
239
  const types = ['A', 'AAAA', 'CAA', 'MX'];
@@ -318,6 +327,7 @@ function skippedDnsDelegationHealth(detail = 'Authoritative delegation checks we
318
327
  };
319
328
  }
320
329
  async function collectDnsDelegationHealth(domainValue, parentQueryValue, options = {}) {
330
+ options.signal?.throwIfAborted();
321
331
  const domain = hostname(domainValue);
322
332
  if (!domain)
323
333
  return skippedDnsDelegationHealth('The domain was not eligible for authoritative delegation checks.');
@@ -338,7 +348,8 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
338
348
  const timeoutMs = Math.max(250, Math.min(5000, Number(options.timeoutMs) || DNS_DELEGATION_TIMEOUT_MS));
339
349
  const now = options.now || Date.now;
340
350
  const started = now();
341
- const authorities = await Promise.all(candidates.map(async (nameserver) => {
351
+ const pending = candidates.map(async (nameserver) => {
352
+ options.signal?.throwIfAborted();
342
353
  const glue = registry.nameserverDetails.find((item) => item.name === nameserver)?.addresses ?? [];
343
354
  const resolved = glue.length
344
355
  ? glue
@@ -350,10 +361,11 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
350
361
  : [])
351
362
  .map(publicAddress)
352
363
  .filter((address) => address !== null);
364
+ options.signal?.throwIfAborted();
353
365
  const addresses = [...new Set(resolved)].slice(0, MAX_AUTHORITY_ADDRESSES);
354
366
  const attempts = await Promise.all(addresses.map(async (address) => {
355
367
  try {
356
- const raw = await withTimeout(queryAuthority({ domain, nameserver, address, timeoutMs }), timeoutMs);
368
+ const raw = await withTimeout(queryAuthority({ domain, nameserver, address, timeoutMs, ...(options.signal ? { signal: options.signal } : {}) }), timeoutMs);
357
369
  const query = record(raw);
358
370
  const code = boundedText(query.errorCode, 40);
359
371
  const error = boundedText(query.error, MAX_ERROR_LENGTH);
@@ -384,6 +396,7 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
384
396
  };
385
397
  }
386
398
  }));
399
+ options.signal?.throwIfAborted();
387
400
  const successful = attempts.find((attempt) => attempt.state === 'success');
388
401
  const partial = attempts.find((attempt) => attempt.state === 'partial');
389
402
  const retained = successful ?? partial;
@@ -391,7 +404,7 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
391
404
  const recordAddress = retained?.address ?? addresses[0];
392
405
  if (queryAuthorityRecords && recordAddress) {
393
406
  try {
394
- recordSets = (await withTimeout(queryAuthorityRecords({ domain, nameserver, address: recordAddress, timeoutMs }), timeoutMs))
407
+ recordSets = (await withTimeout(queryAuthorityRecords({ domain, nameserver, address: recordAddress, timeoutMs, ...(options.signal ? { signal: options.signal } : {}) }), timeoutMs))
395
408
  .filter((item) => ['A', 'AAAA', 'CAA', 'MX'].includes(item.type))
396
409
  .slice(0, 4)
397
410
  .map((item) => {
@@ -428,6 +441,7 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
428
441
  }));
429
442
  }
430
443
  }
444
+ options.signal?.throwIfAborted();
431
445
  return {
432
446
  nameserver,
433
447
  addressSource: glue.length ? 'registry_glue' : 'recursive_address',
@@ -445,7 +459,9 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
445
459
  recordSets,
446
460
  attempts,
447
461
  };
448
- }));
462
+ });
463
+ const authorities = await Promise.all(pending).finally(() => Promise.allSettled(pending));
464
+ options.signal?.throwIfAborted();
449
465
  const successfulAuthorities = authorities.filter((authority) => authority.state === 'success');
450
466
  const partialAuthorities = authorities.filter((authority) => authority.state === 'partial');
451
467
  const lameAuthorities = authorities.filter((authority) => authority.state === 'lame');
@@ -465,8 +481,10 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
465
481
  .filter((primary) => primary !== null))];
466
482
  const soaSerialConflict = soaSerials.length > 1;
467
483
  const soaPrimaryConflict = soaPrimaries.length > 1;
468
- const inBailiwick = candidates.filter((nameserver) => (nameserver === domain || nameserver.endsWith(`.${domain}`)));
469
- const missingGlue = inBailiwick.filter((nameserver) => (!(registry.nameserverDetails.find((item) => item.name === nameserver)?.addresses.length)));
484
+ const glueInventoryComplete = (parentStatus === 'success' && parentNameservers.length > 0 && parentQuery.truncated !== true)
485
+ || (registry.nameservers.length > 0 && !registry.truncated);
486
+ const inBailiwick = eligibleAuthorities.filter((nameserver) => (nameserver === domain || nameserver.endsWith(`.${domain}`)));
487
+ const unobservedGlue = inBailiwick.filter((nameserver) => (!(registry.nameserverDetails.find((item) => item.name === nameserver)?.addresses.length)));
470
488
  const dnssecMismatch = (registry.delegationSigned === true && registry.dsRecordCount === 0) || (registry.delegationSigned === false && registry.dsRecordCount > 0);
471
489
  const recordMatrix = authorityRecordMatrix(authorities);
472
490
  const recordCollectionAttempted = authorities.some((authority) => authority.recordSets.length > 0);
@@ -507,11 +525,13 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
507
525
  : soaPrimaryConflict
508
526
  ? 'Authoritative servers published different SOA primary nameservers'
509
527
  : 'Observed authority SOA serials and primary nameservers agree', retainedSoa.map((entry) => (`${entry.nameserver}: serial ${entry.soa.serial ?? 'unavailable'}, primary ${entry.soa.nsname ?? 'unavailable'}, refresh ${entry.soa.refresh ?? 'unavailable'}, retry ${entry.soa.retry ?? 'unavailable'}, expire ${entry.soa.expire ?? 'unavailable'}`)).join(' | ') || 'No complete SOA observation was available.', 'Confirm zone propagation and the intended SOA primary before relying on a DNS change or secondary-server state.'),
510
- finding('in_bailiwick_glue', 'In-bailiwick glue', !inBailiwick.length ? 'healthy' : missingGlue.length ? 'warning' : 'healthy', !inBailiwick.length
511
- ? 'No in-bailiwick nameserver requires registry glue'
512
- : missingGlue.length
513
- ? 'Registry glue was not observed for every in-bailiwick nameserver'
514
- : 'Registry publication includes observed glue for each in-bailiwick nameserver', missingGlue.length ? `Missing observed glue: ${missingGlue.join(', ')}.` : `In-bailiwick nameservers: ${inBailiwick.join(', ') || 'none'}.`, 'Publish current A and AAAA glue at the registry for every in-bailiwick nameserver.'),
528
+ finding('in_bailiwick_glue', 'In-bailiwick glue', !glueInventoryComplete || unobservedGlue.length ? 'unknown' : 'healthy', !glueInventoryComplete
529
+ ? 'The delegated nameserver inventory is unavailable or incomplete'
530
+ : !inBailiwick.length
531
+ ? 'No in-bailiwick nameserver requires registry glue'
532
+ : unobservedGlue.length
533
+ ? 'Registry address publication does not establish glue for every in-bailiwick nameserver'
534
+ : 'Registry publication includes observed glue for each in-bailiwick nameserver', unobservedGlue.length ? `No address publication retained for: ${unobservedGlue.join(', ')}. Optional RDAP address fields and recursive NS answers do not establish that parent-zone glue is absent.` : `In-bailiwick nameservers: ${inBailiwick.join(', ') || 'none'}.`, 'Verify parent-zone glue with the registry or DNS operator before deciding whether an update is needed.'),
515
535
  finding('dnssec_delegation', 'DNSSEC delegation publication', dnssecMismatch ? 'warning'
516
536
  : registry.delegationSigned === null && registry.dsRecordCount === 0 ? 'unknown' : 'healthy', dnssecMismatch
517
537
  ? 'Registry DNSSEC fields are internally inconsistent'
@@ -1,20 +1,16 @@
1
1
  import { promises as dns } from 'node:dns';
2
2
  import { parse as parseDomain } from 'tldts';
3
- import { parseDmarcRecords } from "./domain-posture-parsers.mjs";
3
+ import { validateDmarcExternalReporting } from "./domain-posture-analysis.mjs";
4
+ import { requireDmarcDomain, walkDmarcTree } from "./dmarc-discovery.mjs";
4
5
  import { buildDnssecQuery, DNS_TYPE_NS, normalizeResolverEndpoint, parseDnssecResponse } from "./dnssec-chain-validation.mjs";
5
6
  import { defaultTcpExchange } from "./service-binding-dns.mjs";
6
- import { isValidAsciiHostname } from "./hostname.mjs";
7
- import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
7
+ import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
8
8
  const MAX_PARENT_SERVERS = 2;
9
9
  const MAX_NS = 16;
10
10
  const QUERY_TIMEOUT_MS = 2200;
11
11
  const REVIEW_TIMEOUT_MS = 10_000;
12
+ export const MAX_DMARC_REVIEW_QUERIES = 32;
12
13
  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
14
  /** Explicit query parameter; malformed opt-ins never broaden collection. */
19
15
  export function parseInheritedDnsSelection(value) {
20
16
  if (value === undefined || value === '')
@@ -26,53 +22,11 @@ export function parseInheritedDnsSelection(value) {
26
22
  function check(id, label, status, summary, detail, records) {
27
23
  return { id, label, status, summary, detail, records, remediation: '' };
28
24
  }
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
25
  /** RFC 9989 sections 4.10–4.10.2; at most eight owners including the exact name. */
53
26
  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);
27
+ const { rows, available, boundary, incomplete, organisationalDomain } = await walkDmarcTree(domain, exact, resolve, 'policy');
69
28
  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));
29
+ const selected = explicit ?? available.find(row => row.owner === organisationalDomain) ?? (boundary?.psd === 'y' ? boundary : null);
76
30
  const summary = incomplete ? 'Inherited policy could not be determined from the collected records.'
77
31
  : selected ? `${selected.owner === domain ? 'Exact-name' : 'Inherited'} policy published at _dmarc.${selected.owner}.`
78
32
  : 'No applicable DMARC policy was found in the completed tree walk.';
@@ -90,7 +44,7 @@ function nameservers(value) {
90
44
  return names.every(name => name.length <= 253 && isValidAsciiHostname(name)) ? [...new Set(names)].sort() : null;
91
45
  }
92
46
  export async function reviewParentDelegation(domain, recursive, dependencies, signal) {
93
- requireDomain(domain);
47
+ requireDmarcDomain(domain);
94
48
  const parsed = parseDomain(domain, { allowPrivateDomains: false });
95
49
  const child = parsed.domain;
96
50
  const parent = parsed.publicSuffix;
@@ -175,8 +129,8 @@ export async function reviewParentDelegation(domain, recursive, dependencies, si
175
129
  }
176
130
  return finish();
177
131
  }
178
- export async function collectDnsInheritanceChecks(domain, exact, recursive, options = {}, injected) {
179
- requireDomain(domain);
132
+ export async function collectDnsInheritanceReview(domain, exact, recursive, options = {}, injected) {
133
+ requireDmarcDomain(domain);
180
134
  options.signal?.throwIfAborted();
181
135
  const timeout = AbortSignal.timeout(REVIEW_TIMEOUT_MS);
182
136
  const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
@@ -188,10 +142,13 @@ export async function collectDnsInheritanceChecks(domain, exact, recursive, opti
188
142
  resolve4: (name) => resolver.resolve4(name), resolve6: (name) => resolver.resolve6(name),
189
143
  exchange: defaultTcpExchange, now: () => new Date(),
190
144
  };
191
- const resolve = async (owner) => {
145
+ let questions = 0;
146
+ const cache = new Map([[`_dmarc.${domain}`, Promise.resolve(exact)]]);
147
+ const query = async (owner) => {
192
148
  signal.throwIfAborted();
193
149
  if (owner.length > 253)
194
150
  return { records: [], error: 'The DNS question exceeds the name limit.' };
151
+ questions += 1;
195
152
  try {
196
153
  return { records: await dependencies.resolveTxt(owner), error: null, observedAt: dependencies.now().toISOString() };
197
154
  }
@@ -200,17 +157,50 @@ export async function collectDnsInheritanceChecks(domain, exact, recursive, opti
200
157
  return { records: [], error: MISSING.has(String(error?.code)) ? null : 'DNS observation unavailable.', observedAt: dependencies.now().toISOString() };
201
158
  }
202
159
  };
203
- const operations = [reviewInheritedDmarc(domain, exact, resolve), reviewParentDelegation(domain, recursive, dependencies, signal)];
160
+ const resolve = (owner) => {
161
+ options.signal?.throwIfAborted();
162
+ const existing = cache.get(owner);
163
+ if (existing)
164
+ return existing;
165
+ if (questions >= MAX_DMARC_REVIEW_QUERIES)
166
+ return Promise.resolve({ records: [], error: 'The shared DMARC query budget was reached.' });
167
+ const pending = query(owner);
168
+ cache.set(owner, pending);
169
+ return pending;
170
+ };
171
+ const organisations = new Map();
172
+ const boundaryRecords = new Map();
173
+ const boundaryResults = new Map();
174
+ const resolveOrganisationalDomain = (owner) => {
175
+ const existing = organisations.get(owner);
176
+ if (existing)
177
+ return existing;
178
+ const pending = (async () => {
179
+ const walk = await walkDmarcTree(owner, await resolve(`_dmarc.${owner}`), resolve, 'organisation');
180
+ for (const row of walk.rows)
181
+ if (cache.has(`_dmarc.${row.owner}`))
182
+ boundaryRecords.set(row.owner, row.record);
183
+ boundaryResults.set(owner, `${owner}: organisational domain ${walk.organisationalDomain ?? 'unknown'}.`);
184
+ return walk.organisationalDomain;
185
+ })();
186
+ organisations.set(owner, pending);
187
+ return pending;
188
+ };
189
+ const operations = [
190
+ reviewInheritedDmarc(domain, exact, resolve),
191
+ reviewParentDelegation(domain, recursive, dependencies, signal),
192
+ validateDmarcExternalReporting(domain, exact, resolve, resolveOrganisationalDomain),
193
+ ];
204
194
  try {
205
- return await Promise.all(operations);
206
- }
207
- catch {
195
+ const [policy, parent, reporting] = await Promise.allSettled(operations);
208
196
  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.', []));
197
+ const checks = [policy, parent].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.', []));
198
+ const dmarcAuthorizations = reporting.status === 'fulfilled' ? reporting.value : [];
199
+ if (organisations.size)
200
+ checks.push(check('dmarc_reporting_boundaries', 'DMARC reporting boundaries', reporting.status === 'rejected' || dmarcAuthorizations.some(item => !['self', 'authorized'].includes(item.state)) ? 'warning' : 'info', 'Reporting destinations were reviewed using DNS-derived organisational boundaries.', `${questions} additional DMARC TXT questions used; the shared limit is ${MAX_DMARC_REVIEW_QUERIES} within ten seconds. Each tree walk examines at most eight owners. Unavailable boundaries remain unknown; a missing optional authorisation record does not establish a configuration error.`, [...boundaryResults.values(), ...boundaryRecords.values()]));
201
+ return { checks, dmarcAuthorizations };
211
202
  }
212
203
  finally {
213
- await Promise.allSettled(operations);
214
204
  signal.removeEventListener('abort', cancel);
215
205
  cancel();
216
206
  }
@@ -1,5 +1,5 @@
1
1
  // Bounded DNS evidence for investigative deep scans. This collector keeps
2
- // authoritative absence distinct from resolver failure, preserves only record
2
+ // resolver-reported negative answers distinct from failures, preserves only record
3
3
  // types that aid domain triage, and never treats shared infrastructure as proof
4
4
  // of common ownership or maliciousness.
5
5
  import { promises as dns } from 'node:dns';
@@ -11,7 +11,8 @@ import { createObservation } from "../packages/evidence/observation.mjs";
11
11
  import { MAX_LOOKUP_DNS_RECORDS_PER_TYPE, MAX_LOOKUP_REVERSE_DNS_PTR_RECORDS, } from "./lookup-network-evidence-bounds.mjs";
12
12
  import { isPrivateAddress } from "./safe-fetch.mjs";
13
13
  import { resolveServiceBindingRecords } from "./service-binding-dns.mjs";
14
- import { canonicalRegistrableDomain } from "./registrable-domain.mjs";
14
+ import { canonicalRegistrableDomain } from "../packages/analysis/registrable-domain.mjs";
15
+ import { DNS_QUERY_OUTCOMES, dnsQueryFailureOutcome } from "../packages/evidence/dns-query-outcome.mjs";
15
16
  const DNS_TIMEOUT_MS = 5000;
16
17
  const MAX_RECORDS_PER_TYPE = MAX_LOOKUP_DNS_RECORDS_PER_TYPE;
17
18
  const MAX_PTR_RECORDS = MAX_LOOKUP_REVERSE_DNS_PTR_RECORDS;
@@ -24,7 +25,6 @@ const MAX_SERVICE_ALPN_IDS = 16;
24
25
  const MAX_SERVICE_ADDRESS_HINTS = 8;
25
26
  const MAX_CAA_TREE_QUERIES = 8;
26
27
  const DNS_CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f]/u;
27
- const MISSING_CODES = new Set(['ENODATA', 'ENOTFOUND', 'ENONAME']);
28
28
  function skippedDnsIntelligence(detail = 'DNS intelligence is disabled by deployment policy.', { includeExtendedContext = false, includeInheritedCaa = false, } = {}) {
29
29
  const skipped = { status: 'skipped', error: null, truncated: false, discarded: 0 };
30
30
  const recordTypes = ['a', 'aaaa', 'cname', 'ns', 'mx', 'spf', 'dmarc', 'caa'];
@@ -386,7 +386,7 @@ function normalizePtr(records) {
386
386
  }
387
387
  function withTimeout(factory, timeoutMs) {
388
388
  return new Promise((resolve, reject) => {
389
- const timer = setTimeout(() => reject(new Error('DNS query timed out')), timeoutMs);
389
+ const timer = setTimeout(() => reject(Object.assign(new Error('DNS query timed out'), { code: 'ETIMEOUT' })), timeoutMs);
390
390
  Promise.resolve().then(factory).then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
391
391
  });
392
392
  }
@@ -396,6 +396,7 @@ async function query(factory, normalize, timeoutMs) {
396
396
  const unusable = !normalized.records.length && (normalized.truncated || normalized.discarded > 0);
397
397
  return {
398
398
  status: unusable ? 'error' : normalized.records.length ? 'success' : 'not_found',
399
+ detail: unusable ? 'invalid_response' : normalized.records.length ? 'records' : 'empty_answer',
399
400
  records: normalized.records,
400
401
  error: unusable ? 'The returned record family could not be retained completely; absence is unknown.' : null,
401
402
  truncated: normalized.truncated,
@@ -403,9 +404,9 @@ async function query(factory, normalize, timeoutMs) {
403
404
  };
404
405
  }
405
406
  catch (error) {
406
- if (error && typeof error === 'object' && MISSING_CODES.has(String(error.code)))
407
- return { status: 'not_found', records: [], error: null, truncated: false, discarded: 0 };
408
- return { status: 'error', records: [], error: boundedError(error), truncated: false, discarded: 0 };
407
+ const detail = dnsQueryFailureOutcome(error && typeof error === 'object' ? error.code : undefined);
408
+ const missing = DNS_QUERY_OUTCOMES[detail].status === 'not_found';
409
+ return { status: missing ? 'not_found' : 'error', detail, records: [], error: missing ? null : boundedError(error), truncated: false, discarded: 0 };
409
410
  }
410
411
  }
411
412
  function completeQuery(result) {
@@ -415,6 +416,7 @@ function observedPresence(result) {
415
416
  return result.records.length > 0 ? true : completeQuery(result) ? false : null;
416
417
  }
417
418
  async function collectEffectiveCaaPolicy(domain, options = {}) {
419
+ options.signal?.throwIfAborted();
418
420
  const normalizedDomain = normalizeHostname(domain);
419
421
  const queryLimit = Math.max(1, Math.min(MAX_CAA_TREE_QUERIES, Math.floor(options.queryLimit || MAX_CAA_TREE_QUERIES)));
420
422
  const now = options.now || Date.now;
@@ -454,9 +456,11 @@ async function collectEffectiveCaaPolicy(domain, options = {}) {
454
456
  const timeoutMs = options.timeoutMs || DNS_TIMEOUT_MS;
455
457
  const queries = [];
456
458
  for (const [index, owner] of owners.entries()) {
459
+ options.signal?.throwIfAborted();
457
460
  const result = index === 0 && options.directResult
458
461
  ? options.directResult
459
462
  : await query(() => resolver(owner), normalizeCaa, timeoutMs);
463
+ options.signal?.throwIfAborted();
460
464
  queries.push({
461
465
  owner,
462
466
  status: result.status,
@@ -581,6 +585,7 @@ async function collectEffectiveCaaPolicy(domain, options = {}) {
581
585
  };
582
586
  }
583
587
  async function collectDnsIntelligence(domain, options = {}) {
588
+ options.signal?.throwIfAborted();
584
589
  const registrationDomain = options.registrationDomain ?? domain;
585
590
  if (options.registrationDomain !== undefined && canonicalRegistrableDomain(domain) !== registrationDomain) {
586
591
  throw new TypeError('DNS registration context must match the hostname being observed.');
@@ -591,7 +596,10 @@ async function collectDnsIntelligence(domain, options = {}) {
591
596
  const timeoutMs = options.timeoutMs || DNS_TIMEOUT_MS;
592
597
  const now = options.now || Date.now;
593
598
  const started = now();
594
- const invoke = (name, fallback, value = domain) => () => (resolvers[name] || fallback)(value);
599
+ const invoke = (name, fallback, value = domain) => () => {
600
+ options.signal?.throwIfAborted();
601
+ return (resolvers[name] || fallback)(value);
602
+ };
595
603
  const aPromise = query(invoke('resolve4', dns.resolve4), (records) => normalizeAddresses(records, 4), timeoutMs);
596
604
  const aaaaPromise = query(invoke('resolve6', dns.resolve6), (records) => normalizeAddresses(records, 6), timeoutMs);
597
605
  const cnamePromise = query(invoke('resolveCname', dns.resolveCname), normalizeHostnames, timeoutMs);
@@ -606,6 +614,7 @@ async function collectDnsIntelligence(domain, options = {}) {
606
614
  directResult,
607
615
  timeoutMs,
608
616
  now,
617
+ ...(options.signal ? { signal: options.signal } : {}),
609
618
  ...(options.observedAt ? { observedAt: options.observedAt } : {}),
610
619
  }))
611
620
  : Promise.resolve(null);
@@ -613,7 +622,7 @@ async function collectDnsIntelligence(domain, options = {}) {
613
622
  ? query(invoke('resolveSoa', dns.resolveSoa), normalizeSoa, timeoutMs)
614
623
  : Promise.resolve(null);
615
624
  const httpsPromise = includeExtendedContext
616
- ? query(invoke('resolveHttps', (value) => resolveServiceBindingRecords(value, 'HTTPS', { timeoutMs })), normalizeServiceBindings, timeoutMs)
625
+ ? query(invoke('resolveHttps', (value) => resolveServiceBindingRecords(value, 'HTTPS', { timeoutMs, ...(options.signal ? { signal: options.signal } : {}) })), normalizeServiceBindings, timeoutMs)
617
626
  : Promise.resolve(null);
618
627
  const registrationNsPromise = includeExtendedContext && registrationDomain !== domain
619
628
  ? query(invoke('resolveNs', dns.resolveNs, registrationDomain), normalizeHostnames, timeoutMs)
@@ -626,10 +635,11 @@ async function collectDnsIntelligence(domain, options = {}) {
626
635
  ...(options.queryAuthority ? { queryAuthority: options.queryAuthority } : {}),
627
636
  timeoutMs,
628
637
  now,
638
+ ...(options.signal ? { signal: options.signal } : {}),
629
639
  ...(options.observedAt ? { observedAt: options.observedAt } : {}),
630
640
  }))
631
641
  : Promise.resolve(null);
632
- const [a, aaaa, cname, ns, mx, spf, dmarc, caa, caaPolicy, soa, https, delegation] = await Promise.all([
642
+ const pending = [
633
643
  aPromise,
634
644
  aaaaPromise,
635
645
  cnamePromise,
@@ -642,7 +652,12 @@ async function collectDnsIntelligence(domain, options = {}) {
642
652
  soaPromise,
643
653
  httpsPromise,
644
654
  delegationPromise,
645
- ]);
655
+ ];
656
+ // A rejected continuation must not release the lookup while sibling queries
657
+ // are still running. Their existing bounded resolver deadlines remain active.
658
+ const [a, aaaa, cname, ns, mx, spf, dmarc, caa, caaPolicy, soa, https, delegation] = await Promise.all(pending)
659
+ .finally(() => Promise.allSettled(pending));
660
+ options.signal?.throwIfAborted();
646
661
  const queries = {
647
662
  a, aaaa, cname, ns, mx, spf, dmarc, caa,
648
663
  ...(soa ? { soa } : {}),
@@ -683,6 +698,7 @@ async function collectDnsIntelligence(domain, options = {}) {
683
698
  diagnostics: {
684
699
  ...Object.fromEntries(Object.entries(queries).map(([name, item]) => [name, {
685
700
  status: item.status,
701
+ detail: item.detail,
686
702
  error: item.error,
687
703
  truncated: item.truncated,
688
704
  discarded: item.discarded,
@@ -0,0 +1,40 @@
1
+ import { exactKeys, requireRecord } from "./bounded-contract-normalizers.mjs";
2
+ const QUERY_KEYS = new Set(['owner', 'type', 'state']);
3
+ /** A positive legacy record proves its own query scope, never another type's absence. */
4
+ export function normaliseDnsQueryCoverage(value, options) {
5
+ const { label, maximum, state, records } = options;
6
+ if (options.legacy) {
7
+ return Object.freeze([...new Map(records.map((record) => [
8
+ `${record.owner}\u0000${record.type}`,
9
+ Object.freeze({ owner: record.owner, type: record.type, state }),
10
+ ])).values()]);
11
+ }
12
+ if (!Array.isArray(value) || value.length > maximum)
13
+ throw new TypeError(`${label} must contain no more than ${maximum} query outcomes.`);
14
+ const byKey = new Map();
15
+ for (const [index, raw] of value.entries()) {
16
+ const path = `${label}[${index}]`;
17
+ const item = requireRecord(raw, path);
18
+ exactKeys(item, QUERY_KEYS, path);
19
+ const owner = options.owner(item.owner, `${path}.owner`);
20
+ const type = options.type(item.type, `${path}.type`);
21
+ if (item.state !== 'observed' && item.state !== 'partial' && item.state !== 'unavailable')
22
+ throw new TypeError(`${path}.state is unsupported.`);
23
+ if ((state === 'unavailable' && item.state !== 'unavailable') || (state === 'observed' && item.state !== 'observed')) {
24
+ throw new TypeError(`${path}.state contradicts the snapshot state.`);
25
+ }
26
+ const key = `${owner}\u0000${type}`;
27
+ if (byKey.has(key))
28
+ throw new TypeError(`${path} duplicates an owner and type.`);
29
+ byKey.set(key, Object.freeze({ owner, type, state: item.state }));
30
+ }
31
+ for (const record of records) {
32
+ const query = byKey.get(`${record.owner}\u0000${record.type}`);
33
+ if (!query || query.state === 'unavailable')
34
+ throw new TypeError(`${label} must declare an observed or partial query for every supplied record.`);
35
+ }
36
+ return Object.freeze([...byKey.values()]);
37
+ }
38
+ export function dnsQueryState(queries, owner, type) {
39
+ return queries.find((query) => query.owner === owner && query.type === type)?.state ?? 'not_queried';
40
+ }
@@ -243,7 +243,7 @@ function buildRecoveryDependencies(input) {
243
243
  };
244
244
  }
245
245
  const RETIREMENT_CHECKS = Object.freeze([
246
- ['autoRenewDisabled', 'Auto-renew is intentionally configured', true],
246
+ ['autoRenewDisabled', 'Auto-renew is disabled as declared', true],
247
247
  ['registrarLockMaintained', 'Registrar lock remains enabled', true],
248
248
  ['websiteRetired', 'Web service is retired or intentionally redirected', true],
249
249
  ['mailRetired', 'Mail service is retired', true],
@@ -5,9 +5,10 @@ import { SORTED_JSON_V2, sha256ArtifactDigestV2 } from "../packages/evidence/art
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 = 3;
8
+ export const DOMAIN_CHANGE_PACKET_VERSION = 4;
9
9
  export const DOMAIN_CHANGE_PACKET_REVIEW_VERSIONS = Object.freeze({
10
10
  2: 1,
11
+ 3: 2,
11
12
  [DOMAIN_CHANGE_PACKET_VERSION]: DOMAIN_CHANGE_REVIEW_VERSION,
12
13
  });
13
14
  export const SUPPORTED_DOMAIN_CHANGE_PACKET_VERSIONS = Object.freeze(Object.keys(DOMAIN_CHANGE_PACKET_REVIEW_VERSIONS).map(Number));
@@ -43,16 +44,10 @@ function changeSummary(before, after) {
43
44
  const beforeRows = rows(before);
44
45
  const afterRows = rows(after);
45
46
  const keys = [...new Set([...beforeRows.keys(), ...afterRows.keys()])].sort();
46
- const authorityCollectionComplete = (review) => {
47
- const representative = review.authoritativeRecordMatrix[0];
48
- return Boolean(representative
49
- && representative.observations.length >= 2
50
- && representative.observations.every((item) => item.state === 'observed'));
51
- };
52
- const rowEvidence = (review, row) => {
47
+ const rowEvidence = (row) => {
53
48
  if (!row)
54
49
  return Object.freeze({
55
- state: authorityCollectionComplete(review) ? 'complete' : 'unavailable',
50
+ state: 'unavailable',
56
51
  values: Object.freeze([]),
57
52
  });
58
53
  if (row.observations.some((item) => item.state !== 'observed')) {
@@ -68,13 +63,15 @@ function changeSummary(before, after) {
68
63
  });
69
64
  };
70
65
  const changed = [];
66
+ const reasons = keys.length > 500 ? ['The comparison exceeds 500 owner and record-type pairs.'] : [];
71
67
  for (const key of keys.slice(0, 500)) {
72
68
  const left = beforeRows.get(key);
73
69
  const right = afterRows.get(key);
74
70
  const [owner, type] = key.split('\u0000');
75
- const beforeEvidence = rowEvidence(before, left);
76
- const afterEvidence = rowEvidence(after, right);
71
+ const beforeEvidence = rowEvidence(left);
72
+ const afterEvidence = rowEvidence(right);
77
73
  if (beforeEvidence.state !== 'complete' || afterEvidence.state !== 'complete') {
74
+ reasons.push(`Comparison evidence is incomplete for ${owner} ${type}.`);
78
75
  continue;
79
76
  }
80
77
  if (JSON.stringify(beforeEvidence.values) === JSON.stringify(afterEvidence.values))
@@ -86,7 +83,7 @@ function changeSummary(before, after) {
86
83
  afterValues: afterEvidence.values,
87
84
  }));
88
85
  }
89
- return Object.freeze(changed);
86
+ return Object.freeze({ changed: Object.freeze(changed), reasons: Object.freeze(reasons) });
90
87
  }
91
88
  export async function buildDomainChangePacket(inputRaw, generatedAtValue = new Date().toISOString()) {
92
89
  const input = record(inputRaw, 'Domain change packet input');
@@ -107,11 +104,13 @@ export async function buildDomainChangePacket(inputRaw, generatedAtValue = new D
107
104
  const preChange = reviewDomainChange(input.preChange, generatedAt);
108
105
  const postChange = reviewDomainChange(input.postChange, generatedAt);
109
106
  const assurance = buildDomainAssurance(input.assurance, generatedAt);
107
+ const comparison = changeSummary(preChange, postChange);
110
108
  const assuranceReasons = assurance.result.review.reasons;
111
109
  const gateReasons = Object.freeze([
112
110
  ...preChange.gate.reasons.map((reason) => `Pre-change evidence: ${reason}`),
113
111
  ...postChange.gate.reasons.map((reason) => `Post-change evidence: ${reason}`),
114
112
  ...assuranceReasons.map((reason) => `Change plan: ${reason}`),
113
+ ...comparison.reasons,
115
114
  ].slice(0, 100));
116
115
  const unsigned = Object.freeze({
117
116
  schema: DOMAIN_CHANGE_PACKET_SCHEMA,
@@ -122,7 +121,7 @@ export async function buildDomainChangePacket(inputRaw, generatedAtValue = new D
122
121
  state: gateReasons.length ? 'review' : 'ready',
123
122
  gate: Object.freeze({ pass: gateReasons.length === 0, reasons: gateReasons }),
124
123
  summary: Object.freeze({
125
- changedAuthoritativeRecordSets: changeSummary(preChange, postChange),
124
+ changedAuthoritativeRecordSets: comparison.changed,
126
125
  preChangeState: preChange.state,
127
126
  postChangeState: postChange.state,
128
127
  assuranceState: assurance.result.review.state,