@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
@@ -0,0 +1,207 @@
1
+ import { promises as dns } from 'node:dns';
2
+ import { parse as parseDomain } from 'tldts';
3
+ import { validateDmarcExternalReporting } from "./domain-posture-analysis.mjs";
4
+ import { requireDmarcDomain, walkDmarcTree } from "./dmarc-discovery.mjs";
5
+ import { buildDnssecQuery, DNS_TYPE_NS, normalizeResolverEndpoint, parseDnssecResponse } from "./dnssec-chain-validation.mjs";
6
+ import { defaultTcpExchange } from "./service-binding-dns.mjs";
7
+ import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
8
+ const MAX_PARENT_SERVERS = 2;
9
+ const MAX_NS = 16;
10
+ const QUERY_TIMEOUT_MS = 2200;
11
+ const REVIEW_TIMEOUT_MS = 10_000;
12
+ export const MAX_DMARC_REVIEW_QUERIES = 32;
13
+ const MISSING = new Set(['ENODATA', 'ENOTFOUND', 'ENONAME']);
14
+ /** Explicit query parameter; malformed opt-ins never broaden collection. */
15
+ export function parseInheritedDnsSelection(value) {
16
+ if (value === undefined || value === '')
17
+ return undefined;
18
+ if (value === '1')
19
+ return true;
20
+ throw new TypeError('includeInheritedDns must be 1 when requested.');
21
+ }
22
+ function check(id, label, status, summary, detail, records) {
23
+ return { id, label, status, summary, detail, records, remediation: '' };
24
+ }
25
+ /** RFC 9989 sections 4.10–4.10.2; at most eight owners including the exact name. */
26
+ export async function reviewInheritedDmarc(domain, exact, resolve) {
27
+ const { rows, available, boundary, incomplete, organisationalDomain } = await walkDmarcTree(domain, exact, resolve, 'policy');
28
+ const explicit = rows[0].valid ? rows[0] : null;
29
+ const selected = explicit ?? available.find(row => row.owner === organisationalDomain) ?? (boundary?.psd === 'y' ? boundary : null);
30
+ const summary = incomplete ? 'Inherited policy could not be determined from the collected records.'
31
+ : selected ? `${selected.owner === domain ? 'Exact-name' : 'Inherited'} policy published at _dmarc.${selected.owner}.`
32
+ : 'No applicable DMARC policy was found in the completed tree walk.';
33
+ const detail = !incomplete && selected
34
+ ? selected.owner === domain
35
+ ? `Published policy: ${selected.parsed.policy}. Test mode: ${selected.parsed.testMode ? 'yes' : 'no'}.`
36
+ : `For an existing name: ${selected.parsed.subdomainPolicy}. For a nonexistent name: ${selected.parsed.nonexistentSubdomainPolicy}. Test mode: ${selected.parsed.testMode ? 'yes' : 'no'}. Name existence is not inferred from this review.`
37
+ : 'Invalid or unavailable records do not establish absence or a usable inherited policy.';
38
+ return check('dmarc_inheritance', 'Inherited DMARC policy', incomplete || !selected ? 'warning' : 'info', summary, `${detail} Discovery follows RFC 9989 with at most eight owners. This is publication evidence, not message authentication or a guarantee of receiver behaviour.`, rows.map(row => row.record));
39
+ }
40
+ function nameservers(value) {
41
+ if (!Array.isArray(value) || value.length > MAX_NS)
42
+ return null;
43
+ const names = value.map(raw => typeof raw === 'string' && raw.length <= 254 ? raw.toLowerCase().replace(/\.$/u, '') : '');
44
+ return names.every(name => name.length <= 253 && isValidAsciiHostname(name)) ? [...new Set(names)].sort() : null;
45
+ }
46
+ export async function reviewParentDelegation(domain, recursive, dependencies, signal) {
47
+ requireDmarcDomain(domain);
48
+ const parsed = parseDomain(domain, { allowPrivateDomains: false });
49
+ const child = parsed.domain;
50
+ const parent = parsed.publicSuffix;
51
+ const records = [];
52
+ let referrals = 0;
53
+ let differs = false;
54
+ let parentDiffers = false;
55
+ let firstReferral = null;
56
+ let incomplete = false;
57
+ const observed = recursive.error || domain !== child ? null : nameservers(recursive.records);
58
+ const finish = () => check('parent_delegation', 'Direct parent delegation', differs || parentDiffers || incomplete || !referrals ? 'warning' : 'info', parentDiffers ? 'Sampled parent servers returned different delegation referrals.'
59
+ : differs ? 'Parent referral and recursive nameserver observations differ.'
60
+ : referrals ? `${referrals} parent server${referrals === 1 ? '' : 's'} returned a delegation referral for ${child}.`
61
+ : 'No direct parent referral was established.', `Registration-domain scope: ${child ?? 'unavailable'}; parent candidate: ${parent ?? 'unavailable'}. At most two discovered parent servers are queried over pinned public-address DNS/TCP, without recursion. This is a sample, not full delegation or DNSSEC validation.${incomplete ? ' Some discovery or direct observations were unavailable or incomplete.' : ''}${!observed ? ' No complete same-scope recursive comparison is available.' : ''}`, records);
62
+ if (!child || !parent || child === parent)
63
+ return finish();
64
+ signal?.throwIfAborted();
65
+ let discovered;
66
+ try {
67
+ discovered = nameservers(await dependencies.resolveNs(parent));
68
+ }
69
+ catch {
70
+ signal?.throwIfAborted();
71
+ incomplete = true;
72
+ return finish();
73
+ }
74
+ if (!discovered?.length) {
75
+ incomplete = true;
76
+ return finish();
77
+ }
78
+ records.push(`Recursive DNS NS ${parent} · ${dependencies.now().toISOString()} · ${discovered.length} discovered; ${Math.min(MAX_PARENT_SERVERS, discovered.length)} selected.`);
79
+ for (const server of discovered.slice(0, MAX_PARENT_SERVERS)) {
80
+ signal?.throwIfAborted();
81
+ const resolutions = await Promise.allSettled([dependencies.resolve4(server), dependencies.resolve6(server)]);
82
+ signal?.throwIfAborted();
83
+ const failed = resolutions.some(result => result.status === 'fulfilled'
84
+ ? !Array.isArray(result.value) || result.value.length > 16
85
+ : !MISSING.has(String(result.reason?.code)));
86
+ if (failed) {
87
+ incomplete = true;
88
+ records.push(`${server}: public-address discovery unavailable or rejected.`);
89
+ continue;
90
+ }
91
+ const addresses = resolutions.flatMap(result => result.status === 'fulfilled' ? result.value : []);
92
+ const endpoints = addresses.map(normalizeResolverEndpoint);
93
+ if (!addresses.length || endpoints.some(endpoint => !endpoint)) {
94
+ incomplete = true;
95
+ records.push(`${server}: public-address discovery unavailable or rejected.`);
96
+ continue;
97
+ }
98
+ const endpoint = endpoints.filter((value) => value !== null)
99
+ .sort((a, b) => a.address < b.address ? -1 : a.address > b.address ? 1 : 0)[0];
100
+ const query = buildDnssecQuery(child, DNS_TYPE_NS);
101
+ query.writeUInt16BE(0, 2); // No recursion or authenticated-data assertion is requested.
102
+ try {
103
+ const responseBytes = await dependencies.exchange(query, endpoint, { timeoutMs: QUERY_TIMEOUT_MS, ...(signal ? { signal } : {}) });
104
+ signal?.throwIfAborted();
105
+ if (responseBytes.byteLength > 65_535)
106
+ throw new RangeError('The DNS response exceeds the wire limit.');
107
+ const response = parseDnssecResponse(responseBytes, { transactionId: query.readUInt16BE(0), name: child, type: DNS_TYPE_NS });
108
+ const referred = nameservers(response.records.filter(row => row.section === 'authority' && row.owner === child && row.type === DNS_TYPE_NS && row.class === 1)
109
+ .flatMap(row => row.data.kind === 'NS' ? [row.data.name] : []));
110
+ records.push(`Direct DNS/TCP ${server} [${endpoint.address}] · ${dependencies.now().toISOString()} · ${child} NS · response ${response.rcode}.`);
111
+ if (response.rcode !== 0 || !referred?.length) {
112
+ incomplete = true;
113
+ continue;
114
+ }
115
+ referrals += 1;
116
+ if (firstReferral && JSON.stringify(firstReferral) !== JSON.stringify(referred))
117
+ parentDiffers = true;
118
+ firstReferral ??= referred;
119
+ for (const name of referred)
120
+ records.push(`${server} authority section: ${child} NS ${name}`);
121
+ if (observed && JSON.stringify(observed) !== JSON.stringify(referred))
122
+ differs = true;
123
+ }
124
+ catch {
125
+ signal?.throwIfAborted();
126
+ incomplete = true;
127
+ records.push(`${server}: bounded direct referral query failed.`);
128
+ }
129
+ }
130
+ return finish();
131
+ }
132
+ export async function collectDnsInheritanceReview(domain, exact, recursive, options = {}, injected) {
133
+ requireDmarcDomain(domain);
134
+ options.signal?.throwIfAborted();
135
+ const timeout = AbortSignal.timeout(REVIEW_TIMEOUT_MS);
136
+ const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
137
+ const resolver = injected ? null : new dns.Resolver({ timeout: QUERY_TIMEOUT_MS, tries: 1 });
138
+ const cancel = () => resolver?.cancel();
139
+ signal.addEventListener('abort', cancel, { once: true });
140
+ const dependencies = injected ?? {
141
+ resolveTxt: (name) => resolver.resolveTxt(name), resolveNs: (name) => resolver.resolveNs(name),
142
+ resolve4: (name) => resolver.resolve4(name), resolve6: (name) => resolver.resolve6(name),
143
+ exchange: defaultTcpExchange, now: () => new Date(),
144
+ };
145
+ let questions = 0;
146
+ const cache = new Map([[`_dmarc.${domain}`, Promise.resolve(exact)]]);
147
+ const query = async (owner) => {
148
+ signal.throwIfAborted();
149
+ if (owner.length > 253)
150
+ return { records: [], error: 'The DNS question exceeds the name limit.' };
151
+ questions += 1;
152
+ try {
153
+ return { records: await dependencies.resolveTxt(owner), error: null, observedAt: dependencies.now().toISOString() };
154
+ }
155
+ catch (error) {
156
+ options.signal?.throwIfAborted();
157
+ return { records: [], error: MISSING.has(String(error?.code)) ? null : 'DNS observation unavailable.', observedAt: dependencies.now().toISOString() };
158
+ }
159
+ };
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
+ ];
194
+ try {
195
+ const [policy, parent, reporting] = await Promise.allSettled(operations);
196
+ options.signal?.throwIfAborted();
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 };
202
+ }
203
+ finally {
204
+ signal.removeEventListener('abort', cancel);
205
+ cancel();
206
+ }
207
+ }
@@ -1,15 +1,18 @@
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';
6
6
  import * as net from 'node:net';
7
7
  import { classifyMxRecords } from "./dns-mx.mjs";
8
+ import { normalizeResolverMxRecord } from "../packages/evidence/domain-control-runtime.mjs";
8
9
  import { collectDnsDelegationHealth, skippedDnsDelegationHealth, } from "./dns-delegation-health.mjs";
9
10
  import { createObservation } from "../packages/evidence/observation.mjs";
10
11
  import { MAX_LOOKUP_DNS_RECORDS_PER_TYPE, MAX_LOOKUP_REVERSE_DNS_PTR_RECORDS, } from "./lookup-network-evidence-bounds.mjs";
11
12
  import { isPrivateAddress } from "./safe-fetch.mjs";
12
13
  import { resolveServiceBindingRecords } from "./service-binding-dns.mjs";
14
+ import { canonicalRegistrableDomain } from "../packages/analysis/registrable-domain.mjs";
15
+ import { DNS_QUERY_OUTCOMES, dnsQueryFailureOutcome } from "../packages/evidence/dns-query-outcome.mjs";
13
16
  const DNS_TIMEOUT_MS = 5000;
14
17
  const MAX_RECORDS_PER_TYPE = MAX_LOOKUP_DNS_RECORDS_PER_TYPE;
15
18
  const MAX_PTR_RECORDS = MAX_LOOKUP_REVERSE_DNS_PTR_RECORDS;
@@ -22,7 +25,6 @@ const MAX_SERVICE_ALPN_IDS = 16;
22
25
  const MAX_SERVICE_ADDRESS_HINTS = 8;
23
26
  const MAX_CAA_TREE_QUERIES = 8;
24
27
  const DNS_CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f]/u;
25
- const MISSING_CODES = new Set(['ENODATA', 'ENOTFOUND', 'ENONAME']);
26
28
  function skippedDnsIntelligence(detail = 'DNS intelligence is disabled by deployment policy.', { includeExtendedContext = false, includeInheritedCaa = false, } = {}) {
27
29
  const skipped = { status: 'skipped', error: null, truncated: false, discarded: 0 };
28
30
  const recordTypes = ['a', 'aaaa', 'cname', 'ns', 'mx', 'spf', 'dmarc', 'caa'];
@@ -123,7 +125,7 @@ function boundedUnique(values, limit = MAX_RECORDS_PER_TYPE) {
123
125
  }
124
126
  function normalizeAddresses(records, family) {
125
127
  const values = [];
126
- let discarded = 0;
128
+ let discarded = Array.isArray(records) ? 0 : 1;
127
129
  for (const record of Array.isArray(records) ? records : []) {
128
130
  const value = typeof record === 'string' ? record : record && typeof record === 'object' ? record.address : null;
129
131
  if (typeof value !== 'string' || net.isIP(value) !== family)
@@ -135,7 +137,7 @@ function normalizeAddresses(records, family) {
135
137
  }
136
138
  function normalizeHostnames(records) {
137
139
  const values = [];
138
- let discarded = 0;
140
+ let discarded = Array.isArray(records) ? 0 : 1;
139
141
  for (const record of Array.isArray(records) ? records : []) {
140
142
  const value = normalizeHostname(record);
141
143
  if (value)
@@ -147,19 +149,15 @@ function normalizeHostnames(records) {
147
149
  }
148
150
  function normalizeMx(records) {
149
151
  const byKey = new Map();
150
- let discarded = 0;
152
+ let discarded = Array.isArray(records) ? 0 : 1;
151
153
  for (const record of Array.isArray(records) ? records : []) {
152
- if (!record || typeof record !== 'object') {
153
- discarded += 1;
154
- continue;
155
- }
156
- const value = record;
157
- const exchange = value.exchange === '' || value.exchange === '.' ? '' : normalizeHostname(value.exchange);
158
- const priority = Number(value.priority);
159
- if (exchange === null || !Number.isInteger(priority) || priority < 0 || priority > 65535) {
154
+ const value = normalizeResolverMxRecord(record);
155
+ if (!value) {
160
156
  discarded += 1;
161
157
  continue;
162
158
  }
159
+ const { priority } = value;
160
+ const exchange = value.exchange === '.' ? '' : value.exchange;
163
161
  byKey.set(`${priority}:${exchange}`, { priority, exchange });
164
162
  }
165
163
  const values = [...byKey.values()].sort((a, b) => a.priority - b.priority || a.exchange.localeCompare(b.exchange));
@@ -167,7 +165,7 @@ function normalizeMx(records) {
167
165
  }
168
166
  function normalizeTxtPolicies(records, prefix) {
169
167
  const values = [];
170
- let discarded = 0;
168
+ let discarded = Array.isArray(records) ? 0 : 1;
171
169
  for (const chunks of Array.isArray(records) ? records : []) {
172
170
  if (!Array.isArray(chunks) || chunks.some((chunk) => typeof chunk !== 'string')) {
173
171
  discarded += 1;
@@ -185,7 +183,7 @@ function normalizeTxtPolicies(records, prefix) {
185
183
  }
186
184
  function normalizeCaa(records) {
187
185
  const byKey = new Map();
188
- let discarded = 0;
186
+ let discarded = Array.isArray(records) ? 0 : 1;
189
187
  for (const record of Array.isArray(records) ? records : []) {
190
188
  const entry = record && typeof record === 'object' ? record : {};
191
189
  const critical = Number(entry.critical);
@@ -388,27 +386,35 @@ function normalizePtr(records) {
388
386
  }
389
387
  function withTimeout(factory, timeoutMs) {
390
388
  return new Promise((resolve, reject) => {
391
- 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);
392
390
  Promise.resolve().then(factory).then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
393
391
  });
394
392
  }
395
393
  async function query(factory, normalize, timeoutMs) {
396
394
  try {
397
395
  const normalized = normalize(await withTimeout(factory, timeoutMs));
396
+ const unusable = !normalized.records.length && (normalized.truncated || normalized.discarded > 0);
398
397
  return {
399
- status: normalized.records.length ? 'success' : 'not_found',
398
+ status: unusable ? 'error' : normalized.records.length ? 'success' : 'not_found',
399
+ detail: unusable ? 'invalid_response' : normalized.records.length ? 'records' : 'empty_answer',
400
400
  records: normalized.records,
401
- error: null,
401
+ error: unusable ? 'The returned record family could not be retained completely; absence is unknown.' : null,
402
402
  truncated: normalized.truncated,
403
403
  discarded: normalized.discarded || 0,
404
404
  };
405
405
  }
406
406
  catch (error) {
407
- if (error && typeof error === 'object' && MISSING_CODES.has(String(error.code)))
408
- return { status: 'not_found', records: [], error: null, truncated: false, discarded: 0 };
409
- 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 };
410
410
  }
411
411
  }
412
+ function completeQuery(result) {
413
+ return result.status !== 'error' && !result.truncated && result.discarded === 0;
414
+ }
415
+ function observedPresence(result) {
416
+ return result.records.length > 0 ? true : completeQuery(result) ? false : null;
417
+ }
412
418
  async function collectEffectiveCaaPolicy(domain, options = {}) {
413
419
  const normalizedDomain = normalizeHostname(domain);
414
420
  const queryLimit = Math.max(1, Math.min(MAX_CAA_TREE_QUERIES, Math.floor(options.queryLimit || MAX_CAA_TREE_QUERIES)));
@@ -459,7 +465,7 @@ async function collectEffectiveCaaPolicy(domain, options = {}) {
459
465
  truncated: result.truncated,
460
466
  discarded: result.discarded,
461
467
  });
462
- if (result.status === 'error') {
468
+ if (result.status === 'error' && !result.truncated && result.discarded === 0) {
463
469
  return {
464
470
  ...createObservation({
465
471
  status: index === 0 ? 'error' : 'partial',
@@ -576,6 +582,10 @@ async function collectEffectiveCaaPolicy(domain, options = {}) {
576
582
  };
577
583
  }
578
584
  async function collectDnsIntelligence(domain, options = {}) {
585
+ const registrationDomain = options.registrationDomain ?? domain;
586
+ if (options.registrationDomain !== undefined && canonicalRegistrableDomain(domain) !== registrationDomain) {
587
+ throw new TypeError('DNS registration context must match the hostname being observed.');
588
+ }
579
589
  const resolvers = options.resolvers || {};
580
590
  const includeExtendedContext = options.includeExtendedContext === true;
581
591
  const includeInheritedCaa = options.includeInheritedCaa === true;
@@ -606,8 +616,11 @@ async function collectDnsIntelligence(domain, options = {}) {
606
616
  const httpsPromise = includeExtendedContext
607
617
  ? query(invoke('resolveHttps', (value) => resolveServiceBindingRecords(value, 'HTTPS', { timeoutMs })), normalizeServiceBindings, timeoutMs)
608
618
  : Promise.resolve(null);
619
+ const registrationNsPromise = includeExtendedContext && registrationDomain !== domain
620
+ ? query(invoke('resolveNs', dns.resolveNs, registrationDomain), normalizeHostnames, timeoutMs)
621
+ : nsPromise;
609
622
  const delegationPromise = includeExtendedContext
610
- ? nsPromise.then((parentNameservers) => collectDnsDelegationHealth(domain, parentNameservers, {
623
+ ? registrationNsPromise.then((parentNameservers) => collectDnsDelegationHealth(registrationDomain, parentNameservers, {
611
624
  registryEvidence: options.registryEvidence,
612
625
  resolve4: (resolvers.resolve4 || dns.resolve4),
613
626
  resolve6: (resolvers.resolve6 || dns.resolve6),
@@ -645,7 +658,8 @@ async function collectDnsIntelligence(domain, options = {}) {
645
658
  || discardedCount > 0
646
659
  || (caaPolicy !== null && caaPolicy.complete !== true)
647
660
  || (delegation !== null && delegation.status !== 'success');
648
- const classifiedMx = mx.status === 'error' ? null : classifyMxRecords(mx.records);
661
+ const classifiedMx = classifyMxRecords(mx.records);
662
+ const mxComplete = completeQuery(mx);
649
663
  return {
650
664
  ...createObservation({
651
665
  status: errorCount === values.length ? 'error' : incomplete ? 'partial' : 'success',
@@ -670,6 +684,7 @@ async function collectDnsIntelligence(domain, options = {}) {
670
684
  diagnostics: {
671
685
  ...Object.fromEntries(Object.entries(queries).map(([name, item]) => [name, {
672
686
  status: item.status,
687
+ detail: item.detail,
673
688
  error: item.error,
674
689
  truncated: item.truncated,
675
690
  discarded: item.discarded,
@@ -703,11 +718,11 @@ async function collectDnsIntelligence(domain, options = {}) {
703
718
  ...(soa ? { soa: soa.records } : {}),
704
719
  ...(https ? { https: https.records } : {}),
705
720
  },
706
- hasMx: classifiedMx ? classifiedMx.hasMx : null,
707
- hasNullMx: classifiedMx ? classifiedMx.hasNullMx : null,
708
- mxHosts: classifiedMx ? classifiedMx.mxHosts : [],
709
- hasSpf: spf.status === 'error' ? null : spf.records.length > 0,
710
- hasDmarc: dmarc.status === 'error' ? null : dmarc.records.length > 0,
721
+ hasMx: classifiedMx.hasMx ? true : mxComplete ? false : null,
722
+ hasNullMx: mxComplete ? classifiedMx.hasNullMx : null,
723
+ mxHosts: classifiedMx.mxHosts,
724
+ hasSpf: observedPresence(spf),
725
+ hasDmarc: observedPresence(dmarc),
711
726
  delegation,
712
727
  ...(caaPolicy ? { caaPolicy } : {}),
713
728
  };
package/lib/dns-mx.mjs CHANGED
@@ -14,7 +14,7 @@ function classifyMxRecords(records) {
14
14
  // of configuration. Not currently used for scoring (both cases already
15
15
  // correctly score as hasMx: false); exposed for callers that want to tell
16
16
  // the two apart.
17
- const hasNullMx = records.length > 0 && realRecords.length === 0;
17
+ const hasNullMx = records.length === 1 && records[0]?.priority === 0 && realRecords.length === 0;
18
18
  return {
19
19
  hasMx: realRecords.length > 0,
20
20
  hasNullMx,
@@ -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
+ }
@@ -130,6 +130,9 @@ function signatureTimeState(value, observedAt) {
130
130
  }
131
131
  function validateDnssecEvidence(input) {
132
132
  const normalizedOwner = ownerName(input.ownerName);
133
+ const missingDs = input.dsRecords === undefined;
134
+ const malformedContainers = [input.dsRecords, input.dnskeyRecords, input.rrSigRecords]
135
+ .filter((value) => value !== undefined && !Array.isArray(value)).length;
133
136
  const rawDs = Array.isArray(input.dsRecords) ? input.dsRecords : [];
134
137
  const rawDnskeys = Array.isArray(input.dnskeyRecords) ? input.dnskeyRecords : [];
135
138
  const rawRrsigs = Array.isArray(input.rrSigRecords) ? input.rrSigRecords : [];
@@ -138,7 +141,8 @@ function validateDnssecEvidence(input) {
138
141
  const rrsigs = rawRrsigs.slice(0, MAX_DNSSEC_RECORDS).map(normalizeRrsig).filter((item) => item !== null);
139
142
  const rejectedCount = Math.min(rawDs.length, MAX_DNSSEC_RECORDS) - dsRecords.length
140
143
  + Math.min(rawDnskeys.length, MAX_DNSSEC_RECORDS) - dnskeys.length
141
- + Math.min(rawRrsigs.length, MAX_DNSSEC_RECORDS) - rrsigs.length;
144
+ + Math.min(rawRrsigs.length, MAX_DNSSEC_RECORDS) - rrsigs.length
145
+ + malformedContainers;
142
146
  const truncated = rawDs.length > MAX_DNSSEC_RECORDS
143
147
  || rawDnskeys.length > MAX_DNSSEC_RECORDS
144
148
  || rawRrsigs.length > MAX_DNSSEC_RECORDS;
@@ -166,7 +170,11 @@ function validateDnssecEvidence(input) {
166
170
  limitations: Object.freeze(limitations),
167
171
  });
168
172
  }
169
- const incompleteInput = rejectedCount > 0 || truncated;
173
+ const incompleteInput = missingDs || rejectedCount > 0 || truncated;
174
+ if (missingDs)
175
+ findings.push('No DS record collection was supplied. Omitted input is not a verified empty collection.');
176
+ if (malformedContainers)
177
+ findings.push(`${malformedContainers} record collection container(s) had an invalid type; their contents are unknown.`);
170
178
  if (input.delegationSigned === false && dsRecords.length === 0 && !incompleteInput) {
171
179
  findings.push('The supplied delegation state was unsigned and no DS records were supplied.');
172
180
  return Object.freeze({
@@ -217,7 +225,7 @@ function validateDnssecEvidence(input) {
217
225
  findings.push('The retained subset contains no usable DS record, but omitted or rejected records prevent a definitive unsigned conclusion.');
218
226
  }
219
227
  if (rejectedCount > 0)
220
- findings.push(`${rejectedCount} malformed record(s) were rejected.`);
228
+ findings.push(`${rejectedCount} malformed record(s) or collection container(s) were rejected.`);
221
229
  if (truncated)
222
230
  findings.push('The supplied record set exceeded the review bound and was truncated.');
223
231
  const completeLocalRelationship = dsRecords.length > 0
@@ -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],
@@ -1,11 +1,17 @@
1
1
  import { exactKeys, requireBoundedString, requireDomainName, requireIsoTimestamp, requireRecord, } from "./bounded-contract-normalizers.mjs";
2
2
  import { DOMAIN_ASSURANCE_INPUT_SCHEMA, buildDomainAssurance, } from "./domain-assurance.mjs";
3
- import { DOMAIN_CHANGE_INPUT_SCHEMA, reviewDomainChange, } from "./domain-change-review.mjs";
3
+ import { DOMAIN_CHANGE_INPUT_SCHEMA, DOMAIN_CHANGE_REVIEW_VERSION, reviewDomainChange, } from "./domain-change-review.mjs";
4
4
  import { SORTED_JSON_V2, sha256ArtifactDigestV2 } from "../packages/evidence/artifact-integrity.mjs";
5
5
  export const DOMAIN_CHANGE_PACKET_INPUT_SCHEMA = 'whoisleuth.domain-change-packet.input';
6
6
  export const DOMAIN_CHANGE_PACKET_SCHEMA = 'whoisleuth.domain-change-packet';
7
7
  export const DOMAIN_CHANGE_PACKET_INPUT_VERSION = 1;
8
- export const DOMAIN_CHANGE_PACKET_VERSION = 2;
8
+ export const DOMAIN_CHANGE_PACKET_VERSION = 4;
9
+ export const DOMAIN_CHANGE_PACKET_REVIEW_VERSIONS = Object.freeze({
10
+ 2: 1,
11
+ 3: 2,
12
+ [DOMAIN_CHANGE_PACKET_VERSION]: DOMAIN_CHANGE_REVIEW_VERSION,
13
+ });
14
+ export const SUPPORTED_DOMAIN_CHANGE_PACKET_VERSIONS = Object.freeze(Object.keys(DOMAIN_CHANGE_PACKET_REVIEW_VERSIONS).map(Number));
9
15
  export const MAX_DOMAIN_CHANGE_PACKET_INPUT_BYTES = 6 * 1024 * 1024;
10
16
  const ROOT_KEYS = new Set([
11
17
  'schema',
@@ -38,16 +44,10 @@ function changeSummary(before, after) {
38
44
  const beforeRows = rows(before);
39
45
  const afterRows = rows(after);
40
46
  const keys = [...new Set([...beforeRows.keys(), ...afterRows.keys()])].sort();
41
- const authorityCollectionComplete = (review) => {
42
- const representative = review.authoritativeRecordMatrix[0];
43
- return Boolean(representative
44
- && representative.observations.length >= 2
45
- && representative.observations.every((item) => item.state === 'observed'));
46
- };
47
- const rowEvidence = (review, row) => {
47
+ const rowEvidence = (row) => {
48
48
  if (!row)
49
49
  return Object.freeze({
50
- state: authorityCollectionComplete(review) ? 'complete' : 'unavailable',
50
+ state: 'unavailable',
51
51
  values: Object.freeze([]),
52
52
  });
53
53
  if (row.observations.some((item) => item.state !== 'observed')) {
@@ -63,13 +63,15 @@ function changeSummary(before, after) {
63
63
  });
64
64
  };
65
65
  const changed = [];
66
+ const reasons = keys.length > 500 ? ['The comparison exceeds 500 owner and record-type pairs.'] : [];
66
67
  for (const key of keys.slice(0, 500)) {
67
68
  const left = beforeRows.get(key);
68
69
  const right = afterRows.get(key);
69
70
  const [owner, type] = key.split('\u0000');
70
- const beforeEvidence = rowEvidence(before, left);
71
- const afterEvidence = rowEvidence(after, right);
71
+ const beforeEvidence = rowEvidence(left);
72
+ const afterEvidence = rowEvidence(right);
72
73
  if (beforeEvidence.state !== 'complete' || afterEvidence.state !== 'complete') {
74
+ reasons.push(`Comparison evidence is incomplete for ${owner} ${type}.`);
73
75
  continue;
74
76
  }
75
77
  if (JSON.stringify(beforeEvidence.values) === JSON.stringify(afterEvidence.values))
@@ -81,7 +83,7 @@ function changeSummary(before, after) {
81
83
  afterValues: afterEvidence.values,
82
84
  }));
83
85
  }
84
- return Object.freeze(changed);
86
+ return Object.freeze({ changed: Object.freeze(changed), reasons: Object.freeze(reasons) });
85
87
  }
86
88
  export async function buildDomainChangePacket(inputRaw, generatedAtValue = new Date().toISOString()) {
87
89
  const input = record(inputRaw, 'Domain change packet input');
@@ -102,11 +104,13 @@ export async function buildDomainChangePacket(inputRaw, generatedAtValue = new D
102
104
  const preChange = reviewDomainChange(input.preChange, generatedAt);
103
105
  const postChange = reviewDomainChange(input.postChange, generatedAt);
104
106
  const assurance = buildDomainAssurance(input.assurance, generatedAt);
107
+ const comparison = changeSummary(preChange, postChange);
105
108
  const assuranceReasons = assurance.result.review.reasons;
106
109
  const gateReasons = Object.freeze([
107
110
  ...preChange.gate.reasons.map((reason) => `Pre-change evidence: ${reason}`),
108
111
  ...postChange.gate.reasons.map((reason) => `Post-change evidence: ${reason}`),
109
112
  ...assuranceReasons.map((reason) => `Change plan: ${reason}`),
113
+ ...comparison.reasons,
110
114
  ].slice(0, 100));
111
115
  const unsigned = Object.freeze({
112
116
  schema: DOMAIN_CHANGE_PACKET_SCHEMA,
@@ -117,7 +121,7 @@ export async function buildDomainChangePacket(inputRaw, generatedAtValue = new D
117
121
  state: gateReasons.length ? 'review' : 'ready',
118
122
  gate: Object.freeze({ pass: gateReasons.length === 0, reasons: gateReasons }),
119
123
  summary: Object.freeze({
120
- changedAuthoritativeRecordSets: changeSummary(preChange, postChange),
124
+ changedAuthoritativeRecordSets: comparison.changed,
121
125
  preChangeState: preChange.state,
122
126
  postChangeState: postChange.state,
123
127
  assuranceState: assurance.result.review.state,