@slicedearth/whoisleuth-cli 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +20 -15
  3. package/bin/whoisleuth.mjs +8 -1
  4. package/cli/archive-content-digest.mjs +14 -0
  5. package/cli/archive-inspect.mjs +20 -53
  6. package/cli/arguments.mjs +454 -1748
  7. package/cli/artifact-validation/case-response.mjs +66 -35
  8. package/cli/artifact-validation/investigation-capsule.mjs +52 -31
  9. package/cli/artifact-validation/lookup-evidence.mjs +12 -6
  10. package/cli/artifact-validation/signed-review.mjs +112 -72
  11. package/cli/artifact-validation/structure-primitives.mjs +1 -105
  12. package/cli/artifact-verify.mjs +90 -24
  13. package/cli/assurance-command-runner.mjs +39 -4
  14. package/cli/bagit-folder.mjs +57 -0
  15. package/cli/bagit-review.mjs +23 -0
  16. package/cli/bounded-json.mjs +1 -1
  17. package/cli/bulk-checkpoint.mjs +34 -3
  18. package/cli/bulk-command-runner.mjs +2 -0
  19. package/cli/bulk-output.mjs +29 -3
  20. package/cli/case-command.mjs +195 -0
  21. package/cli/case-pack.mjs +66 -111
  22. package/cli/command-argument-grammar.mjs +215 -0
  23. package/cli/command-reference.mjs +342 -285
  24. package/cli/completion.mjs +303 -80
  25. package/cli/config-profile.mjs +7 -5
  26. package/cli/ct-event-intake.mjs +12 -12
  27. package/cli/discover.mjs +4 -3
  28. package/cli/discovery-observation-snapshot.mjs +59 -18
  29. package/cli/discovery-scan-command-runner.mjs +21 -0
  30. package/cli/discovery-scan.mjs +13 -4
  31. package/cli/discovery-workflow.mjs +1 -1
  32. package/cli/domain-control-monitor.mjs +50 -20
  33. package/cli/domain-control-observations.mjs +149 -102
  34. package/cli/errors.mjs +33 -1
  35. package/cli/evidence-command-runner.mjs +16 -5
  36. package/cli/evidence-signing.mjs +2 -2
  37. package/cli/external-observation-mapping.mjs +7 -8
  38. package/cli/formatters/terminal-lookup.mjs +18 -46
  39. package/cli/formatters/terminal-shared.mjs +5 -3
  40. package/cli/formatters/terminal-technology.mjs +114 -0
  41. package/cli/inline-command-families.mjs +2 -0
  42. package/cli/input.mjs +116 -0
  43. package/cli/interactive-launcher.mjs +2 -160
  44. package/cli/interchange-report.mjs +18 -8
  45. package/cli/investigation-artifacts.mjs +176 -0
  46. package/cli/investigation-checkpoint.mjs +10 -0
  47. package/cli/investigation-folder.mjs +114 -0
  48. package/cli/investigation-manifest.mjs +1 -112
  49. package/cli/investigation-package-review.mjs +103 -0
  50. package/cli/investigation-plan.mjs +32 -19
  51. package/cli/investigation-run.mjs +188 -33
  52. package/cli/local-document-checkpoint.mjs +170 -0
  53. package/cli/lookalike-calibration.mjs +7 -2
  54. package/cli/lookup-brief.mjs +4 -2
  55. package/cli/lookup-browser-view.mjs +1 -1
  56. package/cli/lookup-browser.mjs +5 -12
  57. package/cli/lookup-command-runner.mjs +17 -6
  58. package/cli/lookup-diff.mjs +37 -3
  59. package/cli/lookup-plan.mjs +11 -5
  60. package/cli/lookup-reconcile.mjs +10 -8
  61. package/cli/mail-header-review.mjs +467 -0
  62. package/cli/mail-review.mjs +66 -14
  63. package/cli/manual.mjs +1 -0
  64. package/cli/network-command-runner.mjs +2 -0
  65. package/cli/output-file.mjs +31 -5
  66. package/cli/registry-doctor.mjs +3 -1
  67. package/cli/registry-support.mjs +1 -1
  68. package/cli/retained-artifact-diff.mjs +2 -2
  69. package/cli/review-command-runner.mjs +70 -0
  70. package/cli/risk-calibration.mjs +19 -17
  71. package/cli/runner.mjs +62 -7
  72. package/cli/sharing-review.mjs +21 -34
  73. package/cli/signer-trust.mjs +104 -0
  74. package/cli/source-reliability.mjs +74 -83
  75. package/cli/terminal-input.mjs +168 -0
  76. package/cli/workflow-command-runner.mjs +68 -13
  77. package/docs/cli-reference.md +77 -24
  78. package/docs/cli.md +300 -25
  79. package/lib/abort.mjs +4 -1
  80. package/lib/availability.mjs +161 -112
  81. package/lib/bounded-file.mjs +3 -1
  82. package/lib/bounded-html-document.mjs +125 -0
  83. package/lib/bounded-json.mjs +51 -8
  84. package/lib/browser-library-profile.mjs +42 -51
  85. package/lib/browser-library-worker.mjs +112 -0
  86. package/lib/client-behavior-profile.mjs +3 -3
  87. package/lib/ct-search.mjs +11 -4
  88. package/lib/dns-convergence-review.mjs +1 -0
  89. package/lib/dns-delegation-health.mjs +24 -14
  90. package/lib/dns-inheritance-review.mjs +217 -0
  91. package/lib/dns-intelligence.mjs +73 -26
  92. package/lib/dns-mx.mjs +1 -1
  93. package/lib/dnssec-chain-validation.mjs +6 -0
  94. package/lib/dnssec-evidence-validation.mjs +11 -3
  95. package/lib/domain-change-packet.mjs +49 -16
  96. package/lib/domain-change-review.mjs +14 -8
  97. package/lib/domain-control-flight-recorder.mjs +221 -117
  98. package/lib/domain-control-manifest.mjs +129 -69
  99. package/lib/domain-posture-analysis.mjs +20 -7
  100. package/lib/domain-posture-parsers.mjs +201 -8
  101. package/lib/domain-posture.mjs +147 -29
  102. package/lib/evidence-export-privacy.mjs +561 -0
  103. package/lib/evidence-export.mjs +11 -543
  104. package/lib/evidence-report.mjs +5 -3
  105. package/lib/favicon.mjs +117 -41
  106. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  107. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  108. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  109. package/lib/hostname.mjs +2 -38
  110. package/lib/html-signals.mjs +111 -108
  111. package/lib/http-evidence-bounds.mjs +2 -1
  112. package/lib/http-intelligence.mjs +39 -26
  113. package/lib/local-geoip-evidence.mjs +19 -3
  114. package/lib/lookup-cache.mjs +34 -11
  115. package/lib/lookup-child-profile-contract.mjs +24 -15
  116. package/lib/lookup-diagnostics.mjs +24 -1
  117. package/lib/lookup-progress.mjs +30 -13
  118. package/lib/lookup-response-contract.mjs +1048 -0
  119. package/lib/lookup-response.mjs +8 -10
  120. package/lib/lookup-source-progress.mjs +40 -14
  121. package/lib/lookup.mjs +48 -27
  122. package/lib/nameserver-preflight-review.mjs +1 -0
  123. package/lib/observed-network-context.mjs +43 -27
  124. package/lib/outbound-request-bounds.mjs +4 -1
  125. package/lib/page-fingerprints.mjs +69 -133
  126. package/lib/page-role-profile.mjs +12 -8
  127. package/lib/perceptual-hash.mjs +43 -14
  128. package/lib/rdap-bootstrap.mjs +72 -22
  129. package/lib/rdap-capabilities.mjs +1 -1
  130. package/lib/rdap-client.mjs +15 -7
  131. package/lib/rdap-normalization.mjs +33 -18
  132. package/lib/rdap-search-workbench.mjs +1 -1
  133. package/lib/rdap-transport.mjs +47 -20
  134. package/lib/rdap-types.mjs +2 -1
  135. package/lib/rdap.mjs +18 -3
  136. package/lib/registrable-domain.mjs +21 -1
  137. package/lib/registrar-standing-contract.mjs +1 -0
  138. package/lib/registry-capabilities.mjs +5 -1
  139. package/lib/registry-capability-catalogue.mjs +2 -1
  140. package/lib/registry-dates.mjs +2 -190
  141. package/lib/registry-insights.mjs +85 -34
  142. package/lib/response-policy.mjs +86 -48
  143. package/lib/risk-calibration-summary.mjs +18 -6
  144. package/lib/rpki-evidence.mjs +8 -5
  145. package/lib/safe-fetch.mjs +54 -47
  146. package/lib/security-txt.mjs +42 -7
  147. package/lib/service-binding-dns.mjs +8 -0
  148. package/lib/smtp-transport-review.mjs +27 -15
  149. package/lib/sslbl-certificates.generated.mjs +136 -135
  150. package/lib/static-html-analysis.mjs +178 -151
  151. package/lib/structured-data-identity.mjs +1 -0
  152. package/lib/technology-markup.mjs +100 -0
  153. package/lib/threat-intelligence-manifests.mjs +11 -139
  154. package/lib/threat-intelligence-runtime.mjs +24 -52
  155. package/lib/threat-intelligence-targets.mjs +6 -34
  156. package/lib/threat-intelligence-types.mjs +39 -0
  157. package/lib/typosquat-generator.mjs +19 -15
  158. package/lib/urlhaus-intelligence.mjs +8 -3
  159. package/lib/urlscan-intelligence.mjs +1 -1
  160. package/lib/website-security-posture.mjs +30 -6
  161. package/lib/website-technology.mjs +153 -45
  162. package/lib/whois-authority.mjs +14 -2
  163. package/lib/whois-chain.mjs +9 -2
  164. package/lib/whois-common-formats.mjs +5 -5
  165. package/lib/whois-contracts.mjs +2 -1
  166. package/lib/whois-dialects.mjs +2 -2
  167. package/lib/whois-parser-support.mjs +8 -1
  168. package/lib/whois-transport.mjs +23 -7
  169. package/lib/zone-intent-review.mjs +16 -6
  170. package/package.json +2 -1
  171. package/packages/cases/case-attachment-model.mjs +83 -0
  172. package/packages/cases/case-evidence-model.mjs +156 -62
  173. package/packages/cases/case-export-input.mjs +53 -0
  174. package/packages/cases/case-migration-model.mjs +127 -44
  175. package/packages/cases/case-model.mjs +1 -0
  176. package/packages/cases/case-recheck-model.mjs +91 -0
  177. package/packages/cases/case-record-contracts.mjs +5 -20
  178. package/packages/cases/case-record-core.mjs +12 -22
  179. package/packages/cases/case-record-decisions.mjs +72 -0
  180. package/packages/cases/case-record-model.mjs +1 -0
  181. package/packages/cases/case-record-operations.mjs +95 -31
  182. package/packages/cases/case-record-projection.mjs +221 -0
  183. package/packages/cases/case-report.mjs +70 -54
  184. package/packages/cases/case-response-actions.mjs +654 -0
  185. package/packages/cases/case-response-model.mjs +64 -1115
  186. package/packages/cases/case-response-outcomes.mjs +317 -0
  187. package/packages/cases/case-response-packet-render.mjs +153 -0
  188. package/packages/cases/case-response-packet-types.mjs +1 -0
  189. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  190. package/packages/cases/case-response-packet.mjs +74 -572
  191. package/packages/cases/case-response-records.mjs +94 -0
  192. package/packages/cases/case-response-review-inputs.mjs +371 -0
  193. package/packages/cases/case-response-values.mjs +85 -0
  194. package/packages/cases/case-review-package.mjs +45 -0
  195. package/packages/cases/case-selection.mjs +46 -0
  196. package/packages/cases/case-storage-model.mjs +31 -2
  197. package/packages/cases/response-route-freshness.mjs +16 -0
  198. package/packages/comparison/capture-context.mjs +61 -0
  199. package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
  200. package/packages/comparison/page-similarity.mjs +5 -2
  201. package/packages/comparison/relationship-evidence.mjs +85 -8
  202. package/packages/comparison/relationship-provenance.mjs +93 -0
  203. package/packages/contracts/analyst-interchange.mjs +216 -0
  204. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  205. package/packages/contracts/analyst-review-state.mjs +5 -4
  206. package/packages/contracts/case-portability.mjs +366 -83
  207. package/packages/contracts/case-views-contract.mjs +12 -0
  208. package/packages/contracts/case-views.mjs +26 -0
  209. package/packages/contracts/cli-command-semantics.mjs +94 -0
  210. package/packages/contracts/cli-lookup.mjs +0 -1
  211. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  212. package/packages/contracts/domain-control-manifest.mjs +127 -73
  213. package/packages/contracts/domain-control-monitor.mjs +48 -45
  214. package/packages/contracts/domain-control-review.mjs +179 -121
  215. package/packages/contracts/domain-name.mjs +21 -0
  216. package/packages/contracts/external-observation-interchange.mjs +2 -1
  217. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  218. package/packages/contracts/http-url.mjs +2 -0
  219. package/packages/contracts/investigation-package-limits.mjs +24 -0
  220. package/packages/contracts/investigation-portability.mjs +56 -27
  221. package/packages/contracts/investigation-run.mjs +16 -0
  222. package/packages/contracts/ip-address.mjs +34 -0
  223. package/packages/contracts/lookup-evidence.mjs +35 -0
  224. package/packages/contracts/offline-comparison.mjs +1 -2
  225. package/packages/contracts/page-fingerprints.mjs +15 -0
  226. package/packages/contracts/risk-calibration.mjs +15 -11
  227. package/packages/contracts/schema-compatibility.mjs +1 -1
  228. package/packages/contracts/schema-lifecycle.mjs +83 -153
  229. package/packages/contracts/selected-file-limits.mjs +6 -0
  230. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  231. package/packages/contracts/web-capture.mjs +10 -0
  232. package/packages/contracts/workspace-portability.mjs +183 -211
  233. package/packages/evidence/artifact-integrity.mjs +8 -2
  234. package/packages/evidence/artifact-structure.mjs +105 -0
  235. package/packages/evidence/decision-fact.mjs +4 -1
  236. package/packages/evidence/domain-control-runtime.mjs +201 -33
  237. package/packages/evidence/domain-posture-context.mjs +79 -0
  238. package/packages/evidence/image-regions.mjs +56 -0
  239. package/packages/evidence/latest-observations.mjs +28 -0
  240. package/packages/evidence/lookup-target.mjs +104 -0
  241. package/packages/evidence/observation.mjs +16 -1
  242. package/packages/evidence/passphrase-encryption.mjs +44 -0
  243. package/packages/evidence/record-identity.mjs +72 -0
  244. package/packages/evidence/registry-dates.mjs +190 -0
  245. package/packages/evidence/retained-file.mjs +69 -0
  246. package/packages/evidence/review-age.mjs +14 -0
  247. package/packages/interchange/bagit.mjs +342 -0
  248. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  249. package/packages/interchange/crc32.mjs +17 -0
  250. package/packages/interchange/external-findings-import.mjs +92 -51
  251. package/packages/interchange/web-capture-import.mjs +382 -0
  252. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  253. package/packages/investigation/investigation-bagit.mjs +14 -0
  254. package/packages/investigation/investigation-capsule.mjs +33 -5
  255. package/packages/investigation/investigation-manifest.mjs +200 -0
  256. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  257. package/packages/investigation/investigation-package.mjs +200 -0
  258. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  259. package/packages/investigation/lookup-asset-graph.mjs +193 -165
  260. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  261. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  262. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  263. package/packages/monitoring/analyst-review-state.mjs +38 -90
  264. package/packages/workspace/brand-profile-model.mjs +276 -22
  265. package/packages/workspace/bulk-columns.mjs +17 -0
  266. package/packages/workspace/bulk-review-model.mjs +23 -11
  267. package/packages/workspace/bulk-session-model.mjs +100 -24
  268. package/packages/workspace/bulk-sort.mjs +1 -0
  269. package/packages/workspace/case-views.mjs +102 -0
  270. package/packages/workspace/detection-rule-model.mjs +70 -22
  271. package/packages/workspace/hostile-input.mjs +29 -8
  272. package/packages/workspace/investigation-template-model.mjs +19 -4
  273. package/packages/workspace/page-baseline.mjs +18 -15
  274. package/packages/workspace/relationship-observation-model.mjs +41 -23
  275. package/packages/workspace/watchlist-history.mjs +2 -2
  276. package/packages/workspace/watchlist-store.mjs +8 -2
  277. package/packages/workspace/website-snapshot-model.mjs +164 -26
  278. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  279. package/packages/workspace/workspace-archive.mjs +87 -21
  280. package/third-party-notices.txt +29 -2
  281. package/cli/evidence-command-arguments.mjs +0 -156
  282. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -1,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, 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_COUNT_KEYS, PUBLIC_DOMAIN_CONTROL_REVIEW_COMPARISON_KEYS, PUBLIC_DOMAIN_CONTROL_REVIEW_LIMITATIONS, 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) {
@@ -115,15 +114,13 @@ function hostnameList(value) {
115
114
  return [...new Set(value
116
115
  .map(domain)
117
116
  .filter((item) => item !== null))]
118
- .sort()
119
- .slice(0, MAX_DOMAIN_CONTROL_RECORDS);
117
+ .sort();
120
118
  }
121
119
  function textList(value, maximum = 300) {
122
120
  return [...new Set(value
123
121
  .map((item) => boundedText(item, maximum)?.toLowerCase() ?? null)
124
122
  .filter((item) => item !== null))]
125
- .sort()
126
- .slice(0, MAX_DOMAIN_CONTROL_RECORDS);
123
+ .sort();
127
124
  }
128
125
  function spkiFingerprint(value) {
129
126
  if (typeof value !== 'string'
@@ -188,24 +185,25 @@ function boundedDataArray(value, maximum) {
188
185
  return null;
189
186
  }
190
187
  }
191
- function fieldValues(field, value) {
188
+ function fieldValues(field, value, maximum) {
192
189
  if (field === 'nameservers') {
193
190
  const normalized = value.map(domain);
194
- return normalized.some((item) => item === null)
195
- ? null
196
- : hostnameList(normalized);
191
+ if (normalized.some((item) => item === null))
192
+ return null;
193
+ const values = hostnameList(normalized);
194
+ return values.length <= maximum ? values : null;
197
195
  }
198
196
  if (field === 'mx' || field === 'caa' || field === 'ds') {
199
197
  const normalized = value.flatMap((item) => canonicalDomainControlRecordList([item], field));
200
- return normalized.length === value.length
201
- ? [...new Set(normalized)].sort().slice(0, MAX_DOMAIN_CONTROL_RECORDS)
202
- : null;
198
+ const values = [...new Set(normalized)].sort();
199
+ return normalized.length === value.length && values.length <= maximum ? values : null;
203
200
  }
204
201
  if (field === 'tlsSpkiSha256') {
205
202
  const normalized = value.map(spkiFingerprint);
206
- return normalized.some((item) => item === null)
207
- ? null
208
- : [...new Set(normalized)].sort().slice(0, MAX_DOMAIN_CONTROL_RECORDS);
203
+ if (normalized.some((item) => item === null))
204
+ return null;
205
+ const values = [...new Set(normalized)].sort();
206
+ return values.length <= maximum ? values : null;
209
207
  }
210
208
  if (field === 'registrarLock') {
211
209
  return value.every((item) => item === 'required' || item === 'not_required')
@@ -213,11 +211,12 @@ function fieldValues(field, value) {
213
211
  : null;
214
212
  }
215
213
  const normalized = value.map((item) => boundedText(item, MAX_DOMAIN_CONTROL_REVIEW_TEXT_LENGTH)?.toLowerCase() ?? null);
216
- return normalized.some((item) => item === null)
217
- ? null
218
- : textList(normalized, MAX_DOMAIN_CONTROL_REVIEW_TEXT_LENGTH);
214
+ if (normalized.some((item) => item === null))
215
+ return null;
216
+ const values = textList(normalized, MAX_DOMAIN_CONTROL_REVIEW_TEXT_LENGTH);
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,36 @@ 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
326
  field: 'renewalReviewAt', state: due ? 'due' : 'aligned', 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, legacy = false) {
332
+ if (comparisons.some((item) => item.state === 'drift' || item.state === 'due'))
333
+ return 'drift';
334
+ if (comparisons.some((item) => ['partial', 'unavailable', 'unsupported'].includes(item.state)))
335
+ return 'partial';
336
+ if (legacy || comparisons.some((item) => item.state === 'aligned'))
337
+ return 'aligned';
338
+ return comparisons.some((item) => item.state === 'observed') ? 'observed' : 'not_configured';
339
+ }
340
+ function aggregateDomainState(domains, legacy = false) {
341
+ if (domains.some((item) => item.state === 'drift'))
342
+ return 'drift';
343
+ if (domains.some((item) => item.state === 'partial'))
344
+ return 'partial';
345
+ if (legacy || domains.some((item) => item.state === 'aligned'))
346
+ return 'aligned';
347
+ return domains.some((item) => item.state === 'observed') ? 'observed' : 'not_configured';
348
+ }
317
349
  export function reviewDomainControlManifest(input, generatedAtValue = new Date().toISOString()) {
318
350
  const source = record(input);
319
- if (!source || source.schema !== DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA || source.version !== DOMAIN_CONTROL_REVIEW_VERSION) {
351
+ if (!source || source.schema !== DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA || !SUPPORTED_DOMAIN_CONTROL_REVIEW_VERSIONS.includes(source.version)) {
320
352
  throw new TypeError(`Domain control review input must use ${DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA} version ${DOMAIN_CONTROL_REVIEW_VERSION}.`);
321
353
  }
322
354
  exactKeys(source, REVIEW_INPUT_KEYS, 'Domain control review input');
@@ -324,11 +356,14 @@ export function reviewDomainControlManifest(input, generatedAtValue = new Date()
324
356
  if (!generatedAt)
325
357
  throw new TypeError('Domain control review time must be valid and include an explicit timezone.');
326
358
  const manifest = verifyDomainControlManifest(source.manifest);
359
+ const legacy = source.version === PUBLIC_DOMAIN_CONTROL_REVIEW_VERSION;
360
+ if (legacy && manifest.version !== PUBLIC_DOMAIN_CONTROL_MANIFEST_VERSION)
361
+ throw new TypeError('Public review input requires its published manifest contract; use the current review input version for explicit expectations.');
327
362
  const rawObservations = boundedDataArray(source.observations, MAX_DOMAIN_CONTROL_REVIEW_OBSERVATIONS);
328
363
  if (!rawObservations) {
329
364
  throw new TypeError('Domain control observations must be a bounded array.');
330
365
  }
331
- const observations = rawObservations.map(normalizeObservation);
366
+ const observations = rawObservations.map((value) => normalizeObservation(value, legacy));
332
367
  if (observations.some((item) => item === null))
333
368
  throw new TypeError('Domain control review contains an invalid observation.');
334
369
  const normalizedObservations = observations;
@@ -339,23 +374,19 @@ export function reviewDomainControlManifest(input, generatedAtValue = new Date()
339
374
  const fields = DOMAIN_CONTROL_REVIEW_FIELDS;
340
375
  const domains = manifest.entries.map((entry) => {
341
376
  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';
377
+ const comparisons = [...fields.map((field) => compareField(entry, observation, field, generatedAt)), renewalComparison(entry, generatedAt)];
378
+ const state = aggregateComparisonState(comparisons);
348
379
  return Object.freeze({ domain: entry.domain, state, comparisons: Object.freeze(comparisons) });
349
380
  });
350
381
  const comparisons = domains.flatMap((item) => item.comparisons);
351
- const counts = Object.fromEntries(['aligned', 'drift', 'partial', 'unavailable', 'unsupported', 'not_configured', 'due']
382
+ const counts = Object.fromEntries(DOMAIN_CONTROL_REVIEW_COUNT_KEYS
352
383
  .map((state) => [state, comparisons.filter((item) => item.state === state).length]));
353
384
  const expired = Date.parse(manifest.expiresAt) <= Date.parse(generatedAt);
354
385
  return Object.freeze({
355
386
  schema: DOMAIN_CONTROL_REVIEW_SCHEMA,
356
387
  version: DOMAIN_CONTROL_REVIEW_VERSION,
357
388
  generatedAt,
358
- state: expired ? 'expired' : domains.some((item) => item.state === 'drift') ? 'drift' : domains.some((item) => item.state === 'partial') ? 'partial' : 'aligned',
389
+ state: expired ? 'expired' : aggregateDomainState(domains),
359
390
  manifest: Object.freeze({
360
391
  generatedAt: manifest.generatedAt,
361
392
  expiresAt: manifest.expiresAt,
@@ -370,9 +401,14 @@ export function reviewDomainControlManifest(input, generatedAtValue = new Date()
370
401
  }
371
402
  export function validateDomainControlReviewDocument(value) {
372
403
  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) {
404
+ if (root.schema !== DOMAIN_CONTROL_REVIEW_SCHEMA || !SUPPORTED_DOMAIN_CONTROL_REVIEW_VERSIONS.includes(root.version)) {
374
405
  throw new TypeError(`Domain control review document must use ${DOMAIN_CONTROL_REVIEW_SCHEMA} version ${DOMAIN_CONTROL_REVIEW_VERSION}.`);
375
406
  }
407
+ const legacy = root.version === PUBLIC_DOMAIN_CONTROL_REVIEW_VERSION;
408
+ const countKeys = legacy ? PUBLIC_DOMAIN_CONTROL_REVIEW_COUNT_KEYS : DOMAIN_CONTROL_REVIEW_COUNT_KEYS;
409
+ const comparisonKeys = legacy ? new Set(PUBLIC_DOMAIN_CONTROL_REVIEW_COMPARISON_KEYS) : REVIEW_COMPARISON_KEYS;
410
+ const recordMaximum = legacy ? MAX_CANONICAL_DOMAIN_CONTROL_RECORDS : MAX_DOMAIN_CONTROL_RECORDS;
411
+ const limitations = legacy ? PUBLIC_DOMAIN_CONTROL_REVIEW_LIMITATIONS : DOMAIN_CONTROL_REVIEW_LIMITATIONS;
376
412
  const generatedAt = exactReviewTimestamp(root.generatedAt, 'Domain control review document generatedAt');
377
413
  const manifestInput = exactReviewRecord(root.manifest, REVIEW_MANIFEST_KEYS, 'Domain control review document manifest');
378
414
  const manifestGeneratedAt = exactReviewTimestamp(manifestInput.generatedAt, 'Domain control review document manifest generatedAt');
@@ -390,9 +426,9 @@ export function validateDomainControlReviewDocument(value) {
390
426
  digestSha256: manifestInput.digestSha256,
391
427
  expired,
392
428
  });
393
- const countInput = exactReviewRecord(root.counts, REVIEW_COUNT_KEYS, 'Domain control review document counts');
429
+ const countInput = exactReviewRecord(root.counts, new Set(countKeys), 'Domain control review document counts');
394
430
  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) => [
431
+ const suppliedCounts = Object.fromEntries(countKeys.map((state) => [
396
432
  state,
397
433
  exactReviewInteger(countInput[state], maximumComparisons, `Domain control review document count ${state}`),
398
434
  ]));
@@ -401,8 +437,8 @@ export function validateDomainControlReviewDocument(value) {
401
437
  throw new TypeError('Domain control review document domains must be a bounded non-empty array.');
402
438
  }
403
439
  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]));
440
+ const comparisonStates = new Set(countKeys);
441
+ const calculatedCounts = Object.fromEntries(countKeys.map((state) => [state, 0]));
406
442
  const domains = domainInput.map((domainValue, domainIndex) => {
407
443
  const item = exactReviewRecord(domainValue, REVIEW_DOMAIN_KEYS, `Domain control review document domain ${domainIndex + 1}`);
408
444
  const normalizedDomain = domain(item.domain);
@@ -414,7 +450,7 @@ export function validateDomainControlReviewDocument(value) {
414
450
  throw new TypeError(`Domain control review document domain ${domainIndex + 1} comparisons are incomplete.`);
415
451
  }
416
452
  const comparisons = comparisonsInput.map((comparisonValue, comparisonIndex) => {
417
- const comparison = exactReviewRecord(comparisonValue, REVIEW_COMPARISON_KEYS, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1}`);
453
+ const comparison = exactReviewRecord(comparisonValue, comparisonKeys, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1}`);
418
454
  const expectedField = comparisonFields[comparisonIndex];
419
455
  if (comparison.field !== expectedField
420
456
  || typeof comparison.state !== 'string'
@@ -428,8 +464,8 @@ export function validateDomainControlReviewDocument(value) {
428
464
  : expectedField === 'caa'
429
465
  ? MAX_DOMAIN_CONTROL_CAA_PRESENTATION_LENGTH
430
466
  : 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`);
467
+ const desired = exactReviewStringArray(comparison.desired, recordMaximum, maximumValueCharacters, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1} desired values`);
468
+ const observed = exactReviewStringArray(comparison.observed, recordMaximum, maximumValueCharacters, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1} observed values`);
433
469
  const source = comparison.source === null
434
470
  ? null
435
471
  : exactReviewText(comparison.source, MAX_DOMAIN_CONTROL_REVIEW_SOURCE_LENGTH, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1} source`);
@@ -439,6 +475,41 @@ export function validateDomainControlReviewDocument(value) {
439
475
  if (expectedField === 'renewalReviewAt' && (observed.length || source !== null || observedAt !== null)) {
440
476
  throw new TypeError(`Domain control review document domain ${domainIndex + 1} renewal comparison is inconsistent.`);
441
477
  }
478
+ let expectation;
479
+ if (!legacy) {
480
+ if (expectedField !== 'renewalReviewAt') {
481
+ for (const values of [desired, observed]) {
482
+ const canonical = fieldValues(expectedField, values, recordMaximum);
483
+ if (!canonical || canonical.length !== values.length || canonical.some((value, index) => value !== values[index]))
484
+ throw new TypeError('Domain control review values are not canonical for their field.');
485
+ }
486
+ }
487
+ if (!DOMAIN_CONTROL_RECORD_MODE_OPTIONS.some((mode) => mode.value === comparison.expectation))
488
+ throw new TypeError('Domain control review comparison expectation is invalid.');
489
+ expectation = comparison.expectation;
490
+ if ((expectation === 'expect_records') !== (desired.length > 0)
491
+ || (!DOMAIN_CONTROL_RECORD_LIST_FIELDS.includes(expectedField) && !['unconfigured', 'expect_records'].includes(expectation))
492
+ || (expectation === 'unconfigured' && (state !== 'not_configured' || observed.length || source !== null || observedAt !== null))
493
+ || (state === 'not_configured' && expectation !== 'unconfigured')
494
+ || (state === 'observed' && expectation !== 'observe_only')
495
+ || (['aligned', 'drift', 'due'].includes(state) && ['unconfigured', 'observe_only'].includes(expectation))) {
496
+ throw new TypeError('Domain control review comparison expectation is inconsistent.');
497
+ }
498
+ if (expectedField === 'renewalReviewAt' && expectation === 'expect_records') {
499
+ if (desired.length !== 1 || normalizeExplicitIsoTimestamp(desired[0]) !== desired[0]
500
+ || state !== (Date.parse(desired[0]) <= Date.parse(generatedAt) ? 'due' : 'aligned'))
501
+ throw new TypeError('Domain control renewal review state is inconsistent.');
502
+ }
503
+ else if (state === 'due')
504
+ throw new TypeError('Only renewal review dates can be due.');
505
+ if (expectedField !== 'renewalReviewAt' && ['aligned', 'drift', 'observed'].includes(state)) {
506
+ if (!source || domainControlEvidenceAdmission(observedAt, generatedAt))
507
+ throw new TypeError('Domain control review requires current source timing for a conclusive comparison.');
508
+ const equal = desired.length === observed.length && desired.every((value, index) => value === observed[index]);
509
+ if ((state === 'aligned' && !equal) || (state === 'drift' && equal))
510
+ throw new TypeError('Domain control review comparison result does not match its records.');
511
+ }
512
+ }
442
513
  return Object.freeze({
443
514
  field: expectedField,
444
515
  state,
@@ -447,15 +518,10 @@ export function validateDomainControlReviewDocument(value) {
447
518
  source,
448
519
  observedAt,
449
520
  explanation: exactReviewText(comparison.explanation, MAX_DOMAIN_CONTROL_REVIEW_TEXT_LENGTH, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1} explanation`),
521
+ ...(expectation !== undefined ? { expectation } : {}),
450
522
  });
451
523
  });
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';
524
+ const expectedState = aggregateComparisonState(comparisons, legacy);
459
525
  if (item.state !== expectedState) {
460
526
  throw new TypeError(`Domain control review document domain ${domainIndex + 1} state is inconsistent.`);
461
527
  }
@@ -464,37 +530,31 @@ export function validateDomainControlReviewDocument(value) {
464
530
  if (new Set(domains.map((item) => item.domain)).size !== domains.length) {
465
531
  throw new TypeError('Domain control review document domains must be unique.');
466
532
  }
467
- for (const state of DOMAIN_CONTROL_REVIEW_COUNT_KEYS) {
533
+ for (const state of countKeys) {
468
534
  if (suppliedCounts[state] !== calculatedCounts[state]) {
469
535
  throw new TypeError('Domain control review document counts are inconsistent.');
470
536
  }
471
537
  }
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';
538
+ const expectedState = expired ? 'expired' : aggregateDomainState(domains, legacy);
479
539
  if (root.state !== expectedState)
480
540
  throw new TypeError('Domain control review document state is inconsistent.');
481
541
  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);
542
+ const limitationsInput = boundedDataArray(root.limitations, limitations.length);
483
543
  if (!limitationsInput
484
- || limitationsInput.length !== DOMAIN_CONTROL_REVIEW_LIMITATIONS.length
485
- || limitationsInput.some((item, index) => item !== DOMAIN_CONTROL_REVIEW_LIMITATIONS[index])) {
544
+ || limitationsInput.length !== limitations.length
545
+ || limitationsInput.some((item, index) => item !== limitations[index])) {
486
546
  throw new TypeError('Domain control review document limitations are invalid.');
487
547
  }
488
548
  return Object.freeze({
489
549
  schema: DOMAIN_CONTROL_REVIEW_SCHEMA,
490
- version: DOMAIN_CONTROL_REVIEW_VERSION,
550
+ version: root.version,
491
551
  generatedAt,
492
552
  state: expectedState,
493
553
  manifest,
494
554
  counts: Object.freeze(suppliedCounts),
495
555
  domains: Object.freeze(domains),
496
556
  ignoredObservationCount,
497
- limitations: DOMAIN_CONTROL_REVIEW_LIMITATIONS,
557
+ limitations,
498
558
  });
499
559
  }
500
560
  export function formatDomainControlResult(value) {
@@ -2,7 +2,7 @@
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 { parseDmarcRecords, parseDmarcReportingAuthorization, parseReportingDestination, parseSpfRecords } from "./domain-posture-parsers.mjs";
6
6
  const SPF_LOOKUP_LIMIT = 10;
7
7
  const SPF_VOID_LOOKUP_LIMIT = 2;
8
8
  const SPF_MAX_DEPTH = 5;
@@ -158,12 +158,15 @@ async function expandSpfPolicy(rootDomain, rootQuery, resolveTxt) {
158
158
  };
159
159
  }
160
160
  function reportDestinationDomain(value) {
161
- const raw = String(value || '').trim();
162
- if (!/^mailto:/iu.test(raw))
161
+ if (typeof value !== 'string')
163
162
  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;
163
+ // The obsolete DMARC report-size suffix remains readable, but arbitrary
164
+ // text after an exclamation mark must not repair a malformed destination.
165
+ const uri = value.trim().replace(/![0-9]+[kmgt]?$/iu, '');
166
+ if (/[!,;]/u.test(uri))
167
+ return null;
168
+ const destination = parseReportingDestination(uri);
169
+ return destination?.scheme === 'mailto' ? strictHostname(destination.host) : null;
167
170
  }
168
171
  async function validateDmarcExternalReporting(ownerDomain, dmarcQuery, resolveTxt) {
169
172
  if (dmarcQuery.error)
@@ -200,12 +203,22 @@ async function validateDmarcExternalReporting(ownerDomain, dmarcQuery, resolveTx
200
203
  continue;
201
204
  }
202
205
  const recordName = `${ownerDomain}._report._dmarc.${destinationDomain}`;
206
+ if (recordName.length > 253) {
207
+ results.push({ destination: destinationDomain, reportType: item.reportType, recordName: null, state: 'unavailable', error: 'The reporting authorisation query exceeds the DNS name limit.' });
208
+ continue;
209
+ }
203
210
  let query = cache.get(recordName);
204
211
  if (!query) {
205
212
  query = await resolveTxt(recordName);
206
213
  cache.set(recordName, query);
207
214
  }
208
- const authorized = query.records.some((record) => (String(Array.isArray(record) ? record.join('') : record || '').trim().toUpperCase().startsWith('V=DMARC1')));
215
+ const authorized = query.records.some((record) => {
216
+ const tags = parseDmarcReportingAuthorization(record);
217
+ if (!tags)
218
+ return false;
219
+ // A recipient override cannot redirect authorisation to another host.
220
+ return tags.rua === undefined || tags.rua.split(',').every((destination) => reportDestinationDomain(destination) === destinationDomain);
221
+ });
209
222
  results.push({
210
223
  destination: destinationDomain,
211
224
  reportType: item.reportType,