@slicedearth/whoisleuth-cli 2.3.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 (264) hide show
  1. package/NOTICE +6 -0
  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/arguments.mjs +68 -6
  7. package/cli/artifact-validation/case-response.mjs +47 -41
  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 +83 -61
  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-command-runner.mjs +2 -0
  18. package/cli/bulk-output.mjs +29 -3
  19. package/cli/case-command.mjs +195 -0
  20. package/cli/case-pack.mjs +39 -31
  21. package/cli/command-argument-grammar.mjs +4 -0
  22. package/cli/command-reference.mjs +145 -75
  23. package/cli/completion.mjs +298 -79
  24. package/cli/config-profile.mjs +7 -5
  25. package/cli/ct-event-intake.mjs +12 -12
  26. package/cli/discover.mjs +4 -3
  27. package/cli/discovery-observation-snapshot.mjs +44 -16
  28. package/cli/discovery-scan-command-runner.mjs +2 -0
  29. package/cli/discovery-scan.mjs +7 -4
  30. package/cli/discovery-workflow.mjs +1 -1
  31. package/cli/domain-control-monitor.mjs +50 -20
  32. package/cli/domain-control-observations.mjs +149 -102
  33. package/cli/errors.mjs +33 -1
  34. package/cli/evidence-command-runner.mjs +16 -5
  35. package/cli/evidence-signing.mjs +2 -2
  36. package/cli/external-observation-mapping.mjs +7 -8
  37. package/cli/formatters/terminal-lookup.mjs +18 -46
  38. package/cli/formatters/terminal-shared.mjs +5 -3
  39. package/cli/formatters/terminal-technology.mjs +114 -0
  40. package/cli/inline-command-families.mjs +1 -0
  41. package/cli/interactive-launcher.mjs +2 -160
  42. package/cli/interchange-report.mjs +4 -8
  43. package/cli/investigation-artifacts.mjs +176 -0
  44. package/cli/investigation-checkpoint.mjs +10 -0
  45. package/cli/investigation-folder.mjs +114 -0
  46. package/cli/investigation-manifest.mjs +1 -112
  47. package/cli/investigation-package-review.mjs +103 -0
  48. package/cli/investigation-plan.mjs +32 -19
  49. package/cli/investigation-run.mjs +188 -33
  50. package/cli/local-document-checkpoint.mjs +170 -0
  51. package/cli/lookup-browser.mjs +5 -12
  52. package/cli/lookup-command-runner.mjs +17 -6
  53. package/cli/lookup-diff.mjs +37 -3
  54. package/cli/lookup-plan.mjs +11 -5
  55. package/cli/lookup-reconcile.mjs +10 -8
  56. package/cli/mail-review.mjs +66 -14
  57. package/cli/manual.mjs +1 -0
  58. package/cli/network-command-runner.mjs +2 -0
  59. package/cli/output-file.mjs +31 -5
  60. package/cli/registry-doctor.mjs +3 -1
  61. package/cli/registry-support.mjs +1 -1
  62. package/cli/retained-artifact-diff.mjs +2 -2
  63. package/cli/review-command-runner.mjs +45 -0
  64. package/cli/runner.mjs +55 -6
  65. package/cli/sharing-review.mjs +21 -34
  66. package/cli/signer-trust.mjs +104 -0
  67. package/cli/source-reliability.mjs +74 -83
  68. package/cli/terminal-input.mjs +168 -0
  69. package/cli/workflow-command-runner.mjs +68 -13
  70. package/docs/cli-reference.md +76 -30
  71. package/docs/cli.md +289 -25
  72. package/lib/abort.mjs +4 -1
  73. package/lib/availability.mjs +161 -112
  74. package/lib/bounded-file.mjs +3 -1
  75. package/lib/bounded-html-document.mjs +125 -0
  76. package/lib/bounded-json.mjs +43 -3
  77. package/lib/browser-library-profile.mjs +42 -51
  78. package/lib/browser-library-worker.mjs +112 -0
  79. package/lib/client-behavior-profile.mjs +3 -3
  80. package/lib/ct-search.mjs +11 -4
  81. package/lib/dns-delegation-health.mjs +24 -14
  82. package/lib/dns-inheritance-review.mjs +217 -0
  83. package/lib/dns-intelligence.mjs +36 -23
  84. package/lib/dns-mx.mjs +1 -1
  85. package/lib/dnssec-evidence-validation.mjs +11 -3
  86. package/lib/domain-change-packet.mjs +7 -2
  87. package/lib/domain-change-review.mjs +14 -8
  88. package/lib/domain-control-flight-recorder.mjs +219 -118
  89. package/lib/domain-control-manifest.mjs +117 -57
  90. package/lib/domain-posture-analysis.mjs +20 -7
  91. package/lib/domain-posture-parsers.mjs +88 -6
  92. package/lib/domain-posture.mjs +113 -26
  93. package/lib/evidence-export-privacy.mjs +561 -0
  94. package/lib/evidence-export.mjs +11 -592
  95. package/lib/evidence-report.mjs +3 -1
  96. package/lib/favicon.mjs +117 -41
  97. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  98. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  99. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  100. package/lib/hostname.mjs +2 -38
  101. package/lib/html-signals.mjs +82 -85
  102. package/lib/http-evidence-bounds.mjs +2 -1
  103. package/lib/http-intelligence.mjs +39 -26
  104. package/lib/lookup-cache.mjs +34 -11
  105. package/lib/lookup-child-profile-contract.mjs +24 -15
  106. package/lib/lookup-diagnostics.mjs +24 -1
  107. package/lib/lookup-progress.mjs +30 -13
  108. package/lib/lookup-response-contract.mjs +1048 -0
  109. package/lib/lookup-response.mjs +7 -10
  110. package/lib/lookup-source-progress.mjs +17 -14
  111. package/lib/lookup.mjs +48 -27
  112. package/lib/observed-network-context.mjs +43 -27
  113. package/lib/outbound-request-bounds.mjs +4 -1
  114. package/lib/page-fingerprints.mjs +69 -133
  115. package/lib/page-role-profile.mjs +12 -8
  116. package/lib/perceptual-hash.mjs +43 -14
  117. package/lib/rdap-bootstrap.mjs +72 -22
  118. package/lib/rdap-client.mjs +15 -7
  119. package/lib/rdap-normalization.mjs +33 -18
  120. package/lib/rdap-search-workbench.mjs +1 -1
  121. package/lib/rdap-transport.mjs +47 -20
  122. package/lib/rdap-types.mjs +2 -1
  123. package/lib/rdap.mjs +18 -3
  124. package/lib/registrable-domain.mjs +21 -1
  125. package/lib/registrar-standing-contract.mjs +1 -0
  126. package/lib/registry-capabilities.mjs +5 -1
  127. package/lib/registry-capability-catalogue.mjs +2 -1
  128. package/lib/registry-dates.mjs +2 -190
  129. package/lib/registry-insights.mjs +56 -18
  130. package/lib/response-policy.mjs +86 -48
  131. package/lib/risk-calibration-summary.mjs +17 -3
  132. package/lib/rpki-evidence.mjs +8 -5
  133. package/lib/safe-fetch.mjs +54 -47
  134. package/lib/security-txt.mjs +10 -7
  135. package/lib/service-binding-dns.mjs +8 -0
  136. package/lib/smtp-transport-review.mjs +27 -15
  137. package/lib/sslbl-certificates.generated.mjs +136 -135
  138. package/lib/static-html-analysis.mjs +117 -146
  139. package/lib/structured-data-identity.mjs +1 -0
  140. package/lib/technology-markup.mjs +100 -0
  141. package/lib/threat-intelligence-manifests.mjs +11 -139
  142. package/lib/threat-intelligence-runtime.mjs +24 -52
  143. package/lib/threat-intelligence-targets.mjs +6 -34
  144. package/lib/threat-intelligence-types.mjs +39 -0
  145. package/lib/typosquat-generator.mjs +19 -15
  146. package/lib/urlhaus-intelligence.mjs +8 -3
  147. package/lib/urlscan-intelligence.mjs +1 -1
  148. package/lib/website-security-posture.mjs +30 -6
  149. package/lib/website-technology.mjs +111 -71
  150. package/lib/whois-authority.mjs +14 -2
  151. package/lib/whois-chain.mjs +9 -2
  152. package/lib/whois-common-formats.mjs +5 -5
  153. package/lib/whois-contracts.mjs +2 -1
  154. package/lib/whois-dialects.mjs +2 -2
  155. package/lib/whois-parser-support.mjs +8 -1
  156. package/lib/whois-transport.mjs +23 -7
  157. package/package.json +2 -1
  158. package/packages/cases/case-attachment-model.mjs +83 -0
  159. package/packages/cases/case-evidence-model.mjs +156 -62
  160. package/packages/cases/case-export-input.mjs +53 -0
  161. package/packages/cases/case-migration-model.mjs +59 -25
  162. package/packages/cases/case-model.mjs +1 -0
  163. package/packages/cases/case-recheck-model.mjs +91 -0
  164. package/packages/cases/case-record-core.mjs +3 -2
  165. package/packages/cases/case-record-operations.mjs +89 -27
  166. package/packages/cases/case-record-projection.mjs +23 -20
  167. package/packages/cases/case-report.mjs +63 -52
  168. package/packages/cases/case-response-actions.mjs +654 -0
  169. package/packages/cases/case-response-model.mjs +41 -1129
  170. package/packages/cases/case-response-outcomes.mjs +317 -0
  171. package/packages/cases/case-response-packet-render.mjs +153 -0
  172. package/packages/cases/case-response-packet-types.mjs +1 -0
  173. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  174. package/packages/cases/case-response-packet.mjs +38 -587
  175. package/packages/cases/case-response-records.mjs +94 -0
  176. package/packages/cases/case-response-review-inputs.mjs +371 -0
  177. package/packages/cases/case-response-values.mjs +85 -0
  178. package/packages/cases/case-review-package.mjs +45 -0
  179. package/packages/cases/case-selection.mjs +46 -0
  180. package/packages/cases/case-storage-model.mjs +25 -0
  181. package/packages/cases/response-route-freshness.mjs +16 -0
  182. package/packages/comparison/capture-context.mjs +61 -0
  183. package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
  184. package/packages/comparison/page-similarity.mjs +2 -1
  185. package/packages/comparison/relationship-evidence.mjs +85 -8
  186. package/packages/comparison/relationship-provenance.mjs +93 -0
  187. package/packages/contracts/analyst-interchange.mjs +216 -0
  188. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  189. package/packages/contracts/analyst-review-state.mjs +5 -4
  190. package/packages/contracts/case-portability.mjs +279 -116
  191. package/packages/contracts/case-views-contract.mjs +12 -0
  192. package/packages/contracts/case-views.mjs +26 -0
  193. package/packages/contracts/cli-command-semantics.mjs +1 -0
  194. package/packages/contracts/cli-lookup.mjs +0 -1
  195. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  196. package/packages/contracts/domain-control-manifest.mjs +127 -73
  197. package/packages/contracts/domain-control-monitor.mjs +48 -45
  198. package/packages/contracts/domain-control-review.mjs +179 -121
  199. package/packages/contracts/domain-name.mjs +21 -0
  200. package/packages/contracts/external-observation-interchange.mjs +2 -1
  201. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  202. package/packages/contracts/http-url.mjs +2 -0
  203. package/packages/contracts/investigation-package-limits.mjs +24 -0
  204. package/packages/contracts/investigation-portability.mjs +56 -27
  205. package/packages/contracts/investigation-run.mjs +16 -0
  206. package/packages/contracts/ip-address.mjs +34 -0
  207. package/packages/contracts/lookup-evidence.mjs +35 -0
  208. package/packages/contracts/offline-comparison.mjs +1 -2
  209. package/packages/contracts/page-fingerprints.mjs +15 -0
  210. package/packages/contracts/risk-calibration.mjs +0 -1
  211. package/packages/contracts/schema-compatibility.mjs +1 -1
  212. package/packages/contracts/schema-lifecycle.mjs +83 -153
  213. package/packages/contracts/selected-file-limits.mjs +6 -0
  214. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  215. package/packages/contracts/web-capture.mjs +10 -0
  216. package/packages/contracts/workspace-portability.mjs +160 -225
  217. package/packages/evidence/artifact-integrity.mjs +8 -2
  218. package/packages/evidence/artifact-structure.mjs +105 -0
  219. package/packages/evidence/decision-fact.mjs +4 -1
  220. package/packages/evidence/domain-control-runtime.mjs +155 -27
  221. package/packages/evidence/domain-posture-context.mjs +79 -0
  222. package/packages/evidence/image-regions.mjs +56 -0
  223. package/packages/evidence/latest-observations.mjs +28 -0
  224. package/packages/evidence/lookup-target.mjs +104 -0
  225. package/packages/evidence/observation.mjs +16 -1
  226. package/packages/evidence/passphrase-encryption.mjs +44 -0
  227. package/packages/evidence/record-identity.mjs +72 -0
  228. package/packages/evidence/registry-dates.mjs +190 -0
  229. package/packages/evidence/retained-file.mjs +69 -0
  230. package/packages/evidence/review-age.mjs +14 -0
  231. package/packages/interchange/bagit.mjs +342 -0
  232. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  233. package/packages/interchange/crc32.mjs +17 -0
  234. package/packages/interchange/external-findings-import.mjs +89 -49
  235. package/packages/interchange/web-capture-import.mjs +382 -0
  236. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  237. package/packages/investigation/investigation-bagit.mjs +14 -0
  238. package/packages/investigation/investigation-capsule.mjs +33 -5
  239. package/packages/investigation/investigation-manifest.mjs +200 -0
  240. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  241. package/packages/investigation/investigation-package.mjs +200 -0
  242. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  243. package/packages/investigation/lookup-asset-graph.mjs +161 -141
  244. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  245. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  246. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  247. package/packages/monitoring/analyst-review-state.mjs +38 -90
  248. package/packages/workspace/brand-profile-model.mjs +158 -20
  249. package/packages/workspace/bulk-columns.mjs +17 -0
  250. package/packages/workspace/bulk-review-model.mjs +11 -5
  251. package/packages/workspace/bulk-session-model.mjs +95 -20
  252. package/packages/workspace/bulk-sort.mjs +1 -0
  253. package/packages/workspace/case-views.mjs +102 -0
  254. package/packages/workspace/hostile-input.mjs +29 -8
  255. package/packages/workspace/investigation-template-model.mjs +19 -4
  256. package/packages/workspace/page-baseline.mjs +18 -15
  257. package/packages/workspace/relationship-observation-model.mjs +41 -26
  258. package/packages/workspace/watchlist-history.mjs +2 -2
  259. package/packages/workspace/watchlist-store.mjs +8 -2
  260. package/packages/workspace/website-snapshot-model.mjs +70 -30
  261. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  262. package/packages/workspace/workspace-archive.mjs +85 -21
  263. package/third-party-notices.txt +29 -2
  264. 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) {
@@ -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,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,
@@ -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, };