@slicedearth/whoisleuth-cli 2.3.0 → 2.5.1

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 (418) hide show
  1. package/NOTICE +8 -2
  2. package/README.md +19 -15
  3. package/bin/whoisleuth.mjs +8 -1
  4. package/cli/archive-content-digest.mjs +14 -0
  5. package/cli/archive-inspect.mjs +20 -53
  6. package/cli/argument-values.mjs +55 -0
  7. package/cli/arguments.mjs +24 -408
  8. package/cli/artifact-validation/case-response.mjs +65 -44
  9. package/cli/artifact-validation/investigation-capsule.mjs +52 -31
  10. package/cli/artifact-validation/lookup-evidence.mjs +15 -9
  11. package/cli/artifact-validation/signed-review.mjs +136 -68
  12. package/cli/artifact-validation/structure-primitives.mjs +1 -105
  13. package/cli/artifact-verify.mjs +92 -24
  14. package/cli/assurance-arguments.mjs +56 -0
  15. package/cli/assurance-command-definitions.mjs +179 -0
  16. package/cli/assurance-command-runner.mjs +40 -5
  17. package/cli/bagit-folder.mjs +57 -0
  18. package/cli/bagit-review.mjs +23 -0
  19. package/cli/bounded-json.mjs +1 -1
  20. package/cli/bulk-checkpoint.mjs +1 -1
  21. package/cli/bulk-command-runner.mjs +4 -2
  22. package/cli/bulk-output.mjs +29 -3
  23. package/cli/bulk.mjs +2 -3
  24. package/cli/case-command.mjs +212 -0
  25. package/cli/case-pack.mjs +50 -32
  26. package/cli/ci-report.mjs +6 -3
  27. package/cli/collection-arguments.mjs +123 -0
  28. package/cli/collection-command-definitions.mjs +330 -0
  29. package/cli/collection-preflight.mjs +8 -1
  30. package/cli/command-argument-grammar.mjs +8 -206
  31. package/cli/command-definition.mjs +329 -0
  32. package/cli/command-reference.mjs +94 -1357
  33. package/cli/command-seeds.mjs +18 -0
  34. package/cli/compare.mjs +1 -1
  35. package/cli/completion.mjs +298 -79
  36. package/cli/config-profile.mjs +8 -6
  37. package/cli/ct-event-intake.mjs +16 -16
  38. package/cli/discover.mjs +5 -4
  39. package/cli/discovery-observation-snapshot.mjs +46 -18
  40. package/cli/discovery-scan-command-runner.mjs +5 -3
  41. package/cli/discovery-scan.mjs +7 -4
  42. package/cli/discovery-snapshot.mjs +2 -2
  43. package/cli/discovery-workflow.mjs +5 -5
  44. package/cli/domain-control-monitor.mjs +50 -20
  45. package/cli/domain-control-observations.mjs +151 -103
  46. package/cli/errors.mjs +37 -6
  47. package/cli/evidence-arguments.mjs +46 -0
  48. package/cli/evidence-command-definitions.mjs +69 -0
  49. package/cli/evidence-command-runner.mjs +18 -8
  50. package/cli/evidence-signing.mjs +5 -4
  51. package/cli/external-observation-mapping.mjs +9 -10
  52. package/cli/formatters/terminal-command-formats.mjs +6 -0
  53. package/cli/formatters/terminal-lookup.mjs +24 -46
  54. package/cli/formatters/terminal-shared.mjs +5 -3
  55. package/cli/formatters/terminal-technology.mjs +114 -0
  56. package/cli/history-arguments.mjs +39 -0
  57. package/cli/history-command-definitions.mjs +88 -0
  58. package/cli/indicator-set-command.mjs +33 -0
  59. package/cli/inline-command-families.mjs +6 -46
  60. package/cli/inline-command-runner.mjs +4 -31
  61. package/cli/input.mjs +2 -2
  62. package/cli/intake-command.mjs +107 -0
  63. package/cli/interactive-launcher.mjs +2 -160
  64. package/cli/interchange-report.mjs +8 -10
  65. package/cli/investigation-artifacts.mjs +176 -0
  66. package/cli/investigation-checkpoint.mjs +10 -0
  67. package/cli/investigation-folder.mjs +114 -0
  68. package/cli/investigation-manifest.mjs +1 -112
  69. package/cli/investigation-package-review.mjs +103 -0
  70. package/cli/investigation-plan.mjs +38 -147
  71. package/cli/investigation-recipes.mjs +177 -0
  72. package/cli/investigation-run.mjs +256 -35
  73. package/cli/local-document-checkpoint.mjs +170 -0
  74. package/cli/local-mmdb-review.mjs +197 -17
  75. package/cli/lookalike-calibration.mjs +1 -1
  76. package/cli/lookup-brief.mjs +2 -2
  77. package/cli/lookup-browser.mjs +5 -12
  78. package/cli/lookup-command-runner.mjs +33 -39
  79. package/cli/lookup-diff.mjs +47 -12
  80. package/cli/lookup-plan.mjs +16 -14
  81. package/cli/lookup-reconcile.mjs +15 -12
  82. package/cli/lookup-timeline.mjs +7 -6
  83. package/cli/mail-header-review.mjs +24 -247
  84. package/cli/mail-review.mjs +75 -21
  85. package/cli/manual.mjs +1 -0
  86. package/cli/network-arguments.mjs +51 -0
  87. package/cli/network-command-definitions.mjs +130 -0
  88. package/cli/network-command-runner.mjs +45 -31
  89. package/cli/offline-evidence-review.mjs +39 -6
  90. package/cli/output-file.mjs +31 -5
  91. package/cli/posture.mjs +1 -1
  92. package/cli/registry-doctor.mjs +3 -1
  93. package/cli/registry-fixture-scaffold.mjs +1 -1
  94. package/cli/registry-support.mjs +1 -1
  95. package/cli/retained-artifact-diff.mjs +10 -9
  96. package/cli/review-arguments.mjs +131 -0
  97. package/cli/review-command-definitions.mjs +273 -0
  98. package/cli/review-command-runner.mjs +104 -149
  99. package/cli/risk-calibration.mjs +8 -8
  100. package/cli/runner.mjs +74 -37
  101. package/cli/saved-lookup.mjs +2 -2
  102. package/cli/selected-input-worker.mjs +63 -0
  103. package/cli/sharing-review.mjs +8 -80
  104. package/cli/signer-trust.mjs +104 -0
  105. package/cli/source-reliability.mjs +75 -84
  106. package/cli/support-arguments.mjs +49 -0
  107. package/cli/support-command-definitions.mjs +209 -0
  108. package/cli/support-command-runner.mjs +2 -2
  109. package/cli/terminal-input.mjs +168 -0
  110. package/cli/workflow-arguments.mjs +86 -0
  111. package/cli/workflow-command-definitions.mjs +78 -0
  112. package/cli/workflow-command-runner.mjs +75 -13
  113. package/docs/cli-reference.md +103 -30
  114. package/docs/cli.md +510 -25
  115. package/docs/contextual-reviews.md +155 -0
  116. package/lib/abort.mjs +4 -1
  117. package/lib/analyst-taxonomy.mjs +2 -40
  118. package/lib/application-version.mjs +1 -1
  119. package/lib/availability.mjs +27 -754
  120. package/lib/bounded-contract-normalizers.mjs +4 -0
  121. package/lib/bounded-file.mjs +3 -1
  122. package/lib/bounded-html-document.mjs +125 -0
  123. package/lib/bounded-json.mjs +2 -290
  124. package/lib/browser-library-profile.mjs +99 -130
  125. package/lib/browser-library-worker.mjs +112 -0
  126. package/lib/candidate-provenance-bounds.mjs +2 -0
  127. package/lib/classify.mjs +1 -1
  128. package/lib/client-behavior-profile.mjs +3 -3
  129. package/lib/ct-query.mjs +2 -27
  130. package/lib/ct-response-bounds.mjs +2 -9
  131. package/lib/ct-search.mjs +89 -46
  132. package/lib/dmarc-discovery.mjs +62 -0
  133. package/lib/dns-convergence-review.mjs +21 -8
  134. package/lib/dns-delegation-health.mjs +36 -22
  135. package/lib/dns-inheritance-review.mjs +207 -0
  136. package/lib/dns-intelligence.mjs +44 -29
  137. package/lib/dns-mx.mjs +1 -1
  138. package/lib/dns-query-coverage.mjs +40 -0
  139. package/lib/dnssec-evidence-validation.mjs +11 -3
  140. package/lib/domain-assurance.mjs +1 -1
  141. package/lib/domain-change-packet.mjs +18 -14
  142. package/lib/domain-change-review.mjs +40 -16
  143. package/lib/domain-control-flight-recorder.mjs +301 -124
  144. package/lib/domain-control-manifest.mjs +126 -58
  145. package/lib/domain-posture-analysis.mjs +41 -11
  146. package/lib/domain-posture-parsers.mjs +88 -6
  147. package/lib/domain-posture.mjs +120 -32
  148. package/lib/domain-registration.mjs +392 -0
  149. package/lib/evidence-export-privacy.mjs +568 -0
  150. package/lib/evidence-export.mjs +12 -593
  151. package/lib/evidence-report.mjs +7 -2
  152. package/lib/external-intelligence-risk.mjs +2 -124
  153. package/lib/favicon.mjs +117 -41
  154. package/lib/generated/cisa-kev-catalog.mjs +50 -4
  155. package/lib/generated/registrar-standing-catalogue.mjs +18 -10
  156. package/lib/generated/retire-browser-catalog.mjs +2742 -1034
  157. package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
  158. package/lib/html-signals.mjs +87 -85
  159. package/lib/http-evidence-bounds.mjs +2 -7
  160. package/lib/http-intelligence.mjs +52 -27
  161. package/lib/idn-confusable-policy.mjs +6 -4
  162. package/lib/idn-confusables.mjs +1 -1
  163. package/lib/interchange-fidelity-registry.mjs +10 -0
  164. package/lib/lookup-cache.mjs +34 -11
  165. package/lib/lookup-child-profile-contract.mjs +24 -15
  166. package/lib/lookup-contract-primitives.mjs +1 -1
  167. package/lib/lookup-diagnostics.mjs +2 -1
  168. package/lib/lookup-network-evidence-bounds.mjs +1 -10
  169. package/lib/lookup-progress.mjs +79 -13
  170. package/lib/lookup-response-contract.mjs +1077 -0
  171. package/lib/lookup-response.mjs +11 -24
  172. package/lib/lookup-source-progress.mjs +24 -35
  173. package/lib/lookup.mjs +49 -28
  174. package/lib/observed-network-context.mjs +43 -27
  175. package/lib/opportunity-scoring.mjs +2 -107
  176. package/lib/outbound-identity.mjs +1 -1
  177. package/lib/outbound-request-bounds.mjs +4 -1
  178. package/lib/page-fingerprints.mjs +87 -134
  179. package/lib/page-role-profile.mjs +12 -8
  180. package/lib/perceptual-hash-comparison.mjs +2 -34
  181. package/lib/perceptual-hash.mjs +79 -24
  182. package/lib/portable-generator.mjs +2 -29
  183. package/lib/project-metadata.mjs +2 -8
  184. package/lib/rdap-bootstrap.mjs +72 -22
  185. package/lib/rdap-client.mjs +26 -14
  186. package/lib/rdap-normalization.mjs +33 -18
  187. package/lib/rdap-search-workbench.mjs +1 -1
  188. package/lib/rdap-transport.mjs +47 -20
  189. package/lib/rdap-types.mjs +2 -1
  190. package/lib/rdap.mjs +18 -3
  191. package/lib/registrable-domain.mjs +2 -36
  192. package/lib/registrar-standing-contract.mjs +2 -1
  193. package/lib/registrar-standing.mjs +9 -13
  194. package/lib/registry-capabilities.mjs +7 -3
  195. package/lib/registry-capability-catalogue.mjs +29 -20
  196. package/lib/registry-dates.mjs +2 -190
  197. package/lib/registry-insights.mjs +56 -18
  198. package/lib/response-policy.mjs +86 -48
  199. package/lib/risk-calibration-summary.mjs +2 -338
  200. package/lib/risk-scoring.mjs +2 -345
  201. package/lib/rpki-evidence.mjs +8 -5
  202. package/lib/safe-fetch.mjs +54 -47
  203. package/lib/scoring-evidence-quality.mjs +2 -107
  204. package/lib/security-txt.mjs +10 -7
  205. package/lib/semantic-version.mjs +2 -54
  206. package/lib/service-binding-dns.mjs +35 -0
  207. package/lib/smtp-transport-review.mjs +75 -34
  208. package/lib/sslbl-certificates.generated.mjs +141 -138
  209. package/lib/static-html-analysis.mjs +117 -146
  210. package/lib/structured-data-identity.mjs +2 -1
  211. package/lib/technology-markup.mjs +100 -0
  212. package/lib/threat-intelligence-manifests.mjs +11 -139
  213. package/lib/threat-intelligence-runtime.mjs +24 -52
  214. package/lib/threat-intelligence-targets.mjs +6 -34
  215. package/lib/threat-intelligence-types.mjs +2 -36
  216. package/lib/typosquat-generator.mjs +29 -16
  217. package/lib/urlhaus-intelligence.mjs +8 -3
  218. package/lib/urlscan-intelligence.mjs +1 -1
  219. package/lib/website-enrichment.mjs +388 -0
  220. package/lib/website-security-posture.mjs +30 -6
  221. package/lib/website-technology.mjs +111 -71
  222. package/lib/whois-authority.mjs +55 -5
  223. package/lib/whois-chain.mjs +9 -2
  224. package/lib/whois-common-formats.mjs +5 -5
  225. package/lib/whois-contracts.mjs +2 -1
  226. package/lib/whois-dialects.mjs +2 -2
  227. package/lib/whois-normalization.mjs +3 -1
  228. package/lib/whois-parser-support.mjs +8 -1
  229. package/lib/whois-parser.mjs +5 -3
  230. package/lib/whois-transport.mjs +23 -7
  231. package/package.json +14 -14
  232. package/packages/analysis/analyst-taxonomy.mjs +40 -0
  233. package/packages/analysis/bounded-json.mjs +330 -0
  234. package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
  235. package/packages/analysis/cli-argument-grammar.mjs +216 -0
  236. package/packages/analysis/ct-query.mjs +27 -0
  237. package/packages/analysis/ct-response-bounds.mjs +9 -0
  238. package/packages/analysis/external-intelligence-risk.mjs +124 -0
  239. package/packages/analysis/http-evidence-bounds.mjs +8 -0
  240. package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
  241. package/packages/analysis/opportunity-scoring.mjs +107 -0
  242. package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
  243. package/packages/analysis/portable-generator.mjs +29 -0
  244. package/packages/analysis/project-metadata.mjs +8 -0
  245. package/packages/analysis/registrable-domain.mjs +56 -0
  246. package/packages/analysis/risk-calibration-summary.mjs +352 -0
  247. package/packages/analysis/risk-scoring.mjs +345 -0
  248. package/packages/analysis/scoring-evidence-quality.mjs +107 -0
  249. package/packages/analysis/semantic-version.mjs +54 -0
  250. package/packages/analysis/threat-intelligence-types.mjs +75 -0
  251. package/packages/cases/case-attachment-model.mjs +83 -0
  252. package/packages/cases/case-evidence-links.mjs +135 -0
  253. package/packages/cases/case-evidence-model.mjs +229 -69
  254. package/packages/cases/case-evidence-quality.mjs +5 -0
  255. package/packages/cases/case-export-input.mjs +55 -0
  256. package/packages/cases/case-incident-context.mjs +52 -0
  257. package/packages/cases/case-markdown.mjs +11 -0
  258. package/packages/cases/case-migration-model.mjs +98 -48
  259. package/packages/cases/case-model.mjs +1 -0
  260. package/packages/cases/case-recheck-model.mjs +149 -0
  261. package/packages/cases/case-record-contracts.mjs +1 -1
  262. package/packages/cases/case-record-core.mjs +4 -3
  263. package/packages/cases/case-record-operations.mjs +123 -133
  264. package/packages/cases/case-record-projection.mjs +30 -20
  265. package/packages/cases/case-record-version-input.mjs +19 -0
  266. package/packages/cases/case-report.mjs +114 -67
  267. package/packages/cases/case-requested-evidence.mjs +146 -0
  268. package/packages/cases/case-response-actions.mjs +756 -0
  269. package/packages/cases/case-response-model.mjs +49 -1129
  270. package/packages/cases/case-response-outcomes.mjs +338 -0
  271. package/packages/cases/case-response-packet-render.mjs +157 -0
  272. package/packages/cases/case-response-packet-types.mjs +1 -0
  273. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  274. package/packages/cases/case-response-packet.mjs +58 -593
  275. package/packages/cases/case-response-records.mjs +93 -0
  276. package/packages/cases/case-response-review-inputs.mjs +382 -0
  277. package/packages/cases/case-response-values.mjs +85 -0
  278. package/packages/cases/case-review-package.mjs +45 -0
  279. package/packages/cases/case-selection.mjs +46 -0
  280. package/packages/cases/case-storage-model.mjs +26 -1
  281. package/packages/cases/case-workflow-metadata.mjs +225 -0
  282. package/packages/cases/case-workflow-migration.mjs +43 -0
  283. package/packages/cases/http-summary.mjs +6 -1
  284. package/packages/cases/response-route-freshness.mjs +16 -0
  285. package/packages/comparison/capture-context.mjs +61 -0
  286. package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
  287. package/packages/comparison/favicon-similarity.mjs +1 -1
  288. package/packages/comparison/page-similarity.mjs +3 -2
  289. package/packages/comparison/relationship-evidence.mjs +103 -14
  290. package/packages/comparison/relationship-provenance.mjs +99 -0
  291. package/packages/contracts/analyst-interchange.mjs +234 -0
  292. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  293. package/packages/contracts/analyst-review-state.mjs +5 -4
  294. package/packages/contracts/case-portability.mjs +370 -114
  295. package/packages/contracts/case-views-contract.mjs +12 -0
  296. package/packages/contracts/case-views.mjs +26 -0
  297. package/packages/contracts/cli-command-semantics.mjs +3 -0
  298. package/packages/contracts/cli-grammar.mjs +6 -0
  299. package/packages/contracts/cli-lookup-brief.mjs +2 -0
  300. package/packages/contracts/cli-lookup.mjs +0 -1
  301. package/packages/contracts/context-review.mjs +24 -0
  302. package/packages/contracts/document-intake.mjs +11 -0
  303. package/packages/contracts/domain-control-flight-recorder.mjs +98 -44
  304. package/packages/contracts/domain-control-manifest.mjs +128 -74
  305. package/packages/contracts/domain-control-monitor.mjs +59 -45
  306. package/packages/contracts/domain-control-review.mjs +200 -121
  307. package/packages/contracts/domain-name.mjs +21 -0
  308. package/packages/contracts/external-observation-interchange.mjs +2 -1
  309. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  310. package/packages/contracts/har-review.mjs +2 -0
  311. package/packages/contracts/http-url.mjs +2 -0
  312. package/packages/contracts/identity-events.mjs +4 -0
  313. package/packages/contracts/investigation-package-limits.mjs +24 -0
  314. package/packages/contracts/investigation-portability.mjs +56 -27
  315. package/packages/contracts/investigation-run.mjs +18 -0
  316. package/packages/contracts/ip-address.mjs +44 -0
  317. package/packages/contracts/lookup-evidence.mjs +35 -0
  318. package/packages/contracts/mail-authentication.mjs +5 -0
  319. package/packages/contracts/message-intake.mjs +31 -0
  320. package/packages/contracts/offline-comparison.mjs +4 -2
  321. package/packages/contracts/page-fingerprints.mjs +15 -0
  322. package/packages/contracts/passive-mail.mjs +10 -0
  323. package/packages/contracts/risk-calibration.mjs +0 -1
  324. package/packages/contracts/schema-compatibility.mjs +1 -1
  325. package/packages/contracts/schema-lifecycle.mjs +83 -153
  326. package/packages/contracts/selected-file-limits.mjs +6 -0
  327. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  328. package/packages/contracts/web-capture.mjs +10 -0
  329. package/packages/contracts/workspace-portability.mjs +219 -238
  330. package/packages/evidence/artifact-integrity.mjs +8 -2
  331. package/packages/evidence/artifact-structure.mjs +105 -0
  332. package/packages/evidence/collection-quality.mjs +67 -0
  333. package/packages/evidence/decision-fact.mjs +4 -1
  334. package/packages/evidence/dns-query-outcome.mjs +72 -0
  335. package/packages/evidence/domain-control-runtime.mjs +155 -27
  336. package/packages/evidence/domain-posture-context.mjs +79 -0
  337. package/packages/evidence/email-recipient.mjs +18 -0
  338. package/packages/evidence/image-regions.mjs +56 -0
  339. package/packages/evidence/latest-observations.mjs +28 -0
  340. package/packages/evidence/lookup-target.mjs +108 -0
  341. package/packages/evidence/observation.mjs +18 -1
  342. package/packages/evidence/passphrase-encryption.mjs +44 -0
  343. package/packages/evidence/png-pixels.mjs +117 -0
  344. package/packages/evidence/public-address-policy.mjs +83 -0
  345. package/packages/evidence/record-identity.mjs +72 -0
  346. package/packages/evidence/registry-dates.mjs +190 -0
  347. package/packages/evidence/retained-file.mjs +69 -0
  348. package/packages/evidence/review-age.mjs +14 -0
  349. package/packages/interchange/bagit.mjs +342 -0
  350. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  351. package/packages/interchange/crc32.mjs +17 -0
  352. package/packages/interchange/defensive-indicator-export.mjs +264 -0
  353. package/packages/interchange/external-findings-import.mjs +93 -61
  354. package/packages/interchange/managed-indicator-export.mjs +35 -0
  355. package/packages/interchange/managed-indicator-set.mjs +210 -0
  356. package/packages/interchange/misp-indicator-export.mjs +93 -0
  357. package/packages/interchange/sharing-policy.mjs +56 -0
  358. package/packages/interchange/stix-indicator-export.mjs +129 -0
  359. package/packages/interchange/web-capture-import.mjs +389 -0
  360. package/packages/investigation/acquisition-decision-packet.mjs +1 -1
  361. package/packages/investigation/bulk-mail-exposure.mjs +9 -14
  362. package/packages/investigation/capture-coverage.mjs +57 -0
  363. package/packages/investigation/connector-provenance-review.mjs +100 -0
  364. package/packages/investigation/context-review.mjs +35 -0
  365. package/packages/investigation/document-intake.mjs +81 -0
  366. package/packages/investigation/docx-intake.mjs +133 -0
  367. package/packages/investigation/domain-history-review.mjs +100 -0
  368. package/packages/investigation/domain-transition-review.mjs +47 -0
  369. package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
  370. package/packages/investigation/har-intake.mjs +68 -0
  371. package/packages/investigation/identity-event-intake.mjs +131 -0
  372. package/packages/investigation/identity-incident-review.mjs +39 -0
  373. package/packages/investigation/incident-sequence-review.mjs +58 -0
  374. package/packages/investigation/intake-report.mjs +19 -0
  375. package/packages/investigation/investigation-bagit.mjs +14 -0
  376. package/packages/investigation/investigation-capsule.mjs +33 -5
  377. package/packages/investigation/investigation-manifest.mjs +200 -0
  378. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  379. package/packages/investigation/investigation-package.mjs +200 -0
  380. package/packages/investigation/link-intake.mjs +179 -0
  381. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  382. package/packages/investigation/lookup-asset-graph.mjs +161 -141
  383. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  384. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  385. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  386. package/packages/investigation/mail-authentication-review.mjs +222 -0
  387. package/packages/investigation/mail-header-identity.mjs +209 -0
  388. package/packages/investigation/message-intake.mjs +195 -0
  389. package/packages/investigation/page-behaviour.mjs +113 -0
  390. package/packages/investigation/pdf-intake.mjs +155 -0
  391. package/packages/investigation/platform-continuity-review.mjs +60 -0
  392. package/packages/investigation/qr-intake.mjs +56 -0
  393. package/packages/investigation/requested-action-hints.mjs +11 -0
  394. package/packages/investigation/selected-input-review.mjs +17 -0
  395. package/packages/investigation/storefront-review.mjs +70 -0
  396. package/packages/monitoring/analyst-review-state.mjs +38 -90
  397. package/packages/workspace/brand-profile-model.mjs +160 -22
  398. package/packages/workspace/bulk-columns.mjs +17 -0
  399. package/packages/workspace/bulk-review-model.mjs +39 -31
  400. package/packages/workspace/bulk-session-model.mjs +112 -27
  401. package/packages/workspace/bulk-sort.mjs +1 -0
  402. package/packages/workspace/campaign-model.mjs +1 -1
  403. package/packages/workspace/case-views.mjs +102 -0
  404. package/packages/workspace/detection-rule-model.mjs +1 -1
  405. package/packages/workspace/hostile-input.mjs +29 -8
  406. package/packages/workspace/investigation-guide.mjs +2 -2
  407. package/packages/workspace/investigation-template-model.mjs +45 -6
  408. package/packages/workspace/page-baseline.mjs +20 -17
  409. package/packages/workspace/relationship-observation-model.mjs +54 -35
  410. package/packages/workspace/shortlist-model.mjs +14 -5
  411. package/packages/workspace/watchlist-history.mjs +30 -14
  412. package/packages/workspace/watchlist-store.mjs +24 -6
  413. package/packages/workspace/website-snapshot-model.mjs +98 -33
  414. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  415. package/packages/workspace/workspace-archive.mjs +90 -25
  416. package/third-party-notices.txt +577 -17
  417. package/lib/hostname.mjs +0 -38
  418. package/packages/investigation/bulk-review-export.mjs +0 -63
package/lib/ct-search.mjs CHANGED
@@ -7,13 +7,14 @@
7
7
  // earliest public signal a new lookalike exists at all. Shared by the
8
8
  // Express server and the Netlify Functions.
9
9
  import { parse } from 'tldts';
10
- import { normalizeCtQuery } from "./ct-query.mjs";
10
+ import { setTimeout as sleep } from 'node:timers/promises';
11
+ import { normalizeCtQuery } from "../packages/analysis/ct-query.mjs";
11
12
  import { safeFetch, readTextCapped } from "./safe-fetch.mjs";
12
13
  import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
13
14
  import { createObservation } from "../packages/evidence/observation.mjs";
14
- import { isValidAsciiHostname } from "./hostname.mjs";
15
+ import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
15
16
  import { normalizeCtTimestamp } from "../packages/evidence/observation.mjs";
16
- import { MAX_CT_RESPONSE_CERTIFICATE_GROUPS, MAX_CT_RESPONSE_DOMAINS_PER_GROUP, MAX_CT_RESPONSE_HOSTNAMES_PER_GROUP, MAX_CT_RESPONSE_HOSTNAMES_PER_MATCH, MAX_CT_RESPONSE_RESULTS, MAX_CT_RESPONSE_TIMESTAMP_LENGTH, } from "./ct-response-bounds.mjs";
17
+ import { MAX_CT_RESPONSE_CERTIFICATE_GROUPS, MAX_CT_RESPONSE_DOMAINS_PER_GROUP, MAX_CT_RESPONSE_HOSTNAMES_PER_GROUP, MAX_CT_RESPONSE_HOSTNAMES_PER_MATCH, MAX_CT_RESPONSE_RESULTS, MAX_CT_RESPONSE_TIMESTAMP_LENGTH, } from "../packages/analysis/ct-response-bounds.mjs";
17
18
  const CRT_SH_TIMEOUT_MS = 20000;
18
19
  const MAX_RESULTS = MAX_CT_RESPONSE_RESULTS;
19
20
  // crt.sh's response size scales with how many certificates ever matched the
@@ -37,6 +38,27 @@ const RETRY_DELAY_MS = 1500;
37
38
  // delay before a status-code retry above), so this caps the worst case at
38
39
  // two attempts (~40s) instead of stacking on top of MAX_RETRIES.
39
40
  const MAX_TIMEOUT_RETRIES = 1;
41
+ const COLLECTION_ERRORS = {
42
+ CT_TIMEOUT: [504, 'Certificate search timed out. Try a narrower keyword or try again later.'],
43
+ CT_OVERLOADED: [503, 'Certificate search is temporarily overloaded. Try again later.'],
44
+ CT_QUERY_TOO_BROAD: [422, 'Certificate search returned too many results. Try a narrower keyword.'],
45
+ CT_INVALID_RESPONSE: [502, 'Certificate search returned invalid UTF-8 or an unexpected response format (expected a JSON array). Try again later.'],
46
+ CT_UPSTREAM_ERROR: [502, 'Certificate search could not retrieve a response from its source. Try again later.'],
47
+ };
48
+ export class CtCollectionError extends Error {
49
+ code;
50
+ constructor(code) {
51
+ super(COLLECTION_ERRORS[code][1]);
52
+ this.code = code;
53
+ this.name = 'CtCollectionError';
54
+ }
55
+ }
56
+ /** Only this finite collection vocabulary may cross an HTTP error boundary. */
57
+ export function ctCollectionErrorResponse(error) {
58
+ return error instanceof CtCollectionError
59
+ ? { statusCode: COLLECTION_ERRORS[error.code][0], body: { error: COLLECTION_ERRORS[error.code][1], errorCode: error.code } }
60
+ : null;
61
+ }
40
62
  // Row-count defense: a hostile or malformed response could fit a very large
41
63
  // number of tiny rows into the 5 MB byte cap and impose excessive
42
64
  // iteration/set overhead after parsing. Legitimate responses under the byte
@@ -71,59 +93,79 @@ function normalizeHostname(raw) {
71
93
  h = h.slice(2);
72
94
  return HOSTNAME_RE.test(h) && isValidAsciiHostname(h, { requireLowercase: true }) ? h : null;
73
95
  }
74
- function delay(ms) {
75
- return new Promise((resolve) => setTimeout(resolve, ms));
96
+ function delay(ms, signal) {
97
+ return sleep(ms, undefined, signal ? { signal } : {});
76
98
  }
77
- async function fetchCrtSh(keyword, attempt = 0, dependencies = {}) {
99
+ async function fetchCrtSh(keyword, dependencies = {}) {
100
+ dependencies.signal?.throwIfAborted();
78
101
  const fetcher = dependencies.fetcher || safeFetch;
79
102
  const wait = dependencies.delay || delay;
80
- const controller = new AbortController();
81
- const timeout = setTimeout(() => controller.abort(), CRT_SH_TIMEOUT_MS);
82
- try {
83
- let res;
103
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
104
+ dependencies.signal?.throwIfAborted();
105
+ const controller = new AbortController();
106
+ const signal = dependencies.signal ? AbortSignal.any([dependencies.signal, controller.signal]) : controller.signal;
107
+ const timeout = setTimeout(() => controller.abort(), CRT_SH_TIMEOUT_MS);
108
+ let retryDelay = 0;
84
109
  try {
85
- res = await fetcher(`https://crt.sh/?q=${encodeURIComponent(keyword)}&output=json`, {
110
+ const res = await fetcher(`https://crt.sh/?q=${encodeURIComponent(keyword)}&output=json`, {
86
111
  headers: whoisleuthRequestHeaders({ Accept: 'application/json' }),
87
- signal: controller.signal,
112
+ signal,
88
113
  }, 0);
89
- }
90
- catch (err) {
91
- if (!(err instanceof Error) || err.name !== 'AbortError')
92
- throw err;
93
- if (attempt < MAX_TIMEOUT_RETRIES)
94
- return fetchCrtSh(keyword, attempt + 1, dependencies);
95
- throw new Error(`crt.sh took too long to respond (>${CRT_SH_TIMEOUT_MS / 1000}s per attempt, ${attempt + 1} attempts) - ` +
96
- "it can be slow for broad search terms or under heavy load; try a narrower keyword or try again shortly.");
97
- }
98
- if (!res.ok) {
99
- // Not reading this body - release it explicitly instead of leaving an
100
- // unconsumed stream (and the connection it's tied to) open until
101
- // undici's own idle-timeout eventually notices.
102
- await res.body?.cancel().catch(() => { });
103
- if (RETRYABLE_STATUSES.has(res.status) && attempt < MAX_RETRIES) {
104
- await wait(RETRY_DELAY_MS * (attempt + 1));
105
- return fetchCrtSh(keyword, attempt + 1, dependencies);
114
+ if (!res.ok) {
115
+ await res.body?.cancel().catch(() => { });
116
+ if (RETRYABLE_STATUSES.has(res.status) && attempt < MAX_RETRIES) {
117
+ retryDelay = RETRY_DELAY_MS * (attempt + 1);
118
+ }
119
+ else {
120
+ throw new CtCollectionError(RETRYABLE_STATUSES.has(res.status) || res.status === 429 ? 'CT_OVERLOADED' : 'CT_UPSTREAM_ERROR');
121
+ }
122
+ }
123
+ else {
124
+ let text;
125
+ try {
126
+ const body = await readTextCapped(res, CRT_SH_MAX_BYTES, { fatalUtf8: true });
127
+ signal.throwIfAborted();
128
+ if (body.truncated)
129
+ throw new CtCollectionError('CT_QUERY_TOO_BROAD');
130
+ text = body.text;
131
+ }
132
+ catch (error) {
133
+ // Preserve transport/deadline failures for the attempt retry below.
134
+ if (error instanceof TypeError && !signal.aborted) {
135
+ throw new CtCollectionError('CT_INVALID_RESPONSE');
136
+ }
137
+ throw error;
138
+ }
139
+ let data;
140
+ try {
141
+ data = JSON.parse(text);
142
+ }
143
+ catch {
144
+ throw new CtCollectionError('CT_INVALID_RESPONSE');
145
+ }
146
+ if (!Array.isArray(data))
147
+ throw new CtCollectionError('CT_INVALID_RESPONSE');
148
+ if (data.length > MAX_CT_ROWS)
149
+ throw new CtCollectionError('CT_QUERY_TOO_BROAD');
150
+ return data;
106
151
  }
107
- throw new Error(RETRYABLE_STATUSES.has(res.status)
108
- ? `crt.sh is temporarily overloaded (${res.status} after ${attempt + 1} attempts) - it's a free public service that intermittently struggles under load; try again in a moment.`
109
- : `crt.sh returned ${res.status}`);
110
- }
111
- const { text, truncated } = await readTextCapped(res, CRT_SH_MAX_BYTES, { fatalUtf8: true });
112
- if (truncated) {
113
- throw new Error(`crt.sh returned more than ${CRT_SH_MAX_BYTES / (1024 * 1024)}MB of results for "${keyword}" - try a narrower/more specific keyword.`);
114
152
  }
115
- const data = JSON.parse(text);
116
- if (!Array.isArray(data)) {
117
- throw new Error('crt.sh returned an unexpected response format (expected a JSON array).');
153
+ catch (error) {
154
+ dependencies.signal?.throwIfAborted();
155
+ if (!controller.signal.aborted && (!(error instanceof Error) || error.name !== 'AbortError'))
156
+ throw error;
157
+ if (attempt >= MAX_TIMEOUT_RETRIES)
158
+ throw new CtCollectionError('CT_TIMEOUT');
118
159
  }
119
- if (data.length > MAX_CT_ROWS) {
120
- throw new Error(`crt.sh returned too many rows (${data.length}) for "${keyword}" - try a narrower keyword.`);
160
+ finally {
161
+ // End this attempt before delay/retry: body reads share the same deadline
162
+ // as headers, and earlier timers cannot fire during a later attempt.
163
+ clearTimeout(timeout);
121
164
  }
122
- return data;
123
- }
124
- finally {
125
- clearTimeout(timeout);
165
+ if (retryDelay)
166
+ await wait(retryDelay, dependencies.signal);
126
167
  }
168
+ throw new CtCollectionError('CT_UPSTREAM_ERROR');
127
169
  }
128
170
  // ---------------------------------------------------------------------------
129
171
  // Certificate identity
@@ -464,6 +506,7 @@ function summarizeCtResults(rows) {
464
506
  // Production search
465
507
  // ---------------------------------------------------------------------------
466
508
  async function searchCertificateTransparency(keyword, dependencies = {}) {
509
+ dependencies.signal?.throwIfAborted();
467
510
  const startedAt = Date.now();
468
511
  const trimmed = normalizeCtQuery(keyword);
469
512
  if (!trimmed) {
@@ -478,7 +521,7 @@ async function searchCertificateTransparency(keyword, dependencies = {}) {
478
521
  }),
479
522
  };
480
523
  }
481
- const data = await fetchCrtSh(trimmed, 0, dependencies);
524
+ const data = await fetchCrtSh(trimmed, dependencies);
482
525
  const summary = summarizeCtResults(data);
483
526
  return {
484
527
  certCount: data.length,
@@ -0,0 +1,62 @@
1
+ import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
2
+ import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
3
+ import { parseDmarcRecords } from "./domain-posture-parsers.mjs";
4
+ export function requireDmarcDomain(domain) {
5
+ if (typeof domain !== 'string' || domain.length > 253 || !isValidAsciiHostname(domain) || domain !== domain.toLowerCase()) {
6
+ throw new TypeError('The additional DNS review requires a normalised domain.');
7
+ }
8
+ }
9
+ export function dmarcPolicyObservation(owner, observation) {
10
+ const records = observation.records;
11
+ let bytes = 0;
12
+ const admitted = Array.isArray(records) && records.length <= 64 && records.every(raw => {
13
+ const parts = typeof raw === 'string' ? [raw] : raw;
14
+ return Array.isArray(parts) && parts.length <= 256 && parts.every(part => {
15
+ if (typeof part !== 'string' || part.length > 4096)
16
+ return false;
17
+ bytes += Buffer.byteLength(part, 'utf8');
18
+ return bytes <= 65_536;
19
+ });
20
+ });
21
+ const parsed = parseDmarcRecords(admitted ? records : []);
22
+ const unavailable = Boolean(observation.error) || !admitted;
23
+ const psd = parsed.tags.psd?.toLowerCase() ?? 'u';
24
+ const valid = !unavailable && parsed.valid && ['u', 'n', 'y'].includes(psd);
25
+ const incomplete = unavailable || (!valid && parsed.records.length === 1);
26
+ const state = unavailable ? 'unavailable' : valid ? 'observed' : parsed.records.length ? 'invalid' : 'not found';
27
+ const description = valid
28
+ ? `p=${parsed.policy}; sp=${parsed.subdomainPolicy}; np=${parsed.nonexistentSubdomainPolicy}; psd=${psd}; test=${parsed.testMode ? 'yes' : 'no'}`
29
+ : state;
30
+ const time = typeof observation.observedAt === 'string' && observation.observedAt.length <= 64
31
+ ? normalizeExplicitIsoTimestamp(observation.observedAt) : null;
32
+ return { owner, parsed, psd, valid, unavailable, incomplete,
33
+ record: `Recursive DNS TXT _dmarc.${owner} · ${time ?? 'time unavailable'} · ${description}` };
34
+ }
35
+ /** RFC 9989 sections 4.10–4.10.2: at most eight owners, never PSL inference. */
36
+ export async function walkDmarcTree(domain, exact, resolve, purpose) {
37
+ requireDmarcDomain(domain);
38
+ const rows = [dmarcPolicyObservation(domain, exact)];
39
+ const first = rows[0];
40
+ const labels = domain.split('.');
41
+ let next = labels.length >= 8 ? labels.slice(-7) : labels.slice(1);
42
+ const policyOnly = purpose === 'policy' && first.valid;
43
+ if (!policyOnly && !first.unavailable && !(first.valid && ['n', 'y'].includes(first.psd))) {
44
+ while (next.length) {
45
+ const owner = next.join('.');
46
+ const row = dmarcPolicyObservation(owner, await resolve(`_dmarc.${owner}`));
47
+ rows.push(row);
48
+ if (row.unavailable || (row.valid && ['n', 'y'].includes(row.psd)))
49
+ break;
50
+ next = next.slice(1);
51
+ }
52
+ }
53
+ const available = rows.filter(row => row.valid);
54
+ const boundary = available.find(row => row.psd === 'n' || (row.psd === 'y' && row.owner !== domain));
55
+ const incomplete = rows.some(row => row.incomplete);
56
+ // An exact policy alone does not establish the organisational boundary.
57
+ const organisationalDomain = incomplete || (policyOnly && !['n', 'y'].includes(first.psd)) ? null
58
+ : boundary?.psd === 'y'
59
+ ? labels.slice(-(boundary.owner.split('.').length + 1)).join('.')
60
+ : boundary?.owner ?? available.at(-1)?.owner ?? domain;
61
+ return { rows, available, boundary, incomplete, organisationalDomain };
62
+ }
@@ -1,14 +1,18 @@
1
1
  import { exactKeys, requireBoundedString, requireDomainName, requireIsoTimestamp, requireRecord, } from "./bounded-contract-normalizers.mjs";
2
2
  import { normaliseRdata } from "./zone-intent-review.mjs";
3
+ import { dnsQueryState, normaliseDnsQueryCoverage } from "./dns-query-coverage.mjs";
3
4
  export const DNS_CONVERGENCE_INPUT_SCHEMA = 'whoisleuth.dns-convergence.input';
4
5
  export const DNS_CONVERGENCE_REVIEW_SCHEMA = 'whoisleuth.dns-convergence.review';
5
- export const DNS_CONVERGENCE_REVIEW_VERSION = 1;
6
+ export const DNS_CONVERGENCE_INPUT_VERSION = 2;
7
+ export const SUPPORTED_DNS_CONVERGENCE_INPUT_VERSIONS = Object.freeze([1, DNS_CONVERGENCE_INPUT_VERSION]);
8
+ export const DNS_CONVERGENCE_REVIEW_VERSION = 2;
6
9
  export const MAX_DNS_CONVERGENCE_SNAPSHOTS = 100;
7
10
  export const MAX_DNS_CONVERGENCE_RECORDS = 2_000;
8
11
  const RECORD_TYPES = new Set(['A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NS', 'SOA', 'SRV', 'SVCB', 'HTTPS', 'TLSA', 'TXT']);
9
12
  const ROOT_KEYS = new Set(['schema', 'version', 'domain', 'expected', 'snapshots']);
10
13
  const EXPECTED_KEYS = new Set(['owner', 'type', 'values']);
11
14
  const SNAPSHOT_KEYS = new Set(['observer', 'source', 'observedAt', 'state', 'records']);
15
+ const COVERED_SNAPSHOT_KEYS = new Set([...SNAPSHOT_KEYS, 'queries']);
12
16
  const RECORD_KEYS = new Set(['owner', 'type', 'value', 'ttl']);
13
17
  function type(value, label) {
14
18
  const candidate = requireBoundedString(value, label, 16).toUpperCase();
@@ -83,8 +87,8 @@ function expectedRows(value, apex) {
83
87
  }
84
88
  export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toISOString()) {
85
89
  const input = requireRecord(inputRaw, 'DNS convergence input');
86
- if (input.schema !== DNS_CONVERGENCE_INPUT_SCHEMA || input.version !== 1) {
87
- throw new TypeError(`DNS convergence input must use ${DNS_CONVERGENCE_INPUT_SCHEMA} version 1.`);
90
+ if (input.schema !== DNS_CONVERGENCE_INPUT_SCHEMA || !SUPPORTED_DNS_CONVERGENCE_INPUT_VERSIONS.includes(input.version)) {
91
+ throw new TypeError(`DNS convergence input must use ${DNS_CONVERGENCE_INPUT_SCHEMA} version 1 or ${DNS_CONVERGENCE_INPUT_VERSION}.`);
88
92
  }
89
93
  exactKeys(input, ROOT_KEYS, 'DNS convergence input');
90
94
  const apex = requireDomainName(input.domain, 'domain');
@@ -93,9 +97,10 @@ export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toI
93
97
  throw new TypeError(`snapshots must contain between 2 and ${MAX_DNS_CONVERGENCE_SNAPSHOTS} entries.`);
94
98
  }
95
99
  let recordCount = 0;
100
+ let queryCount = 0;
96
101
  const snapshots = input.snapshots.map((raw, index) => {
97
102
  const item = requireRecord(raw, `snapshots[${index}]`);
98
- exactKeys(item, SNAPSHOT_KEYS, `snapshots[${index}]`);
103
+ exactKeys(item, input.version === 1 ? SNAPSHOT_KEYS : COVERED_SNAPSHOT_KEYS, `snapshots[${index}]`);
99
104
  if (!Array.isArray(item.records))
100
105
  throw new TypeError(`snapshots[${index}].records must be an array.`);
101
106
  recordCount += item.records.length;
@@ -104,12 +109,20 @@ export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toI
104
109
  const observationState = state(item.state, `snapshots[${index}].state`);
105
110
  if (observationState === 'unavailable' && item.records.length)
106
111
  throw new TypeError(`snapshots[${index}] cannot contain records when unavailable.`);
112
+ const records = Object.freeze(item.records.map((entry, recordIndex) => record(entry, apex, `snapshots[${index}].records[${recordIndex}]`)));
113
+ const queries = normaliseDnsQueryCoverage(item.queries, {
114
+ legacy: input.version === 1, label: `snapshots[${index}].queries`,
115
+ maximum: MAX_DNS_CONVERGENCE_RECORDS - queryCount, state: observationState, records,
116
+ owner: (value, label) => owner(value, apex, label), type,
117
+ });
118
+ queryCount += queries.length;
107
119
  return Object.freeze({
108
120
  observer: requireBoundedString(item.observer, `snapshots[${index}].observer`, 120),
109
121
  source: requireBoundedString(item.source, `snapshots[${index}].source`, 240),
110
122
  observedAt: requireIsoTimestamp(item.observedAt, `snapshots[${index}].observedAt`),
111
123
  state: observationState,
112
- records: Object.freeze(item.records.map((entry, recordIndex) => record(entry, apex, `snapshots[${index}].records[${recordIndex}]`))),
124
+ records,
125
+ queries,
113
126
  });
114
127
  }).sort((left, right) => left.observedAt.localeCompare(right.observedAt) || left.observer.localeCompare(right.observer));
115
128
  const observers = [...new Set(snapshots.map((item) => item.observer))];
@@ -118,7 +131,7 @@ export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toI
118
131
  const latestByObserver = observers.map((observer) => snapshots.filter((item) => item.observer === observer).at(-1));
119
132
  const keys = [...new Set([
120
133
  ...expected.map((item) => `${item.owner}\u0000${item.type}`),
121
- ...snapshots.flatMap((item) => item.records.map((entry) => `${entry.owner}\u0000${entry.type}`)),
134
+ ...snapshots.flatMap((item) => item.queries.map((entry) => `${entry.owner}\u0000${entry.type}`)),
122
135
  ])].sort();
123
136
  const rows = keys.map((key) => {
124
137
  const [recordOwner = '', recordType = ''] = key.split('\u0000');
@@ -134,7 +147,7 @@ export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toI
134
147
  observer: snapshot.observer,
135
148
  source: snapshot.source,
136
149
  observedAt: snapshot.observedAt,
137
- state: snapshot.state,
150
+ state: dnsQueryState(snapshot.queries, recordOwner, recordType),
138
151
  values: Object.freeze(values),
139
152
  cacheUntil,
140
153
  });
@@ -178,7 +191,7 @@ export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toI
178
191
  'This local workbench compares only supplied observations and makes no DNS request.',
179
192
  'Resolver differences can reflect caches, split-horizon policy, interception, collection timing, or propagation; they do not establish which value is authoritative.',
180
193
  'Cache-until times are simple observation-time plus supplied TTL projections and do not reveal actual cache age, refresh behaviour, or resolver policy.',
181
- 'Partial and unavailable snapshots remain incomplete and are never interpreted as record absence.',
194
+ 'Only an explicitly observed query can establish an empty answer. Unqueried, partial and unavailable record types are never interpreted as absence; legacy inputs establish scope only for their supplied records.',
182
195
  ]),
183
196
  });
184
197
  }
@@ -1,11 +1,12 @@
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 { isValidAsciiHostname } from "./hostname.mjs";
8
+ import { normalizeResolverMxRecord } from "../packages/evidence/domain-control-runtime.mjs";
9
+ import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
9
10
  import { isPrivateAddress } from "./safe-fetch.mjs";
10
11
  const DNS_DELEGATION_HEALTH_VERSION = 1;
11
12
  const MAX_AUTHORITIES = 4;
@@ -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;
@@ -464,8 +465,10 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
464
465
  .filter((primary) => primary !== null))];
465
466
  const soaSerialConflict = soaSerials.length > 1;
466
467
  const soaPrimaryConflict = soaPrimaries.length > 1;
467
- const inBailiwick = candidates.filter((nameserver) => (nameserver === domain || nameserver.endsWith(`.${domain}`)));
468
- const missingGlue = inBailiwick.filter((nameserver) => (!(registry.nameserverDetails.find((item) => item.name === nameserver)?.addresses.length)));
468
+ const glueInventoryComplete = (parentStatus === 'success' && parentNameservers.length > 0 && parentQuery.truncated !== true)
469
+ || (registry.nameservers.length > 0 && !registry.truncated);
470
+ const inBailiwick = eligibleAuthorities.filter((nameserver) => (nameserver === domain || nameserver.endsWith(`.${domain}`)));
471
+ const unobservedGlue = inBailiwick.filter((nameserver) => (!(registry.nameserverDetails.find((item) => item.name === nameserver)?.addresses.length)));
469
472
  const dnssecMismatch = (registry.delegationSigned === true && registry.dsRecordCount === 0) || (registry.delegationSigned === false && registry.dsRecordCount > 0);
470
473
  const recordMatrix = authorityRecordMatrix(authorities);
471
474
  const recordCollectionAttempted = authorities.some((authority) => authority.recordSets.length > 0);
@@ -473,13 +476,13 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
473
476
  const recordPartial = recordMatrix.filter((row) => row.state === 'partial');
474
477
  const recordInsufficient = recordMatrix.filter((row) => row.state === 'insufficient');
475
478
  const findings = [
476
- finding('parent_registry_ns', 'Parent and registry nameservers', !parentNameservers.length || !registry.nameservers.length
479
+ finding('parent_registry_ns', 'Recursive and registry nameservers', !parentNameservers.length || !registry.nameservers.length
477
480
  ? 'unknown'
478
481
  : sameSet(parentNameservers, registry.nameservers) ? 'healthy' : 'warning', !parentNameservers.length || !registry.nameservers.length
479
482
  ? 'Comparison is incomplete'
480
483
  : 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.'),
484
+ ? 'Recursive observation and registry publication agree'
485
+ : '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
486
  finding('authority_reachability', 'Direct nameserver reachability', lameAuthorities.length ? 'danger'
484
487
  : partialAuthorities.length || unreachableAuthorities.length ? 'warning'
485
488
  : successfulAuthorities.length ? 'healthy' : 'unknown', lameAuthorities.length
@@ -496,7 +499,7 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
496
499
  ? 'No direct authority answer was available'
497
500
  : inconsistentAuthoritySets
498
501
  ? '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.'),
502
+ : '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
503
  finding('authority_soa_consistency', 'Authoritative SOA consistency', retainedSoa.length < 2 || soaSerials.length < 1 || soaPrimaries.length < 1
501
504
  ? 'unknown'
502
505
  : soaSerialConflict || soaPrimaryConflict ? 'warning' : 'healthy', retainedSoa.length < 2
@@ -506,11 +509,13 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
506
509
  : soaPrimaryConflict
507
510
  ? 'Authoritative servers published different SOA primary nameservers'
508
511
  : '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.'),
509
- finding('in_bailiwick_glue', 'In-bailiwick glue', !inBailiwick.length ? 'healthy' : missingGlue.length ? 'warning' : 'healthy', !inBailiwick.length
510
- ? 'No in-bailiwick nameserver requires registry glue'
511
- : missingGlue.length
512
- ? 'Registry glue was not observed for every in-bailiwick nameserver'
513
- : '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.'),
512
+ finding('in_bailiwick_glue', 'In-bailiwick glue', !glueInventoryComplete || unobservedGlue.length ? 'unknown' : 'healthy', !glueInventoryComplete
513
+ ? 'The delegated nameserver inventory is unavailable or incomplete'
514
+ : !inBailiwick.length
515
+ ? 'No in-bailiwick nameserver requires registry glue'
516
+ : unobservedGlue.length
517
+ ? 'Registry address publication does not establish glue for every in-bailiwick nameserver'
518
+ : '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.'),
514
519
  finding('dnssec_delegation', 'DNSSEC delegation publication', dnssecMismatch ? 'warning'
515
520
  : registry.delegationSigned === null && registry.dsRecordCount === 0 ? 'unknown' : 'healthy', dnssecMismatch
516
521
  ? 'Registry DNSSEC fields are internally inconsistent'
@@ -530,6 +535,7 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
530
535
  ];
531
536
  const collectionIncomplete = parentStatus !== 'success'
532
537
  || candidates.length === 0
538
+ || omittedAuthorityCount > 0
533
539
  || parentQuery.truncated === true
534
540
  || registry.truncated
535
541
  || authorities.some((authority) => authority.state !== 'success')
@@ -540,6 +546,7 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
540
546
  const observedAt = (options.observedAt || (() => new Date().toISOString()))();
541
547
  return {
542
548
  delegationHealthVersion: DNS_DELEGATION_HEALTH_VERSION,
549
+ domain,
543
550
  ...createObservation({
544
551
  status,
545
552
  observedAt,
@@ -549,9 +556,13 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
549
556
  complete: !collectionIncomplete,
550
557
  truncated: parentQuery.truncated === true
551
558
  || registry.truncated
559
+ || omittedAuthorityCount > 0
552
560
  || (recordCollectionAttempted && recordMatrix.some((row) => row.observations.some((item) => item.truncated))),
553
561
  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.',
562
+ '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.',
563
+ ...(omittedAuthorityCount > 0
564
+ ? [`${candidates.length} of ${eligibleAuthorities.length} eligible nameservers were selected; ${omittedAuthorityCount} were not queried.`]
565
+ : []),
555
566
  'Direct queries use at most four selected nameservers and two validated public addresses per nameserver.',
556
567
  '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
568
  'A direct answer does not prove global reachability, and a failed query is not evidence that the record is absent.',
@@ -563,6 +574,9 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
563
574
  ],
564
575
  diagnostics: {
565
576
  authorityCount: authorities.length,
577
+ eligibleAuthorityCount: eligibleAuthorities.length,
578
+ queriedAuthorityCount: authorities.filter((authority) => authority.attempts.length > 0).length,
579
+ omittedAuthorityCount,
566
580
  queriedAddressCount: authorities.reduce((sum, authority) => sum + authority.attempts.length, 0),
567
581
  successfulAuthorityCount: successfulAuthorities.length,
568
582
  partialAuthorityCount: partialAuthorities.length,
@@ -576,7 +590,7 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
576
590
  ? 'The bounded delegation-health collection completed.'
577
591
  : status === 'partial'
578
592
  ? '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.',
593
+ : 'The delegation-health collection could not confirm a recursive or direct authority observation.',
580
594
  parent: {
581
595
  state: parentStatus,
582
596
  nameservers: parentNameservers,