@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
@@ -1,21 +1,20 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { domainToASCII } from 'node:url';
3
3
  import { canonicalArtifactJsonFor, } from "../packages/evidence/artifact-integrity.mjs";
4
- import { DOMAIN_CONTROL_MANIFEST_CURRENT_CANONICALIZATION, DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA, DOMAIN_CONTROL_MANIFEST_VERSION, DOMAIN_CONTROL_MANIFEST_SCHEMA, DOMAIN_CONTROL_TEXT_INPUT_BOUND_FACTOR, MAX_DOMAIN_CONTROL_CAA_PRESENTATION_LENGTH, MAX_CANONICAL_DOMAIN_CONTROL_RECORDS, MAX_DOMAIN_CONTROL_DS_PRESENTATION_LENGTH, MAX_DOMAIN_CONTROL_INPUT_RECORDS, MAX_DOMAIN_CONTROL_MANIFEST_ENTRIES, MAX_DOMAIN_CONTROL_TIMESTAMP_LENGTH, } from "../packages/contracts/domain-control-manifest.mjs";
5
- import { DOMAIN_CONTROL_REVIEW_FIELDS, DOMAIN_CONTROL_REVIEW_COMPARISON_KEYS, DOMAIN_CONTROL_REVIEW_COUNT_KEYS, DOMAIN_CONTROL_REVIEW_DOMAIN_KEYS, DOMAIN_CONTROL_REVIEW_INPUT_KEYS, DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA, DOMAIN_CONTROL_REVIEW_LIMITATIONS, DOMAIN_CONTROL_REVIEW_MANIFEST_SUMMARY_KEYS, DOMAIN_CONTROL_REVIEW_OBSERVATION_FIELD_KEYS, DOMAIN_CONTROL_REVIEW_OBSERVATION_KEYS, DOMAIN_CONTROL_REVIEW_ROOT_KEYS, DOMAIN_CONTROL_REVIEW_SCHEMA, DOMAIN_CONTROL_REVIEW_VERSION, MAX_DOMAIN_CONTROL_REVIEW_OBSERVATIONS, MAX_DOMAIN_CONTROL_REVIEW_SOURCE_LENGTH, MAX_DOMAIN_CONTROL_REVIEW_TEXT_LENGTH, } from "../packages/contracts/domain-control-review.mjs";
6
- import { assertDomainControlManifestByteBudget, buildUnsignedDomainControlManifest, canonicalDomainControlRecordList, normalizeDomainControlManifestDocument, } from "../packages/evidence/domain-control-runtime.mjs";
4
+ import { DOMAIN_CONTROL_MANIFEST_CURRENT_CANONICALIZATION, DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA, DOMAIN_CONTROL_MANIFEST_VERSION, DOMAIN_CONTROL_MANIFEST_SCHEMA, DOMAIN_CONTROL_TEXT_INPUT_BOUND_FACTOR, MAX_DOMAIN_CONTROL_CAA_PRESENTATION_LENGTH, MAX_CANONICAL_DOMAIN_CONTROL_RECORDS, MAX_CURRENT_DOMAIN_CONTROL_RECORDS, DOMAIN_CONTROL_RECORD_LIST_FIELDS, DOMAIN_CONTROL_RECORD_MODE_OPTIONS, PUBLIC_DOMAIN_CONTROL_MANIFEST_VERSION, MAX_DOMAIN_CONTROL_DS_PRESENTATION_LENGTH, MAX_DOMAIN_CONTROL_INPUT_RECORDS, MAX_DOMAIN_CONTROL_MANIFEST_ENTRIES, MAX_DOMAIN_CONTROL_TIMESTAMP_LENGTH, } from "../packages/contracts/domain-control-manifest.mjs";
5
+ import { DOMAIN_CONTROL_REVIEW_FIELDS, DOMAIN_CONTROL_REVIEW_COMPARISON_KEYS, DOMAIN_CONTROL_REVIEW_COUNT_KEYS, DOMAIN_CONTROL_REVIEW_DOMAIN_KEYS, DOMAIN_CONTROL_REVIEW_INPUT_KEYS, DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA, DOMAIN_CONTROL_REVIEW_LIMITATIONS, domainControlReviewCountKeys, domainControlReviewLimitations, DOMAIN_CONTROL_REVIEW_MANIFEST_SUMMARY_KEYS, DOMAIN_CONTROL_REVIEW_OBSERVATION_FIELD_KEYS, DOMAIN_CONTROL_REVIEW_OBSERVATION_KEYS, DOMAIN_CONTROL_REVIEW_ROOT_KEYS, DOMAIN_CONTROL_REVIEW_SCHEMA, DOMAIN_CONTROL_REVIEW_VERSION, PUBLIC_DOMAIN_CONTROL_REVIEW_VERSION, SUPPORTED_DOMAIN_CONTROL_REVIEW_VERSIONS, PUBLIC_DOMAIN_CONTROL_REVIEW_COMPARISON_KEYS, MAX_DOMAIN_CONTROL_REVIEW_OBSERVATIONS, MAX_DOMAIN_CONTROL_REVIEW_SOURCE_LENGTH, MAX_DOMAIN_CONTROL_REVIEW_TEXT_LENGTH, } from "../packages/contracts/domain-control-review.mjs";
6
+ import { assertDomainControlManifestByteBudget, buildUnsignedDomainControlManifest, canonicalDomainControlRecordList, domainControlRecordMode, domainControlEvidenceAdmission, normalizeDomainControlManifestDocument, } from "../packages/evidence/domain-control-runtime.mjs";
7
7
  import { exactKeys } from "./bounded-contract-normalizers.mjs";
8
8
  import { normalizeExplicitIsoTimestamp, normalizeLegacyIsoTimestamp } from "../packages/evidence/observation.mjs";
9
9
  export { DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA, DOMAIN_CONTROL_MANIFEST_SCHEMA, DOMAIN_CONTROL_MANIFEST_VERSION, DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA, DOMAIN_CONTROL_REVIEW_SCHEMA, DOMAIN_CONTROL_REVIEW_VERSION, };
10
10
  export const MAX_DOMAIN_CONTROL_ENTRIES = MAX_DOMAIN_CONTROL_MANIFEST_ENTRIES;
11
- export const MAX_DOMAIN_CONTROL_RECORDS = MAX_CANONICAL_DOMAIN_CONTROL_RECORDS;
11
+ export const MAX_DOMAIN_CONTROL_RECORDS = MAX_CURRENT_DOMAIN_CONTROL_RECORDS;
12
12
  const REVIEW_INPUT_KEYS = new Set(DOMAIN_CONTROL_REVIEW_INPUT_KEYS);
13
13
  const OBSERVATION_KEYS = new Set(DOMAIN_CONTROL_REVIEW_OBSERVATION_KEYS);
14
14
  const OBSERVATION_FIELDS = new Set(DOMAIN_CONTROL_REVIEW_FIELDS);
15
15
  const OBSERVATION_FIELD_KEYS = new Set(DOMAIN_CONTROL_REVIEW_OBSERVATION_FIELD_KEYS);
16
16
  const REVIEW_ROOT_KEYS = new Set(DOMAIN_CONTROL_REVIEW_ROOT_KEYS);
17
17
  const REVIEW_MANIFEST_KEYS = new Set(DOMAIN_CONTROL_REVIEW_MANIFEST_SUMMARY_KEYS);
18
- const REVIEW_COUNT_KEYS = new Set(DOMAIN_CONTROL_REVIEW_COUNT_KEYS);
19
18
  const REVIEW_DOMAIN_KEYS = new Set(DOMAIN_CONTROL_REVIEW_DOMAIN_KEYS);
20
19
  const REVIEW_COMPARISON_KEYS = new Set(DOMAIN_CONTROL_REVIEW_COMPARISON_KEYS);
21
20
  function record(value) {
@@ -186,25 +185,25 @@ function boundedDataArray(value, maximum) {
186
185
  return null;
187
186
  }
188
187
  }
189
- function fieldValues(field, value) {
188
+ function fieldValues(field, value, maximum) {
190
189
  if (field === 'nameservers') {
191
190
  const normalized = value.map(domain);
192
191
  if (normalized.some((item) => item === null))
193
192
  return null;
194
193
  const values = hostnameList(normalized);
195
- return values.length <= MAX_DOMAIN_CONTROL_RECORDS ? values : null;
194
+ return values.length <= maximum ? values : null;
196
195
  }
197
196
  if (field === 'mx' || field === 'caa' || field === 'ds') {
198
197
  const normalized = value.flatMap((item) => canonicalDomainControlRecordList([item], field));
199
198
  const values = [...new Set(normalized)].sort();
200
- return normalized.length === value.length && values.length <= MAX_DOMAIN_CONTROL_RECORDS ? values : null;
199
+ return normalized.length === value.length && values.length <= maximum ? values : null;
201
200
  }
202
201
  if (field === 'tlsSpkiSha256') {
203
202
  const normalized = value.map(spkiFingerprint);
204
203
  if (normalized.some((item) => item === null))
205
204
  return null;
206
205
  const values = [...new Set(normalized)].sort();
207
- return values.length <= MAX_DOMAIN_CONTROL_RECORDS ? values : null;
206
+ return values.length <= maximum ? values : null;
208
207
  }
209
208
  if (field === 'registrarLock') {
210
209
  return value.every((item) => item === 'required' || item === 'not_required')
@@ -215,9 +214,9 @@ function fieldValues(field, value) {
215
214
  if (normalized.some((item) => item === null))
216
215
  return null;
217
216
  const values = textList(normalized, MAX_DOMAIN_CONTROL_REVIEW_TEXT_LENGTH);
218
- return values.length <= MAX_DOMAIN_CONTROL_RECORDS ? values : null;
217
+ return values.length <= maximum ? values : null;
219
218
  }
220
- function normalizeObservation(value) {
219
+ function normalizeObservation(value, legacy) {
221
220
  const source = record(value);
222
221
  const normalizedDomain = domain(source?.domain);
223
222
  const rawFields = record(source?.fields);
@@ -242,12 +241,12 @@ function normalizeObservation(value) {
242
241
  const values = boundedDataArray(raw.values, MAX_DOMAIN_CONTROL_INPUT_RECORDS);
243
242
  if (!sourceLabel || !values)
244
243
  return null;
245
- const normalizedValues = fieldValues(field, values);
246
- const observedAt = raw.observedAt === null ? null : timestamp(raw.observedAt, true);
244
+ const normalizedValues = fieldValues(field, values, legacy ? MAX_CANONICAL_DOMAIN_CONTROL_RECORDS : MAX_DOMAIN_CONTROL_RECORDS);
245
+ const observedAt = raw.observedAt === null ? null : timestamp(raw.observedAt, legacy);
247
246
  if (!normalizedValues || (raw.observedAt !== null && !observedAt))
248
247
  return null;
249
248
  fields[field] = Object.freeze({
250
- state,
249
+ state: field === 'registrarLock' && state === 'observed' && normalizedValues.length !== 1 ? 'partial' : state,
251
250
  values: Object.freeze(normalizedValues),
252
251
  source: sourceLabel,
253
252
  observedAt,
@@ -264,18 +263,22 @@ function desiredValues(entry, field) {
264
263
  return entry.registrarLock ? [entry.registrarLock] : [];
265
264
  return [...entry[field]];
266
265
  }
267
- function compareField(entry, observation, field) {
266
+ function compareField(entry, observation, field, now) {
268
267
  const desired = desiredValues(entry, field);
269
- if (!desired.length)
268
+ const expectation = DOMAIN_CONTROL_RECORD_LIST_FIELDS.includes(field)
269
+ ? domainControlRecordMode(entry, field) : desired.length ? 'expect_records' : 'unconfigured';
270
+ if (expectation === 'unconfigured')
270
271
  return Object.freeze({
271
272
  field, state: 'not_configured', desired: Object.freeze([]), observed: Object.freeze([]), source: null, observedAt: null,
272
273
  explanation: 'No desired value was configured for this field.',
274
+ expectation,
273
275
  });
274
276
  const evidence = observation?.fields[field];
275
277
  if (!evidence)
276
278
  return Object.freeze({
277
279
  field, state: 'unavailable', desired: Object.freeze(desired), observed: Object.freeze([]), source: null, observedAt: null,
278
280
  explanation: 'No separately attributed observation was supplied for this field.',
281
+ expectation,
279
282
  });
280
283
  if (evidence.state !== 'observed')
281
284
  return Object.freeze({
@@ -288,7 +291,13 @@ function compareField(entry, observation, field) {
288
291
  explanation: evidence.state === 'partial'
289
292
  ? 'The supplied observation was partial, so a difference is inconclusive.'
290
293
  : `The supplied source reported this field as ${evidence.state}.`,
294
+ expectation,
291
295
  });
296
+ const limitation = domainControlEvidenceAdmission(evidence.observedAt, now);
297
+ if (limitation)
298
+ return Object.freeze({ field, state: 'partial', desired: Object.freeze(desired), observed: evidence.values, source: evidence.source, observedAt: evidence.observedAt, explanation: limitation, expectation });
299
+ if (expectation === 'observe_only')
300
+ return Object.freeze({ field, state: 'observed', desired: Object.freeze([]), observed: evidence.values, source: evidence.source, observedAt: evidence.observedAt, explanation: 'Observation only: no required record set is compared.', expectation });
292
301
  const aligned = desired.length === evidence.values.length && desired.every((item, index) => item === evidence.values[index]);
293
302
  return Object.freeze({
294
303
  field,
@@ -297,9 +306,12 @@ function compareField(entry, observation, field) {
297
306
  observed: evidence.values,
298
307
  source: evidence.source,
299
308
  observedAt: evidence.observedAt,
300
- explanation: aligned
301
- ? 'The complete supplied observation matches the desired value.'
302
- : 'The complete supplied observation differs from the desired value.',
309
+ explanation: expectation === 'expect_none'
310
+ ? aligned ? 'The complete source observation contains no records, as expected.' : 'The complete source observation contains records where none are expected.'
311
+ : aligned
312
+ ? 'The complete supplied observation matches the desired value.'
313
+ : 'The complete supplied observation differs from the desired value.',
314
+ expectation,
303
315
  });
304
316
  }
305
317
  function renewalComparison(entry, now) {
@@ -307,16 +319,44 @@ function renewalComparison(entry, now) {
307
319
  return Object.freeze({
308
320
  field: 'renewalReviewAt', state: 'not_configured', desired: Object.freeze([]), observed: Object.freeze([]), source: null, observedAt: null,
309
321
  explanation: 'No renewal review date was configured.',
322
+ expectation: 'unconfigured',
310
323
  });
311
324
  const due = Date.parse(entry.renewalReviewAt) <= Date.parse(now);
312
325
  return Object.freeze({
313
- field: 'renewalReviewAt', state: due ? 'due' : 'aligned', desired: Object.freeze([entry.renewalReviewAt]), observed: Object.freeze([]), source: null, observedAt: null,
326
+ field: 'renewalReviewAt', state: due ? 'due' : 'not_due', desired: Object.freeze([entry.renewalReviewAt]), observed: Object.freeze([]), source: null, observedAt: null,
314
327
  explanation: due ? 'The configured renewal review date is due.' : 'The configured renewal review date is still in the future.',
328
+ expectation: 'expect_records',
315
329
  });
316
330
  }
331
+ function aggregateComparisonState(comparisons, version = DOMAIN_CONTROL_REVIEW_VERSION) {
332
+ if (comparisons.some((item) => item.state === 'drift' || (version < 3 && item.state === 'due')))
333
+ return 'drift';
334
+ if (comparisons.some((item) => ['partial', 'unavailable', 'unsupported'].includes(item.state)))
335
+ return 'partial';
336
+ if (comparisons.some((item) => item.state === 'due'))
337
+ return 'due';
338
+ if (version === 1 || comparisons.some((item) => item.state === 'aligned'))
339
+ return 'aligned';
340
+ if (comparisons.some((item) => item.state === 'observed'))
341
+ return 'observed';
342
+ return comparisons.some((item) => item.state === 'not_due') ? 'not_due' : 'not_configured';
343
+ }
344
+ function aggregateDomainState(domains, version = DOMAIN_CONTROL_REVIEW_VERSION) {
345
+ if (domains.some((item) => item.state === 'drift'))
346
+ return 'drift';
347
+ if (domains.some((item) => item.state === 'partial'))
348
+ return 'partial';
349
+ if (domains.some((item) => item.state === 'due'))
350
+ return 'due';
351
+ if (version === 1 || domains.some((item) => item.state === 'aligned'))
352
+ return 'aligned';
353
+ if (domains.some((item) => item.state === 'observed'))
354
+ return 'observed';
355
+ return domains.some((item) => item.state === 'not_due') ? 'not_due' : 'not_configured';
356
+ }
317
357
  export function reviewDomainControlManifest(input, generatedAtValue = new Date().toISOString()) {
318
358
  const source = record(input);
319
- if (!source || source.schema !== DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA || source.version !== DOMAIN_CONTROL_REVIEW_VERSION) {
359
+ if (!source || source.schema !== DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA || !SUPPORTED_DOMAIN_CONTROL_REVIEW_VERSIONS.includes(source.version)) {
320
360
  throw new TypeError(`Domain control review input must use ${DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA} version ${DOMAIN_CONTROL_REVIEW_VERSION}.`);
321
361
  }
322
362
  exactKeys(source, REVIEW_INPUT_KEYS, 'Domain control review input');
@@ -324,11 +364,14 @@ export function reviewDomainControlManifest(input, generatedAtValue = new Date()
324
364
  if (!generatedAt)
325
365
  throw new TypeError('Domain control review time must be valid and include an explicit timezone.');
326
366
  const manifest = verifyDomainControlManifest(source.manifest);
367
+ const legacy = source.version === PUBLIC_DOMAIN_CONTROL_REVIEW_VERSION;
368
+ if (legacy && manifest.version !== PUBLIC_DOMAIN_CONTROL_MANIFEST_VERSION)
369
+ throw new TypeError('Public review input requires its published manifest contract; use the current review input version for explicit expectations.');
327
370
  const rawObservations = boundedDataArray(source.observations, MAX_DOMAIN_CONTROL_REVIEW_OBSERVATIONS);
328
371
  if (!rawObservations) {
329
372
  throw new TypeError('Domain control observations must be a bounded array.');
330
373
  }
331
- const observations = rawObservations.map(normalizeObservation);
374
+ const observations = rawObservations.map((value) => normalizeObservation(value, legacy));
332
375
  if (observations.some((item) => item === null))
333
376
  throw new TypeError('Domain control review contains an invalid observation.');
334
377
  const normalizedObservations = observations;
@@ -339,23 +382,19 @@ export function reviewDomainControlManifest(input, generatedAtValue = new Date()
339
382
  const fields = DOMAIN_CONTROL_REVIEW_FIELDS;
340
383
  const domains = manifest.entries.map((entry) => {
341
384
  const observation = observationMap.get(entry.domain) ?? null;
342
- const comparisons = [...fields.map((field) => compareField(entry, observation, field)), renewalComparison(entry, generatedAt)];
343
- const state = comparisons.some((item) => item.state === 'drift' || item.state === 'due')
344
- ? 'drift'
345
- : comparisons.some((item) => item.state === 'partial' || item.state === 'unavailable' || item.state === 'unsupported')
346
- ? 'partial'
347
- : 'aligned';
385
+ const comparisons = [...fields.map((field) => compareField(entry, observation, field, generatedAt)), renewalComparison(entry, generatedAt)];
386
+ const state = aggregateComparisonState(comparisons);
348
387
  return Object.freeze({ domain: entry.domain, state, comparisons: Object.freeze(comparisons) });
349
388
  });
350
389
  const comparisons = domains.flatMap((item) => item.comparisons);
351
- const counts = Object.fromEntries(['aligned', 'drift', 'partial', 'unavailable', 'unsupported', 'not_configured', 'due']
390
+ const counts = Object.fromEntries(DOMAIN_CONTROL_REVIEW_COUNT_KEYS
352
391
  .map((state) => [state, comparisons.filter((item) => item.state === state).length]));
353
392
  const expired = Date.parse(manifest.expiresAt) <= Date.parse(generatedAt);
354
393
  return Object.freeze({
355
394
  schema: DOMAIN_CONTROL_REVIEW_SCHEMA,
356
395
  version: DOMAIN_CONTROL_REVIEW_VERSION,
357
396
  generatedAt,
358
- state: expired ? 'expired' : domains.some((item) => item.state === 'drift') ? 'drift' : domains.some((item) => item.state === 'partial') ? 'partial' : 'aligned',
397
+ state: expired ? 'expired' : aggregateDomainState(domains),
359
398
  manifest: Object.freeze({
360
399
  generatedAt: manifest.generatedAt,
361
400
  expiresAt: manifest.expiresAt,
@@ -370,9 +409,14 @@ export function reviewDomainControlManifest(input, generatedAtValue = new Date()
370
409
  }
371
410
  export function validateDomainControlReviewDocument(value) {
372
411
  const root = exactReviewRecord(value, REVIEW_ROOT_KEYS, 'Domain control review document');
373
- if (root.schema !== DOMAIN_CONTROL_REVIEW_SCHEMA || root.version !== DOMAIN_CONTROL_REVIEW_VERSION) {
412
+ if (root.schema !== DOMAIN_CONTROL_REVIEW_SCHEMA || !SUPPORTED_DOMAIN_CONTROL_REVIEW_VERSIONS.includes(root.version)) {
374
413
  throw new TypeError(`Domain control review document must use ${DOMAIN_CONTROL_REVIEW_SCHEMA} version ${DOMAIN_CONTROL_REVIEW_VERSION}.`);
375
414
  }
415
+ const legacy = root.version === PUBLIC_DOMAIN_CONTROL_REVIEW_VERSION;
416
+ const countKeys = domainControlReviewCountKeys(root.version);
417
+ const comparisonKeys = legacy ? new Set(PUBLIC_DOMAIN_CONTROL_REVIEW_COMPARISON_KEYS) : REVIEW_COMPARISON_KEYS;
418
+ const recordMaximum = legacy ? MAX_CANONICAL_DOMAIN_CONTROL_RECORDS : MAX_DOMAIN_CONTROL_RECORDS;
419
+ const limitations = domainControlReviewLimitations(root.version);
376
420
  const generatedAt = exactReviewTimestamp(root.generatedAt, 'Domain control review document generatedAt');
377
421
  const manifestInput = exactReviewRecord(root.manifest, REVIEW_MANIFEST_KEYS, 'Domain control review document manifest');
378
422
  const manifestGeneratedAt = exactReviewTimestamp(manifestInput.generatedAt, 'Domain control review document manifest generatedAt');
@@ -390,9 +434,9 @@ export function validateDomainControlReviewDocument(value) {
390
434
  digestSha256: manifestInput.digestSha256,
391
435
  expired,
392
436
  });
393
- const countInput = exactReviewRecord(root.counts, REVIEW_COUNT_KEYS, 'Domain control review document counts');
437
+ const countInput = exactReviewRecord(root.counts, new Set(countKeys), 'Domain control review document counts');
394
438
  const maximumComparisons = MAX_DOMAIN_CONTROL_MANIFEST_ENTRIES * (DOMAIN_CONTROL_REVIEW_FIELDS.length + 1);
395
- const suppliedCounts = Object.fromEntries(DOMAIN_CONTROL_REVIEW_COUNT_KEYS.map((state) => [
439
+ const suppliedCounts = Object.fromEntries(countKeys.map((state) => [
396
440
  state,
397
441
  exactReviewInteger(countInput[state], maximumComparisons, `Domain control review document count ${state}`),
398
442
  ]));
@@ -401,8 +445,8 @@ export function validateDomainControlReviewDocument(value) {
401
445
  throw new TypeError('Domain control review document domains must be a bounded non-empty array.');
402
446
  }
403
447
  const comparisonFields = [...DOMAIN_CONTROL_REVIEW_FIELDS, 'renewalReviewAt'];
404
- const comparisonStates = new Set(DOMAIN_CONTROL_REVIEW_COUNT_KEYS);
405
- const calculatedCounts = Object.fromEntries(DOMAIN_CONTROL_REVIEW_COUNT_KEYS.map((state) => [state, 0]));
448
+ const comparisonStates = new Set(countKeys);
449
+ const calculatedCounts = Object.fromEntries(countKeys.map((state) => [state, 0]));
406
450
  const domains = domainInput.map((domainValue, domainIndex) => {
407
451
  const item = exactReviewRecord(domainValue, REVIEW_DOMAIN_KEYS, `Domain control review document domain ${domainIndex + 1}`);
408
452
  const normalizedDomain = domain(item.domain);
@@ -414,7 +458,7 @@ export function validateDomainControlReviewDocument(value) {
414
458
  throw new TypeError(`Domain control review document domain ${domainIndex + 1} comparisons are incomplete.`);
415
459
  }
416
460
  const comparisons = comparisonsInput.map((comparisonValue, comparisonIndex) => {
417
- const comparison = exactReviewRecord(comparisonValue, REVIEW_COMPARISON_KEYS, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1}`);
461
+ const comparison = exactReviewRecord(comparisonValue, comparisonKeys, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1}`);
418
462
  const expectedField = comparisonFields[comparisonIndex];
419
463
  if (comparison.field !== expectedField
420
464
  || typeof comparison.state !== 'string'
@@ -428,8 +472,8 @@ export function validateDomainControlReviewDocument(value) {
428
472
  : expectedField === 'caa'
429
473
  ? MAX_DOMAIN_CONTROL_CAA_PRESENTATION_LENGTH
430
474
  : MAX_DOMAIN_CONTROL_REVIEW_TEXT_LENGTH;
431
- const desired = exactReviewStringArray(comparison.desired, MAX_DOMAIN_CONTROL_RECORDS, maximumValueCharacters, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1} desired values`);
432
- const observed = exactReviewStringArray(comparison.observed, MAX_DOMAIN_CONTROL_RECORDS, maximumValueCharacters, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1} observed values`);
475
+ const desired = exactReviewStringArray(comparison.desired, recordMaximum, maximumValueCharacters, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1} desired values`);
476
+ const observed = exactReviewStringArray(comparison.observed, recordMaximum, maximumValueCharacters, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1} observed values`);
433
477
  const source = comparison.source === null
434
478
  ? null
435
479
  : exactReviewText(comparison.source, MAX_DOMAIN_CONTROL_REVIEW_SOURCE_LENGTH, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1} source`);
@@ -439,6 +483,41 @@ export function validateDomainControlReviewDocument(value) {
439
483
  if (expectedField === 'renewalReviewAt' && (observed.length || source !== null || observedAt !== null)) {
440
484
  throw new TypeError(`Domain control review document domain ${domainIndex + 1} renewal comparison is inconsistent.`);
441
485
  }
486
+ let expectation;
487
+ if (!legacy) {
488
+ if (expectedField !== 'renewalReviewAt') {
489
+ for (const values of [desired, observed]) {
490
+ const canonical = fieldValues(expectedField, values, recordMaximum);
491
+ if (!canonical || canonical.length !== values.length || canonical.some((value, index) => value !== values[index]))
492
+ throw new TypeError('Domain control review values are not canonical for their field.');
493
+ }
494
+ }
495
+ if (!DOMAIN_CONTROL_RECORD_MODE_OPTIONS.some((mode) => mode.value === comparison.expectation))
496
+ throw new TypeError('Domain control review comparison expectation is invalid.');
497
+ expectation = comparison.expectation;
498
+ if ((expectation === 'expect_records') !== (desired.length > 0)
499
+ || (!DOMAIN_CONTROL_RECORD_LIST_FIELDS.includes(expectedField) && !['unconfigured', 'expect_records'].includes(expectation))
500
+ || (expectation === 'unconfigured' && (state !== 'not_configured' || observed.length || source !== null || observedAt !== null))
501
+ || (state === 'not_configured' && expectation !== 'unconfigured')
502
+ || (state === 'observed' && expectation !== 'observe_only')
503
+ || (['aligned', 'drift', 'due', 'not_due'].includes(state) && ['unconfigured', 'observe_only'].includes(expectation))) {
504
+ throw new TypeError('Domain control review comparison expectation is inconsistent.');
505
+ }
506
+ if (expectedField === 'renewalReviewAt' && expectation === 'expect_records') {
507
+ if (desired.length !== 1 || normalizeExplicitIsoTimestamp(desired[0]) !== desired[0]
508
+ || state !== (Date.parse(desired[0]) <= Date.parse(generatedAt) ? 'due' : root.version === 2 ? 'aligned' : 'not_due'))
509
+ throw new TypeError('Domain control renewal review state is inconsistent.');
510
+ }
511
+ else if (state === 'due' || state === 'not_due')
512
+ throw new TypeError('Only renewal review dates can be due or not due.');
513
+ if (expectedField !== 'renewalReviewAt' && ['aligned', 'drift', 'observed'].includes(state)) {
514
+ if (!source || domainControlEvidenceAdmission(observedAt, generatedAt))
515
+ throw new TypeError('Domain control review requires current source timing for a conclusive comparison.');
516
+ const equal = desired.length === observed.length && desired.every((value, index) => value === observed[index]);
517
+ if ((state === 'aligned' && !equal) || (state === 'drift' && equal))
518
+ throw new TypeError('Domain control review comparison result does not match its records.');
519
+ }
520
+ }
442
521
  return Object.freeze({
443
522
  field: expectedField,
444
523
  state,
@@ -447,15 +526,10 @@ export function validateDomainControlReviewDocument(value) {
447
526
  source,
448
527
  observedAt,
449
528
  explanation: exactReviewText(comparison.explanation, MAX_DOMAIN_CONTROL_REVIEW_TEXT_LENGTH, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1} explanation`),
529
+ ...(expectation !== undefined ? { expectation } : {}),
450
530
  });
451
531
  });
452
- const expectedState = comparisons.some((comparison) => comparison.state === 'drift' || comparison.state === 'due')
453
- ? 'drift'
454
- : comparisons.some((comparison) => comparison.state === 'partial'
455
- || comparison.state === 'unavailable'
456
- || comparison.state === 'unsupported')
457
- ? 'partial'
458
- : 'aligned';
532
+ const expectedState = aggregateComparisonState(comparisons, root.version);
459
533
  if (item.state !== expectedState) {
460
534
  throw new TypeError(`Domain control review document domain ${domainIndex + 1} state is inconsistent.`);
461
535
  }
@@ -464,37 +538,31 @@ export function validateDomainControlReviewDocument(value) {
464
538
  if (new Set(domains.map((item) => item.domain)).size !== domains.length) {
465
539
  throw new TypeError('Domain control review document domains must be unique.');
466
540
  }
467
- for (const state of DOMAIN_CONTROL_REVIEW_COUNT_KEYS) {
541
+ for (const state of countKeys) {
468
542
  if (suppliedCounts[state] !== calculatedCounts[state]) {
469
543
  throw new TypeError('Domain control review document counts are inconsistent.');
470
544
  }
471
545
  }
472
- const expectedState = expired
473
- ? 'expired'
474
- : domains.some((item) => item.state === 'drift')
475
- ? 'drift'
476
- : domains.some((item) => item.state === 'partial')
477
- ? 'partial'
478
- : 'aligned';
546
+ const expectedState = expired ? 'expired' : aggregateDomainState(domains, root.version);
479
547
  if (root.state !== expectedState)
480
548
  throw new TypeError('Domain control review document state is inconsistent.');
481
549
  const ignoredObservationCount = exactReviewInteger(root.ignoredObservationCount, MAX_DOMAIN_CONTROL_REVIEW_OBSERVATIONS, 'Domain control review document ignored observation count');
482
- const limitationsInput = boundedDataArray(root.limitations, DOMAIN_CONTROL_REVIEW_LIMITATIONS.length);
550
+ const limitationsInput = boundedDataArray(root.limitations, limitations.length);
483
551
  if (!limitationsInput
484
- || limitationsInput.length !== DOMAIN_CONTROL_REVIEW_LIMITATIONS.length
485
- || limitationsInput.some((item, index) => item !== DOMAIN_CONTROL_REVIEW_LIMITATIONS[index])) {
552
+ || limitationsInput.length !== limitations.length
553
+ || limitationsInput.some((item, index) => item !== limitations[index])) {
486
554
  throw new TypeError('Domain control review document limitations are invalid.');
487
555
  }
488
556
  return Object.freeze({
489
557
  schema: DOMAIN_CONTROL_REVIEW_SCHEMA,
490
- version: DOMAIN_CONTROL_REVIEW_VERSION,
558
+ version: root.version,
491
559
  generatedAt,
492
560
  state: expectedState,
493
561
  manifest,
494
562
  counts: Object.freeze(suppliedCounts),
495
563
  domains: Object.freeze(domains),
496
564
  ignoredObservationCount,
497
- limitations: DOMAIN_CONTROL_REVIEW_LIMITATIONS,
565
+ limitations,
498
566
  });
499
567
  }
500
568
  export function formatDomainControlResult(value) {
@@ -2,7 +2,8 @@
2
2
  // injected so every traversal can be fixture-tested and the caller retains the
3
3
  // existing DNS timeout and missing/error semantics.
4
4
  import { parse as parseDomain } from 'tldts';
5
- import { parseDmarcRecords, parseSpfRecords } from "./domain-posture-parsers.mjs";
5
+ import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
6
+ import { parseDmarcRecords, parseDmarcReportingAuthorization, parseReportingDestination, parseSpfRecords } from "./domain-posture-parsers.mjs";
6
7
  const SPF_LOOKUP_LIMIT = 10;
7
8
  const SPF_VOID_LOOKUP_LIMIT = 2;
8
9
  const SPF_MAX_DEPTH = 5;
@@ -158,14 +159,18 @@ async function expandSpfPolicy(rootDomain, rootQuery, resolveTxt) {
158
159
  };
159
160
  }
160
161
  function reportDestinationDomain(value) {
161
- const raw = String(value || '').trim();
162
- if (!/^mailto:/iu.test(raw))
162
+ if (typeof value !== 'string')
163
163
  return null;
164
- const address = raw.slice(7).split('!')[0] || '';
165
- const at = address.lastIndexOf('@');
166
- return at > 0 ? strictHostname(address.slice(at + 1)) : null;
164
+ // The obsolete DMARC report-size suffix remains readable, but arbitrary
165
+ // text after an exclamation mark must not repair a malformed destination.
166
+ const uri = value.trim().replace(/![0-9]+[kmgt]?$/iu, '');
167
+ if (/[!,;]/u.test(uri))
168
+ return null;
169
+ const destination = parseReportingDestination(uri);
170
+ const domain = destination?.scheme === 'mailto' ? strictHostname(destination.host) : null;
171
+ return domain && isValidAsciiHostname(domain) ? domain : null;
167
172
  }
168
- async function validateDmarcExternalReporting(ownerDomain, dmarcQuery, resolveTxt) {
173
+ async function validateDmarcExternalReporting(ownerDomain, dmarcQuery, resolveTxt, resolveOrganisationalDomain) {
169
174
  if (dmarcQuery.error)
170
175
  return [];
171
176
  const parsed = parseDmarcRecords(dmarcQuery.records);
@@ -189,7 +194,7 @@ async function validateDmarcExternalReporting(ownerDomain, dmarcQuery, resolveTx
189
194
  });
190
195
  continue;
191
196
  }
192
- if (destinationDomain === ownerDomain || destinationDomain.endsWith(`.${ownerDomain}`)) {
197
+ if (destinationDomain === ownerDomain) {
193
198
  results.push({
194
199
  destination: destinationDomain,
195
200
  reportType: item.reportType,
@@ -199,19 +204,44 @@ async function validateDmarcExternalReporting(ownerDomain, dmarcQuery, resolveTx
199
204
  });
200
205
  continue;
201
206
  }
207
+ const ownerOrganisation = resolveOrganisationalDomain ? await resolveOrganisationalDomain(ownerDomain) : null;
208
+ const recipientOrganisation = ownerOrganisation && resolveOrganisationalDomain ? await resolveOrganisationalDomain(destinationDomain) : null;
209
+ if (ownerOrganisation && ownerOrganisation === recipientOrganisation) {
210
+ results.push({ destination: destinationDomain, reportType: item.reportType, recordName: null, state: 'self', error: null });
211
+ continue;
212
+ }
213
+ const externalConfirmed = Boolean(ownerOrganisation && recipientOrganisation);
214
+ // Descendant reporting previously made no authorisation request. Do not
215
+ // broaden default collection to resolve its unknown organisational scope.
216
+ if (!resolveOrganisationalDomain && destinationDomain.endsWith(`.${ownerDomain}`)) {
217
+ results.push({ destination: destinationDomain, reportType: item.reportType, recordName: null, state: 'unavailable',
218
+ error: 'The organisational boundary was not checked. Select the additional DNS review to determine whether external reporting authorisation is required.' });
219
+ continue;
220
+ }
202
221
  const recordName = `${ownerDomain}._report._dmarc.${destinationDomain}`;
222
+ if (recordName.length > 253) {
223
+ results.push({ destination: destinationDomain, reportType: item.reportType, recordName: null, state: 'unavailable', error: 'The reporting authorisation query exceeds the DNS name limit.' });
224
+ continue;
225
+ }
203
226
  let query = cache.get(recordName);
204
227
  if (!query) {
205
228
  query = await resolveTxt(recordName);
206
229
  cache.set(recordName, query);
207
230
  }
208
- const authorized = query.records.some((record) => (String(Array.isArray(record) ? record.join('') : record || '').trim().toUpperCase().startsWith('V=DMARC1')));
231
+ const authorized = query.records.some((record) => {
232
+ const tags = parseDmarcReportingAuthorization(record);
233
+ if (!tags)
234
+ return false;
235
+ // A recipient override cannot redirect authorisation to another host.
236
+ return tags.rua === undefined || tags.rua.split(',').every((destination) => reportDestinationDomain(destination) === destinationDomain);
237
+ });
209
238
  results.push({
210
239
  destination: destinationDomain,
211
240
  reportType: item.reportType,
212
241
  recordName,
213
- state: query.error ? 'unavailable' : authorized ? 'authorized' : 'not_found',
214
- error: query.error,
242
+ state: query.error ? 'unavailable' : authorized ? 'authorized' : externalConfirmed ? 'not_found' : 'unavailable',
243
+ error: query.error ?? (!authorized && !externalConfirmed
244
+ ? 'The organisational boundary is unknown; no authorisation requirement can be inferred from the missing record.' : null),
215
245
  });
216
246
  }
217
247
  return results;
@@ -3,6 +3,7 @@
3
3
  // synthetic records and the Express/Netlify paths share identical behavior.
4
4
  import { createPublicKey } from 'node:crypto';
5
5
  import { isIP } from 'node:net';
6
+ import { domainToASCII } from 'node:url';
6
7
  function joinTxtRecords(records) {
7
8
  if (!Array.isArray(records))
8
9
  return [];
@@ -76,8 +77,9 @@ function parseSpfRecords(records) {
76
77
  return true;
77
78
  }
78
79
  const hostname = value.toLowerCase().replace(/\.$/u, '');
79
- if (hostname.length > 253 || !hostname.includes('.') || !hostname.split('.').every((part) => /^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/u.test(part))) {
80
+ if (/[^\x00-\x7f]/u.test(value) || hostname.length > 253 || !hostname.includes('.') || !hostname.split('.').every((part) => /^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/u.test(part))) {
80
81
  result.issues.push(`${label} has an invalid literal domain-spec.`);
82
+ invalidSyntax = true;
81
83
  return false;
82
84
  }
83
85
  return true;
@@ -195,9 +197,73 @@ function parseSpfRecords(records) {
195
197
  result.valid = !invalidSyntax;
196
198
  return result;
197
199
  }
200
+ function hasDmarcVersion(record) {
201
+ return /^[vV][ \t]*=[ \t]*DMARC1(?:[ \t]*;|[ \t]*$)/u.test(record);
202
+ }
203
+ function parseDmarcReportingAuthorization(record) {
204
+ const text = joinTxtRecords([record])[0] || '';
205
+ if (!hasDmarcVersion(text))
206
+ return null;
207
+ const fields = text.split(';');
208
+ if (!fields[fields.length - 1]?.trim())
209
+ fields.pop();
210
+ // RFC 9990 section 4 requires a complete tag list, not a version prefix.
211
+ if (fields.some((field) => !/^[ \t]*[a-zA-Z]+[ \t]*=[ \t]*[\x20-\x3a\x3c-\x7e]+[ \t]*$/u.test(field)))
212
+ return null;
213
+ const parsed = parseTagList(text);
214
+ return parsed.duplicates.length || parsed.malformed.length ? null : parsed.tags;
215
+ }
216
+ function parseReportingDestination(value) {
217
+ // Validate URI spelling before URL parsing can repair whitespace, escapes or
218
+ // missing authority delimiters. DNS collection already bounds the input.
219
+ if (!value || /[^a-zA-Z0-9._~:/?#\[\]@!$&'()*+,;=%-]/u.test(value) || /%(?![a-fA-F0-9]{2})/u.test(value))
220
+ return null;
221
+ if (/^mailto:/iu.test(value)) {
222
+ const parts = value.slice(7).split('?');
223
+ if (parts.length > 2 || value.includes('#'))
224
+ return null;
225
+ const headers = parts[1];
226
+ if (headers !== undefined && !headers.split('&').every((field) => /^[a-zA-Z0-9._~!$'()*+,:@%\-]*=[a-zA-Z0-9._~!$'()*+,:@%\-]*$/u.test(field)))
227
+ return null;
228
+ let address;
229
+ try {
230
+ address = decodeURIComponent(parts[0] || '');
231
+ }
232
+ catch {
233
+ return null;
234
+ }
235
+ const at = address.lastIndexOf('@');
236
+ if (at <= 0)
237
+ return null;
238
+ const local = address.slice(0, at);
239
+ const dotAtom = /^[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*$/u;
240
+ const quoted = /^"(?:[\x20-\x21\x23-\x5b\x5d-\x7e]|\\[\x20-\x7e])*"$/u;
241
+ if (!dotAtom.test(local) && !quoted.test(local))
242
+ return null;
243
+ const domain = address.slice(at + 1);
244
+ if (domain.startsWith('[') && domain.endsWith(']')) {
245
+ const literal = domain.slice(1, -1);
246
+ return isIP(literal.replace(/^IPv6:/iu, '')) ? { scheme: 'mailto', host: domain } : null;
247
+ }
248
+ const host = domainToASCII(domain).toLowerCase();
249
+ if (!host || host.length > 253 || !host.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(label)))
250
+ return null;
251
+ return { scheme: 'mailto', host };
252
+ }
253
+ const authority = /^https:\/\/([^/?#]+)/iu.exec(value)?.[1];
254
+ if (!authority || authority.includes('@') || value.includes('#'))
255
+ return null;
256
+ try {
257
+ const uri = new URL(value);
258
+ return uri.hostname && !uri.username && !uri.password && !uri.hash ? { scheme: 'https', host: uri.hostname } : null;
259
+ }
260
+ catch {
261
+ return null;
262
+ }
263
+ }
198
264
  function parseDmarcRecords(records) {
199
265
  const allRecords = joinTxtRecords(records);
200
- const dmarcRecords = allRecords.filter((record) => /^v\s*=\s*dmarc1(?:\s*;|$)/i.test(record));
266
+ const dmarcRecords = allRecords.filter(hasDmarcVersion);
201
267
  const result = {
202
268
  records: dmarcRecords,
203
269
  valid: false,
@@ -255,8 +321,12 @@ function parseDmarcRecords(records) {
255
321
  result.failureReporting = Boolean(parsed.tags.ruf);
256
322
  result.aggregateDestinations = (parsed.tags.rua || '').split(',').map((value) => value.trim()).filter(Boolean);
257
323
  result.failureDestinations = (parsed.tags.ruf || '').split(',').map((value) => value.trim()).filter(Boolean);
324
+ const emptyDestination = [parsed.tags.rua, parsed.tags.ruf].some((value) => value !== undefined && value.split(',').some((destination) => !destination.trim()));
325
+ if (emptyDestination)
326
+ result.issues.push('A DMARC reporting URI list contains an empty destination.');
258
327
  result.valid = parsed.duplicates.length === 0
259
328
  && parsed.malformed.length === 0
329
+ && !emptyDestination
260
330
  && Boolean(result.policy && result.subdomainPolicy && result.nonexistentSubdomainPolicy)
261
331
  && (!parsed.tags.t || /^[yn]$/i.test(parsed.tags.t));
262
332
  return result;
@@ -333,7 +403,7 @@ function parseMtaStsPolicy(text) {
333
403
  }
334
404
  function parseTlsRptRecords(records) {
335
405
  const allRecords = joinTxtRecords(records);
336
- const policyRecords = allRecords.filter((record) => /^v\s*=\s*tlsrptv1(?:\s*;|$)/i.test(record));
406
+ const policyRecords = allRecords.filter((record) => /^v=TLSRPTv1(?:[ \t]*;|$)/u.test(record));
337
407
  const result = { records: policyRecords, valid: false, record: policyRecords[0] || null, rua: [], issues: [] };
338
408
  if (policyRecords.length === 0) {
339
409
  result.issues.push('No TLS-RPT policy record was found.');
@@ -344,14 +414,26 @@ function parseTlsRptRecords(records) {
344
414
  return result;
345
415
  }
346
416
  const parsed = parseTagList(policyRecords[0]);
347
- result.rua = (parsed.tags.rua || '').split(',').map((value) => value.trim()).filter(Boolean);
417
+ const destinations = (parsed.tags.rua || '').split(',').map((value) => value.trim());
418
+ result.rua = destinations.filter(Boolean);
348
419
  if (result.rua.length === 0)
349
420
  result.issues.push('The TLS-RPT record has no aggregate report destination.');
421
+ if (destinations.some((destination) => /[!,;]/u.test(destination) || !parseReportingDestination(destination))) {
422
+ result.issues.push('TLS-RPT destinations must be complete supported mailto or HTTPS URIs; reserved reporting delimiters must be percent-encoded.');
423
+ }
424
+ const fields = (policyRecords[0] || '').split(';');
425
+ if (!fields[fields.length - 1]?.trim())
426
+ fields.pop();
427
+ if (fields.slice(1).some((field) => {
428
+ const text = field.trim();
429
+ return text.startsWith('rua=') ? false : !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,31}=[\x21-\x3a\x3c\x3e-\x7e]+$/u.test(text) || /^rua=/iu.test(text);
430
+ }))
431
+ result.issues.push('The TLS-RPT record contains an invalid field name or value.');
350
432
  if (parsed.duplicates.length)
351
433
  result.issues.push(`Duplicate TLS-RPT tag${parsed.duplicates.length === 1 ? '' : 's'}: ${parsed.duplicates.join(', ')}.`);
352
434
  if (parsed.malformed.length)
353
435
  result.issues.push(`Malformed TLS-RPT field${parsed.malformed.length === 1 ? '' : 's'}: ${parsed.malformed.join(', ')}.`);
354
- result.valid = result.rua.length > 0 && parsed.duplicates.length === 0 && parsed.malformed.length === 0;
436
+ result.valid = result.issues.length === 0;
355
437
  return result;
356
438
  }
357
439
  function parseBimiRecords(records) {
@@ -466,4 +548,4 @@ function parseDkimRecords(selector, records) {
466
548
  result.valid = result.issues.length === 0;
467
549
  return result;
468
550
  }
469
- export { joinTxtRecords, parseTagList, parseSpfRecords, parseDmarcRecords, parseMtaStsDnsRecords, parseMtaStsPolicy, parseTlsRptRecords, parseBimiRecords, parseDkimRecords, };
551
+ export { joinTxtRecords, parseTagList, parseSpfRecords, parseDmarcRecords, parseDmarcReportingAuthorization, parseReportingDestination, parseMtaStsDnsRecords, parseMtaStsPolicy, parseTlsRptRecords, parseBimiRecords, parseDkimRecords, };