@slicedearth/whoisleuth-cli 1.45.0 → 2.0.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 (268) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +74 -124
  4. package/SECURITY.md +6 -3
  5. package/bin/whoisleuth.mjs +7 -4
  6. package/cli/archive-inspect.mjs +28 -10
  7. package/cli/arguments.mjs +450 -173
  8. package/cli/artifact-structure.mjs +2507 -0
  9. package/cli/artifact-verify.mjs +286 -64
  10. package/cli/bounded-json.mjs +1 -0
  11. package/cli/bulk-checkpoint.mjs +35 -23
  12. package/cli/bulk-command-runner.mjs +2 -5
  13. package/cli/bulk.mjs +9 -2
  14. package/cli/case-pack.mjs +435 -46
  15. package/cli/ci-report.mjs +6 -1
  16. package/cli/collection-preflight.mjs +2 -2
  17. package/cli/command-catalogue.mjs +12 -3
  18. package/cli/command-reference.mjs +948 -106
  19. package/cli/completion.mjs +504 -218
  20. package/cli/config-profile.mjs +13 -3
  21. package/cli/ct-event-intake.mjs +32 -2
  22. package/cli/discover.mjs +7 -1
  23. package/cli/discovery-observation-snapshot.mjs +24 -28
  24. package/cli/discovery-scan-command-runner.mjs +13 -14
  25. package/cli/discovery-scan.mjs +17 -3
  26. package/cli/discovery-snapshot.mjs +15 -7
  27. package/cli/discovery-workflow.mjs +2 -3
  28. package/cli/doctor.mjs +2 -2
  29. package/cli/domain-control-monitor.mjs +215 -36
  30. package/cli/domain-control-observations.mjs +34 -17
  31. package/cli/errors.mjs +8 -3
  32. package/cli/evidence-command-runner.mjs +17 -11
  33. package/cli/evidence-signing.mjs +68 -50
  34. package/cli/export-evidence.mjs +11 -4
  35. package/cli/external-observation-mapping.mjs +3 -3
  36. package/cli/formatters/html.mjs +3 -3
  37. package/cli/formatters/json.mjs +20 -32
  38. package/cli/formatters/terminal.mjs +586 -22
  39. package/cli/input.mjs +62 -0
  40. package/cli/interactive-launcher.mjs +226 -0
  41. package/cli/interchange-report.mjs +226 -0
  42. package/cli/investigation-manifest.mjs +16 -17
  43. package/cli/investigation-plan.mjs +167 -16
  44. package/cli/investigation-run.mjs +80 -24
  45. package/cli/local-mmdb-review.mjs +45 -14
  46. package/cli/lookalike-calibration.mjs +3 -1
  47. package/cli/lookup-brief.mjs +13 -8
  48. package/cli/lookup-browser.mjs +844 -0
  49. package/cli/lookup-command-runner.mjs +96 -33
  50. package/cli/lookup-diff.mjs +11 -2
  51. package/cli/lookup-plan.mjs +1 -0
  52. package/cli/mail-review.mjs +206 -94
  53. package/cli/manual.mjs +5 -5
  54. package/cli/network-command-runner.mjs +78 -0
  55. package/cli/offline-evidence-review.mjs +45 -14
  56. package/cli/open-asset-model-bridge.mjs +1 -1
  57. package/cli/output-file.mjs +70 -14
  58. package/cli/page-compare.mjs +18 -10
  59. package/cli/progress-events.mjs +2 -1
  60. package/cli/progress.mjs +3 -3
  61. package/cli/registry-cohort.mjs +343 -61
  62. package/cli/registry-doctor.mjs +67 -28
  63. package/cli/registry-support.mjs +2 -1
  64. package/cli/retained-artifact-diff.mjs +375 -0
  65. package/cli/risk-calibration.mjs +89 -40
  66. package/cli/runner.mjs +322 -246
  67. package/cli/saved-lookup.mjs +439 -49
  68. package/cli/sharing-review.mjs +18 -7
  69. package/cli/source-reliability.mjs +75 -36
  70. package/cli/terminal-presentation.mjs +43 -18
  71. package/docs/cli-reference.md +125 -1334
  72. package/docs/cli.md +87 -236
  73. package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -0
  74. package/frontend/src/lib/analysis/domain-control-records.js +3 -0
  75. package/lib/application-version.mjs +3 -0
  76. package/lib/availability.mjs +72 -52
  77. package/lib/bounded-contract-normalizers.mjs +2 -4
  78. package/lib/bounded-file.mjs +91 -4
  79. package/lib/bounded-json.mjs +287 -0
  80. package/lib/browser-library-profile.mjs +1 -1
  81. package/lib/certificate-extension-profile.mjs +2 -1
  82. package/lib/classify.mjs +56 -5
  83. package/lib/client-behavior-profile.mjs +1 -1
  84. package/lib/credential-surface-profile.mjs +1 -1
  85. package/lib/cryptographic-assurance.mjs +173 -0
  86. package/lib/ct-query.mjs +3 -3
  87. package/lib/ct-search.mjs +120 -64
  88. package/lib/distributed-operation-budget.mjs +4 -2
  89. package/lib/dns-delegation-health.mjs +9 -6
  90. package/lib/dns-intelligence.mjs +4 -3
  91. package/lib/dns-resolver-selection.mjs +6 -4
  92. package/lib/dnssec-chain-validation.mjs +1052 -0
  93. package/lib/dnssec-evidence-validation.mjs +23 -10
  94. package/lib/domain-assurance.mjs +12 -12
  95. package/lib/domain-change-packet.mjs +7 -5
  96. package/lib/domain-control-flight-recorder.mjs +442 -78
  97. package/lib/domain-control-manifest.mjs +314 -171
  98. package/lib/domain-posture-analysis.mjs +5 -2
  99. package/lib/domain-posture.mjs +7 -4
  100. package/lib/encrypted-dns-contract.mjs +2 -3
  101. package/lib/evidence-export.mjs +1243 -88
  102. package/lib/evidence-report-markdown.mjs +3 -0
  103. package/lib/evidence-report.mjs +5 -0
  104. package/lib/external-intelligence-risk.mjs +26 -10
  105. package/lib/favicon.mjs +8 -5
  106. package/lib/feature-policy.mjs +1 -1
  107. package/lib/homepage-metadata-contract.mjs +290 -0
  108. package/lib/hostname.mjs +1 -1
  109. package/lib/html-signals.mjs +89 -5
  110. package/lib/http-evidence-bounds.mjs +3 -0
  111. package/lib/http-intelligence.mjs +332 -6
  112. package/lib/interchange-fidelity-registry.mjs +124 -0
  113. package/lib/local-geoip-evidence.mjs +2 -1
  114. package/lib/lookup-cache.mjs +6 -4
  115. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  116. package/lib/lookup.mjs +27 -4
  117. package/lib/observed-network-context.mjs +1 -1
  118. package/lib/outbound-identity.mjs +2 -3
  119. package/lib/page-fingerprints.mjs +2 -2
  120. package/lib/page-role-profile.mjs +1 -1
  121. package/lib/perceptual-hash.mjs +12 -4
  122. package/lib/portable-generator.mjs +29 -0
  123. package/lib/provider-policy-admission.mjs +11 -4
  124. package/lib/rdap-bootstrap.mjs +76 -16
  125. package/lib/rdap-client.mjs +20 -12
  126. package/lib/rdap-normalization.mjs +324 -80
  127. package/lib/rdap-registrar.mjs +5 -1
  128. package/lib/rdap-transport.mjs +2 -2
  129. package/lib/rdap-validation.mjs +65 -14
  130. package/lib/rdap.mjs +2 -2
  131. package/lib/registrable-domain.mjs +36 -0
  132. package/lib/registry-capabilities.mjs +3 -0
  133. package/lib/registry-capability-catalogue.mjs +12 -2
  134. package/lib/registry-comparison.mjs +28 -11
  135. package/lib/registry-insights.mjs +7 -0
  136. package/lib/response-policy.mjs +27 -4
  137. package/lib/risk-calibration-summary.mjs +340 -0
  138. package/lib/risk-scoring.mjs +4 -3
  139. package/lib/rpki-evidence.mjs +9 -8
  140. package/lib/safe-fetch.mjs +121 -50
  141. package/lib/security-txt.mjs +27 -4
  142. package/lib/service-binding-dns.mjs +6 -1
  143. package/lib/smtp-transport-review.mjs +787 -0
  144. package/lib/sslbl-certificates.generated.mjs +136 -134
  145. package/lib/sslbl-intelligence.mjs +6 -8
  146. package/lib/static-html-analysis.mjs +301 -4
  147. package/lib/structured-data-identity.mjs +51 -19
  148. package/lib/threat-intelligence-fixtures.mjs +2 -4
  149. package/lib/threat-intelligence-manifests.mjs +2 -7
  150. package/lib/threat-intelligence-runtime.mjs +5 -15
  151. package/lib/threat-intelligence-types.mjs +21 -1
  152. package/lib/threatfox-intelligence.mjs +7 -3
  153. package/lib/tls-intelligence.mjs +15 -9
  154. package/lib/tlsa-evidence.mjs +37 -7
  155. package/lib/urlhaus-intelligence.mjs +4 -2
  156. package/lib/urlscan-intelligence.mjs +6 -5
  157. package/lib/website-security-posture.mjs +1 -1
  158. package/lib/website-technology.mjs +1 -1
  159. package/lib/whois-authority.mjs +43 -8
  160. package/lib/whois-chain.mjs +25 -3
  161. package/lib/whois-normalization.mjs +3 -1
  162. package/lib/whois-transport.mjs +12 -3
  163. package/lib/zone-intent-review.mjs +1 -1
  164. package/package.json +12 -4
  165. package/packages/cases/case-brand-profile-references.mjs +85 -0
  166. package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +43 -14
  167. package/packages/cases/case-investigation-branch-model.mjs +132 -0
  168. package/packages/cases/case-migration-model.mjs +476 -0
  169. package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
  170. package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -19
  171. package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
  172. package/packages/cases/case-record-model.mjs +9 -0
  173. package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +106 -33
  174. package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +143 -16
  175. package/packages/cases/case-response-model.mjs +1604 -0
  176. package/packages/cases/case-response-packet.mjs +1312 -0
  177. package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
  178. package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
  179. package/packages/cases/opaque-reference-id.mjs +9 -0
  180. package/packages/comparison/comparison-ledger-bulk.mjs +366 -0
  181. package/packages/comparison/comparison-ledger-contract.mjs +241 -0
  182. package/packages/comparison/comparison-ledger-json.mjs +80 -0
  183. package/packages/comparison/comparison-ledger-serialization.mjs +52 -0
  184. package/packages/comparison/favicon-similarity.mjs +78 -0
  185. package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
  186. package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
  187. package/packages/contracts/analyst-review-state-contract.mjs +61 -0
  188. package/packages/contracts/analyst-review-state.mjs +50 -0
  189. package/packages/contracts/case-portability.mjs +1310 -0
  190. package/packages/contracts/cli-command-catalogue.mjs +2 -0
  191. package/packages/contracts/cli-lookup.mjs +394 -0
  192. package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
  193. package/packages/contracts/domain-control-manifest.mjs +1027 -0
  194. package/packages/contracts/domain-control-monitor.mjs +310 -0
  195. package/packages/contracts/domain-control-review.mjs +828 -0
  196. package/packages/contracts/domain-name.mjs +4 -0
  197. package/packages/contracts/external-observation-interchange.mjs +224 -0
  198. package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
  199. package/packages/contracts/investigation-portability.mjs +331 -0
  200. package/packages/contracts/offline-comparison.mjs +160 -0
  201. package/packages/contracts/risk-calibration.mjs +1288 -0
  202. package/packages/contracts/schema-compatibility.mjs +71 -0
  203. package/packages/contracts/schema-lifecycle.mjs +1702 -0
  204. package/packages/contracts/sslbl-snapshot.mjs +17 -0
  205. package/packages/contracts/workspace-portability.mjs +1347 -0
  206. package/packages/evidence/artifact-integrity.mjs +65 -0
  207. package/packages/evidence/decision-fact.mjs +735 -0
  208. package/packages/evidence/domain-control-runtime.mjs +547 -0
  209. package/packages/evidence/domain-name.mjs +41 -0
  210. package/packages/evidence/observation.mjs +384 -0
  211. package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +86 -20
  212. package/packages/interchange/retained-text.mjs +8 -0
  213. package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +11 -8
  214. package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +28 -9
  215. package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +67 -18
  216. package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +10 -9
  217. package/packages/investigation/bulk-review-policy.mjs +1 -0
  218. package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +50 -18
  219. package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
  220. package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
  221. package/packages/investigation/lookup-claim-passport.mjs +232 -0
  222. package/packages/investigation/lookup-claim-readiness.mjs +287 -0
  223. package/packages/investigation/lookup-investigation-brief.mjs +172 -0
  224. package/packages/monitoring/analyst-review-state.mjs +680 -0
  225. package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +97 -50
  226. package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +15 -15
  227. package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +179 -39
  228. package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +12 -5
  229. package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
  230. package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
  231. package/packages/workspace/hostile-input.mjs +172 -0
  232. package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +51 -46
  233. package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +15 -15
  234. package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
  235. package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
  236. package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
  237. package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
  238. package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
  239. package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +18 -12
  240. package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
  241. package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +195 -84
  242. package/third-party-notices.txt +112 -5834
  243. package/frontend/src/lib/analysis/artifact-integrity.js +0 -21
  244. package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
  245. package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
  246. package/frontend/src/lib/analysis/case-migration-model.js +0 -253
  247. package/frontend/src/lib/analysis/case-record-model.js +0 -7
  248. package/frontend/src/lib/analysis/case-response-model.js +0 -669
  249. package/frontend/src/lib/analysis/case-response-packet.js +0 -573
  250. package/frontend/src/lib/analysis/lookup-decision-support.js +0 -487
  251. package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
  252. package/frontend/src/lib/analysis/lookup-presentation.js +0 -58
  253. package/frontend/src/lib/analysis/lookup-response.js +0 -2
  254. package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
  255. package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
  256. package/frontend/src/lib/analysis/scoring.js +0 -55
  257. package/frontend/src/lib/analysis/utils.js +0 -232
  258. package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
  259. package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
  260. package/frontend/src/lib/analysis/visualization-matrix.js +0 -88
  261. package/frontend/src/lib/analysis/visualization-models.js +0 -369
  262. package/frontend/src/lib/analysis/visualization-time-series.js +0 -144
  263. package/lib/bounded-json-response.mjs +0 -157
  264. package/lib/lookup-response-contract.mjs +0 -346
  265. package/lib/observation.mjs +0 -114
  266. /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
  267. /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
  268. /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
@@ -1,43 +1,106 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { domainToASCII } from 'node:url';
3
- import { canonicalArtifactJson } from "../frontend/src/lib/analysis/artifact-integrity.js";
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
7
  import { exactKeys } from "./bounded-contract-normalizers.mjs";
5
- export const DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA = 'whoisleuth.domain-control-manifest-input';
6
- export const DOMAIN_CONTROL_MANIFEST_SCHEMA = 'whoisleuth.domain-control-manifest';
7
- export const DOMAIN_CONTROL_MANIFEST_VERSION = 1;
8
- export const DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA = 'whoisleuth.domain-control-review-input';
9
- export const DOMAIN_CONTROL_REVIEW_SCHEMA = 'whoisleuth.domain-control-review';
10
- export const DOMAIN_CONTROL_REVIEW_VERSION = 1;
11
- export const MAX_DOMAIN_CONTROL_ENTRIES = 100;
12
- export const MAX_DOMAIN_CONTROL_RECORDS = 32;
13
- const MANIFEST_KEYS = new Set(['schema', 'version', 'generatedAt', 'expiresAt', 'entries', 'limitations', 'integrity']);
14
- const MANIFEST_ENTRY_KEYS = new Set(['domain', 'nameservers', 'ds', 'mx', 'caa', 'tlsIssuer', 'tlsSpkiSha256', 'registrarLock', 'renewalReviewAt', 'note']);
15
- const MANIFEST_INTEGRITY_KEYS = new Set(['algorithm', 'canonicalization', 'digestSha256']);
16
- const MANIFEST_INPUT_KEYS = new Set(['schema', 'version', 'expiresAt', 'entries']);
17
- const REVIEW_INPUT_KEYS = new Set(['schema', 'version', 'manifest', 'observations']);
18
- const OBSERVATION_KEYS = new Set(['domain', 'fields']);
19
- const OBSERVATION_FIELDS = new Set(['nameservers', 'ds', 'mx', 'caa', 'tlsIssuer', 'tlsSpkiSha256', 'registrarLock']);
20
- const OBSERVATION_FIELD_KEYS = new Set(['state', 'values', 'source', 'observedAt']);
21
- const DOMAIN_CONTROL_LIMITATIONS = Object.freeze([
22
- 'This analyst-authored manifest records intended domain-control state. It does not collect evidence or change registrar, DNS, mail, or certificate configuration.',
23
- 'Empty desired fields are unconfigured rather than claims that a record should be absent.',
24
- ]);
8
+ import { normalizeExplicitIsoTimestamp, normalizeLegacyIsoTimestamp } from "../packages/evidence/observation.mjs";
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
+ export const MAX_DOMAIN_CONTROL_ENTRIES = MAX_DOMAIN_CONTROL_MANIFEST_ENTRIES;
11
+ export const MAX_DOMAIN_CONTROL_RECORDS = MAX_CANONICAL_DOMAIN_CONTROL_RECORDS;
12
+ const REVIEW_INPUT_KEYS = new Set(DOMAIN_CONTROL_REVIEW_INPUT_KEYS);
13
+ const OBSERVATION_KEYS = new Set(DOMAIN_CONTROL_REVIEW_OBSERVATION_KEYS);
14
+ const OBSERVATION_FIELDS = new Set(DOMAIN_CONTROL_REVIEW_FIELDS);
15
+ const OBSERVATION_FIELD_KEYS = new Set(DOMAIN_CONTROL_REVIEW_OBSERVATION_FIELD_KEYS);
16
+ const REVIEW_ROOT_KEYS = new Set(DOMAIN_CONTROL_REVIEW_ROOT_KEYS);
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
+ const REVIEW_DOMAIN_KEYS = new Set(DOMAIN_CONTROL_REVIEW_DOMAIN_KEYS);
20
+ const REVIEW_COMPARISON_KEYS = new Set(DOMAIN_CONTROL_REVIEW_COMPARISON_KEYS);
25
21
  function record(value) {
26
22
  return value && typeof value === 'object' && !Array.isArray(value)
27
23
  ? value
28
24
  : null;
29
25
  }
26
+ function exactReviewRecord(value, keys, label) {
27
+ try {
28
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
29
+ throw new TypeError(`${label} must be an object.`);
30
+ }
31
+ const prototype = Object.getPrototypeOf(value);
32
+ if (prototype !== Object.prototype && prototype !== null) {
33
+ throw new TypeError(`${label} must be an ordinary object.`);
34
+ }
35
+ const ownKeys = Reflect.ownKeys(value);
36
+ if (ownKeys.length !== keys.size
37
+ || ownKeys.some((key) => typeof key !== 'string' || !keys.has(key))) {
38
+ throw new TypeError(`${label} must use its exact registered fields.`);
39
+ }
40
+ const result = {};
41
+ for (const key of keys) {
42
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
43
+ if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) {
44
+ throw new TypeError(`${label}.${key} must be an enumerable data field.`);
45
+ }
46
+ result[key] = descriptor.value;
47
+ }
48
+ return Object.freeze(result);
49
+ }
50
+ catch (cause) {
51
+ if (cause instanceof TypeError && cause.message.startsWith(label))
52
+ throw cause;
53
+ throw new TypeError(`${label} must be an ordinary object.`);
54
+ }
55
+ }
56
+ function exactReviewText(value, maximum, label) {
57
+ const normalized = boundedText(value, maximum);
58
+ if (!normalized || normalized !== value) {
59
+ throw new TypeError(`${label} must be a canonical bounded string.`);
60
+ }
61
+ return normalized;
62
+ }
63
+ function exactReviewTimestamp(value, label) {
64
+ const normalized = timestamp(value);
65
+ if (!normalized || normalized !== value)
66
+ throw new TypeError(`${label} must be a canonical timestamp.`);
67
+ return normalized;
68
+ }
69
+ function exactReviewInteger(value, maximum, label) {
70
+ if (!Number.isSafeInteger(value) || Number(value) < 0 || Number(value) > maximum) {
71
+ throw new TypeError(`${label} must be a bounded non-negative integer.`);
72
+ }
73
+ return Number(value);
74
+ }
75
+ function exactReviewStringArray(value, maximumItems, maximumCharacters, label) {
76
+ const input = boundedDataArray(value, maximumItems);
77
+ if (!input)
78
+ throw new TypeError(`${label} must be a bounded array.`);
79
+ const output = input.map((item, index) => exactReviewText(item, maximumCharacters, `${label}[${index}]`));
80
+ if (new Set(output).size !== output.length
81
+ || output.some((item, index) => index > 0 && output[index - 1] >= item)) {
82
+ throw new TypeError(`${label} must contain unique canonically ordered values.`);
83
+ }
84
+ return Object.freeze(output);
85
+ }
30
86
  function boundedText(value, maximum = 300) {
31
- if (typeof value !== 'string' || value.length > maximum * 4 || /[\u0000-\u001f\u007f]/u.test(value))
87
+ if (typeof value !== 'string'
88
+ || value.length > maximum * DOMAIN_CONTROL_TEXT_INPUT_BOUND_FACTOR
89
+ || /[\u0000-\u001f\u007f]/u.test(value))
32
90
  return null;
33
91
  return value.replace(/\s+/gu, ' ').trim().slice(0, maximum) || null;
34
92
  }
35
- function timestamp(value) {
36
- const text = boundedText(value, 64);
37
- if (!text)
93
+ function timestamp(value, legacy = false) {
94
+ if (typeof value !== 'string'
95
+ || value.length > MAX_DOMAIN_CONTROL_TIMESTAMP_LENGTH * DOMAIN_CONTROL_TEXT_INPUT_BOUND_FACTOR
96
+ || /[\u0000-\u001f\u007f]/u.test(value))
97
+ return null;
98
+ const text = value.replace(/\s+/gu, ' ').trim();
99
+ if (!text || text.length > MAX_DOMAIN_CONTROL_TIMESTAMP_LENGTH)
38
100
  return null;
39
- const parsed = Date.parse(text);
40
- return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
101
+ return text
102
+ ? normalizeExplicitIsoTimestamp(text) ?? (legacy ? normalizeLegacyIsoTimestamp(text) : null)
103
+ : null;
41
104
  }
42
105
  function domain(value) {
43
106
  const text = boundedText(value, 253)?.toLowerCase().replace(/\.$/u, '');
@@ -49,165 +112,110 @@ function domain(value) {
49
112
  return ascii;
50
113
  }
51
114
  function hostnameList(value) {
52
- if (!Array.isArray(value))
53
- return [];
54
- return [...new Set(value.slice(0, MAX_DOMAIN_CONTROL_RECORDS * 4)
115
+ return [...new Set(value
55
116
  .map(domain)
56
117
  .filter((item) => item !== null))]
57
118
  .sort()
58
119
  .slice(0, MAX_DOMAIN_CONTROL_RECORDS);
59
120
  }
60
121
  function textList(value, maximum = 300) {
61
- if (!Array.isArray(value))
62
- return [];
63
- return [...new Set(value.slice(0, MAX_DOMAIN_CONTROL_RECORDS * 4)
122
+ return [...new Set(value
64
123
  .map((item) => boundedText(item, maximum)?.toLowerCase() ?? null)
65
124
  .filter((item) => item !== null))]
66
125
  .sort()
67
126
  .slice(0, MAX_DOMAIN_CONTROL_RECORDS);
68
127
  }
69
128
  function spkiFingerprint(value) {
70
- const text = boundedText(value, 64)?.toLowerCase();
71
- return text && /^[a-f0-9]{64}$/u.test(text) ? text : null;
72
- }
73
- function normalizeEntry(value) {
74
- const source = record(value);
75
- const normalizedDomain = domain(source?.domain);
76
- if (!source || !normalizedDomain)
129
+ if (typeof value !== 'string'
130
+ || value.length > 64 * DOMAIN_CONTROL_TEXT_INPUT_BOUND_FACTOR
131
+ || /[\u0000-\u001f\u007f]/u.test(value))
77
132
  return null;
78
- const registrarLock = source.registrarLock === 'required' || source.registrarLock === 'not_required'
79
- ? source.registrarLock
80
- : null;
81
- return Object.freeze({
82
- domain: normalizedDomain,
83
- nameservers: Object.freeze(hostnameList(source.nameservers)),
84
- ds: Object.freeze(textList(source.ds, 500)),
85
- mx: Object.freeze(hostnameList(source.mx)),
86
- caa: Object.freeze(textList(source.caa, 500)),
87
- tlsIssuer: boundedText(source.tlsIssuer, 300)?.toLowerCase() ?? null,
88
- tlsSpkiSha256: spkiFingerprint(source.tlsSpkiSha256),
89
- registrarLock,
90
- renewalReviewAt: timestamp(source.renewalReviewAt),
91
- note: boundedText(source.note, 500),
92
- });
93
- }
94
- function unsignedManifest(value) {
95
- return value;
96
- }
97
- function integrityDigest(value) {
98
- return `sha256:${createHash('sha256').update(canonicalArtifactJson(value)).digest('hex')}`;
133
+ const text = value.trim().toLowerCase();
134
+ return text && /^[a-f0-9]{64}$/u.test(text) ? text : null;
99
135
  }
100
136
  export function buildDomainControlManifest(input, generatedAtValue = new Date().toISOString()) {
101
- const source = record(input);
102
- if (!source || source.schema !== DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA || source.version !== 1) {
103
- throw new TypeError(`Domain control manifest input must use ${DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA} version 1.`);
104
- }
105
- exactKeys(source, MANIFEST_INPUT_KEYS, 'Domain control manifest input');
106
- const generatedAt = timestamp(generatedAtValue);
107
- const expiresAt = timestamp(source.expiresAt);
108
- if (!generatedAt || !expiresAt || Date.parse(expiresAt) <= Date.parse(generatedAt)) {
109
- throw new TypeError('Domain control manifest expiry must be a valid time after generation.');
110
- }
111
- if (!Array.isArray(source.entries) || source.entries.length < 1 || source.entries.length > MAX_DOMAIN_CONTROL_ENTRIES) {
112
- throw new TypeError(`Domain control manifest input must contain between 1 and ${MAX_DOMAIN_CONTROL_ENTRIES} entries.`);
113
- }
114
- const entries = source.entries.map((entry, index) => {
115
- exactKeys(entry, MANIFEST_ENTRY_KEYS, `Domain control manifest input entry ${index + 1}`);
116
- return normalizeEntry(entry);
117
- });
118
- if (entries.some((entry) => entry === null))
119
- throw new TypeError('Domain control manifest contains an invalid entry.');
120
- const normalizedEntries = entries;
121
- const domains = new Set(normalizedEntries.map((entry) => entry.domain));
122
- if (domains.size !== normalizedEntries.length)
123
- throw new TypeError('Domain control manifest entries must use unique domains.');
124
- const base = Object.freeze({
125
- schema: DOMAIN_CONTROL_MANIFEST_SCHEMA,
126
- version: DOMAIN_CONTROL_MANIFEST_VERSION,
127
- generatedAt,
128
- expiresAt,
129
- entries: Object.freeze([...normalizedEntries].sort((left, right) => left.domain.localeCompare(right.domain))),
130
- limitations: DOMAIN_CONTROL_LIMITATIONS,
131
- });
132
- return Object.freeze({
133
- ...base,
137
+ const unsigned = buildUnsignedDomainControlManifest(input, generatedAtValue);
138
+ const manifest = Object.freeze({
139
+ ...unsigned,
134
140
  integrity: Object.freeze({
135
141
  algorithm: 'SHA-256',
136
- canonicalization: 'sorted-json-v1',
137
- digestSha256: integrityDigest(unsignedManifest(base)),
142
+ canonicalization: DOMAIN_CONTROL_MANIFEST_CURRENT_CANONICALIZATION,
143
+ digestSha256: `sha256:${createHash('sha256').update(canonicalArtifactJsonFor(unsigned, DOMAIN_CONTROL_MANIFEST_CURRENT_CANONICALIZATION)).digest('hex')}`,
138
144
  }),
139
145
  });
146
+ assertDomainControlManifestByteBudget(manifest);
147
+ return manifest;
140
148
  }
141
149
  export function verifyDomainControlManifest(value) {
142
- const source = record(value);
143
- const integrity = record(source?.integrity);
144
- if (!source
145
- || source.schema !== DOMAIN_CONTROL_MANIFEST_SCHEMA
146
- || source.version !== DOMAIN_CONTROL_MANIFEST_VERSION
147
- || !Array.isArray(source.entries)
148
- || source.entries.length < 1
149
- || source.entries.length > MAX_DOMAIN_CONTROL_ENTRIES
150
- || !Array.isArray(source.limitations)
151
- || source.limitations.length !== DOMAIN_CONTROL_LIMITATIONS.length
152
- || source.limitations.some((limitation, index) => limitation !== DOMAIN_CONTROL_LIMITATIONS[index])
153
- || !integrity
154
- || integrity.algorithm !== 'SHA-256'
155
- || integrity.canonicalization !== 'sorted-json-v1'
156
- || typeof integrity.digestSha256 !== 'string'
157
- || !/^sha256:[a-f0-9]{64}$/u.test(integrity.digestSha256)) {
158
- throw new TypeError('Domain control manifest has an unsupported or malformed structure.');
159
- }
160
- exactKeys(source, MANIFEST_KEYS, 'Domain control manifest');
161
- exactKeys(integrity, MANIFEST_INTEGRITY_KEYS, 'Domain control manifest integrity');
162
- for (const [index, entry] of source.entries.entries()) {
163
- exactKeys(entry, MANIFEST_ENTRY_KEYS, `Domain control manifest entry ${index + 1}`);
164
- }
165
- const generatedAt = timestamp(source.generatedAt);
166
- const expiresAt = timestamp(source.expiresAt);
167
- const entries = source.entries.map(normalizeEntry);
168
- if (!generatedAt || !expiresAt || Date.parse(expiresAt) <= Date.parse(generatedAt) || entries.some((entry) => entry === null)) {
169
- throw new TypeError('Domain control manifest has invalid normalised content.');
170
- }
171
- const normalizedEntries = entries;
172
- if (new Set(normalizedEntries.map((entry) => entry.domain)).size !== normalizedEntries.length) {
173
- throw new TypeError('Domain control manifest entries must use unique domains.');
150
+ const normalized = normalizeDomainControlManifestDocument(value);
151
+ if (`sha256:${createHash('sha256').update(normalized.canonicalUnsigned).digest('hex')}` !== normalized.manifest.integrity.digestSha256) {
152
+ throw new TypeError('Domain control manifest failed its SHA-256 integrity check.');
174
153
  }
175
- const { integrity: _integrity, ...unsigned } = source;
176
- const canonical = Object.freeze({
177
- schema: DOMAIN_CONTROL_MANIFEST_SCHEMA,
178
- version: DOMAIN_CONTROL_MANIFEST_VERSION,
179
- generatedAt,
180
- expiresAt,
181
- entries: Object.freeze([...normalizedEntries].sort((left, right) => left.domain.localeCompare(right.domain))),
182
- limitations: DOMAIN_CONTROL_LIMITATIONS,
183
- });
184
- let suppliedCanonical;
154
+ return normalized.manifest;
155
+ }
156
+ function boundedDataArray(value, maximum) {
185
157
  try {
186
- suppliedCanonical = canonicalArtifactJson(unsigned);
158
+ if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype)
159
+ return null;
160
+ if (Object.getOwnPropertyDescriptor(value, Symbol.iterator)
161
+ || Object.getOwnPropertyDescriptor(value, 'toJSON'))
162
+ return null;
163
+ const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length');
164
+ const length = lengthDescriptor && !lengthDescriptor.enumerable && 'value' in lengthDescriptor
165
+ ? lengthDescriptor.value
166
+ : null;
167
+ if (!Number.isSafeInteger(length)
168
+ || Number(length) < 0
169
+ || Number(length) > maximum)
170
+ return null;
171
+ const boundedLength = Number(length);
172
+ const keys = Reflect.ownKeys(value);
173
+ if (keys.length !== boundedLength + 1 || !keys.includes('length'))
174
+ return null;
175
+ const result = [];
176
+ for (let index = 0; index < boundedLength; index += 1) {
177
+ const key = String(index);
178
+ if (!keys.includes(key))
179
+ return null;
180
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
181
+ if (!descriptor || !descriptor.enumerable || !('value' in descriptor))
182
+ return null;
183
+ result.push(descriptor.value);
184
+ }
185
+ return result;
187
186
  }
188
187
  catch {
189
- throw new TypeError('Domain control manifest has invalid normalised content.');
190
- }
191
- if (suppliedCanonical !== canonicalArtifactJson(canonical)) {
192
- throw new TypeError('Domain control manifest must use its canonical normalised content.');
193
- }
194
- if (`sha256:${createHash('sha256').update(suppliedCanonical).digest('hex')}` !== integrity.digestSha256) {
195
- throw new TypeError('Domain control manifest failed its SHA-256 integrity check.');
188
+ return null;
196
189
  }
197
- return source;
198
190
  }
199
191
  function fieldValues(field, value) {
200
- if (field === 'nameservers' || field === 'mx')
201
- return hostnameList(value);
192
+ if (field === 'nameservers') {
193
+ const normalized = value.map(domain);
194
+ return normalized.some((item) => item === null)
195
+ ? null
196
+ : hostnameList(normalized);
197
+ }
198
+ if (field === 'mx' || field === 'caa' || field === 'ds') {
199
+ 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;
203
+ }
202
204
  if (field === 'tlsSpkiSha256') {
203
- const normalized = Array.isArray(value) ? value.map(spkiFingerprint).filter(Boolean) : [spkiFingerprint(value)].filter(Boolean);
204
- return [...new Set(normalized)].sort().slice(0, MAX_DOMAIN_CONTROL_RECORDS);
205
+ 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);
205
209
  }
206
210
  if (field === 'registrarLock') {
207
- const values = Array.isArray(value) ? value : [value];
208
- return [...new Set(values.filter((item) => item === 'required' || item === 'not_required'))];
211
+ return value.every((item) => item === 'required' || item === 'not_required')
212
+ ? [...new Set(value)].sort()
213
+ : null;
209
214
  }
210
- return textList(Array.isArray(value) ? value : [value], 500);
215
+ 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);
211
219
  }
212
220
  function normalizeObservation(value) {
213
221
  const source = record(value);
@@ -218,22 +226,31 @@ function normalizeObservation(value) {
218
226
  exactKeys(source, OBSERVATION_KEYS, 'Domain control observation');
219
227
  exactKeys(rawFields, OBSERVATION_FIELDS, 'Domain control observation fields');
220
228
  const fields = {};
221
- for (const field of ['nameservers', 'ds', 'mx', 'caa', 'tlsIssuer', 'tlsSpkiSha256', 'registrarLock']) {
229
+ for (const field of DOMAIN_CONTROL_REVIEW_FIELDS) {
230
+ if (!Object.hasOwn(rawFields, field))
231
+ continue;
222
232
  const raw = record(rawFields[field]);
223
233
  if (!raw)
224
- continue;
234
+ return null;
225
235
  exactKeys(raw, OBSERVATION_FIELD_KEYS, `Domain control observation field ${field}`);
236
+ if (!DOMAIN_CONTROL_REVIEW_OBSERVATION_FIELD_KEYS.every((key) => Object.hasOwn(raw, key)))
237
+ return null;
226
238
  const state = raw.state;
227
239
  if (state !== 'observed' && state !== 'partial' && state !== 'unavailable' && state !== 'unsupported')
228
240
  return null;
229
- const sourceLabel = boundedText(raw.source, 120);
230
- if (!sourceLabel)
241
+ const sourceLabel = boundedText(raw.source, MAX_DOMAIN_CONTROL_REVIEW_SOURCE_LENGTH);
242
+ const values = boundedDataArray(raw.values, MAX_DOMAIN_CONTROL_INPUT_RECORDS);
243
+ if (!sourceLabel || !values)
244
+ return null;
245
+ const normalizedValues = fieldValues(field, values);
246
+ const observedAt = raw.observedAt === null ? null : timestamp(raw.observedAt, true);
247
+ if (!normalizedValues || (raw.observedAt !== null && !observedAt))
231
248
  return null;
232
249
  fields[field] = Object.freeze({
233
250
  state,
234
- values: Object.freeze(fieldValues(field, raw.values)),
251
+ values: Object.freeze(normalizedValues),
235
252
  source: sourceLabel,
236
- observedAt: timestamp(raw.observedAt),
253
+ observedAt,
237
254
  });
238
255
  }
239
256
  return Object.freeze({ domain: normalizedDomain, fields: Object.freeze(fields) });
@@ -299,18 +316,19 @@ function renewalComparison(entry, now) {
299
316
  }
300
317
  export function reviewDomainControlManifest(input, generatedAtValue = new Date().toISOString()) {
301
318
  const source = record(input);
302
- if (!source || source.schema !== DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA || source.version !== 1) {
303
- throw new TypeError(`Domain control review input must use ${DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA} version 1.`);
319
+ if (!source || source.schema !== DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA || source.version !== DOMAIN_CONTROL_REVIEW_VERSION) {
320
+ throw new TypeError(`Domain control review input must use ${DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA} version ${DOMAIN_CONTROL_REVIEW_VERSION}.`);
304
321
  }
305
322
  exactKeys(source, REVIEW_INPUT_KEYS, 'Domain control review input');
306
323
  const generatedAt = timestamp(generatedAtValue);
307
324
  if (!generatedAt)
308
- throw new TypeError('Domain control review time is invalid.');
325
+ throw new TypeError('Domain control review time must be valid and include an explicit timezone.');
309
326
  const manifest = verifyDomainControlManifest(source.manifest);
310
- if (!Array.isArray(source.observations) || source.observations.length > MAX_DOMAIN_CONTROL_ENTRIES * 2) {
327
+ const rawObservations = boundedDataArray(source.observations, MAX_DOMAIN_CONTROL_REVIEW_OBSERVATIONS);
328
+ if (!rawObservations) {
311
329
  throw new TypeError('Domain control observations must be a bounded array.');
312
330
  }
313
- const observations = source.observations.map(normalizeObservation);
331
+ const observations = rawObservations.map(normalizeObservation);
314
332
  if (observations.some((item) => item === null))
315
333
  throw new TypeError('Domain control review contains an invalid observation.');
316
334
  const normalizedObservations = observations;
@@ -318,7 +336,7 @@ export function reviewDomainControlManifest(input, generatedAtValue = new Date()
318
336
  throw new TypeError('Domain control observations must use unique domains.');
319
337
  }
320
338
  const observationMap = new Map(normalizedObservations.map((item) => [item.domain, item]));
321
- const fields = ['nameservers', 'ds', 'mx', 'caa', 'tlsIssuer', 'tlsSpkiSha256', 'registrarLock'];
339
+ const fields = DOMAIN_CONTROL_REVIEW_FIELDS;
322
340
  const domains = manifest.entries.map((entry) => {
323
341
  const observation = observationMap.get(entry.domain) ?? null;
324
342
  const comparisons = [...fields.map((field) => compareField(entry, observation, field)), renewalComparison(entry, generatedAt)];
@@ -347,11 +365,136 @@ export function reviewDomainControlManifest(input, generatedAtValue = new Date()
347
365
  counts: Object.freeze(counts),
348
366
  domains: Object.freeze(domains),
349
367
  ignoredObservationCount: normalizedObservations.filter((item) => !manifest.entries.some((entry) => entry.domain === item.domain)).length,
350
- limitations: Object.freeze([
351
- 'This local review compares analyst-authored desired state with separately supplied observations and performs no collection or configuration change.',
352
- 'Only complete observations can produce drift. Partial, unavailable, unsupported, or missing evidence remains inconclusive.',
353
- 'A valid manifest digest or signature establishes file integrity, not the correctness of the desired state or supplied observations.',
354
- ]),
368
+ limitations: DOMAIN_CONTROL_REVIEW_LIMITATIONS,
369
+ });
370
+ }
371
+ export function validateDomainControlReviewDocument(value) {
372
+ 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) {
374
+ throw new TypeError(`Domain control review document must use ${DOMAIN_CONTROL_REVIEW_SCHEMA} version ${DOMAIN_CONTROL_REVIEW_VERSION}.`);
375
+ }
376
+ const generatedAt = exactReviewTimestamp(root.generatedAt, 'Domain control review document generatedAt');
377
+ const manifestInput = exactReviewRecord(root.manifest, REVIEW_MANIFEST_KEYS, 'Domain control review document manifest');
378
+ const manifestGeneratedAt = exactReviewTimestamp(manifestInput.generatedAt, 'Domain control review document manifest generatedAt');
379
+ const expiresAt = exactReviewTimestamp(manifestInput.expiresAt, 'Domain control review document manifest expiresAt');
380
+ if (typeof manifestInput.digestSha256 !== 'string' || !/^sha256:[a-f0-9]{64}$/u.test(manifestInput.digestSha256)) {
381
+ throw new TypeError('Domain control review document manifest digest is invalid.');
382
+ }
383
+ const expired = Date.parse(expiresAt) <= Date.parse(generatedAt);
384
+ if (manifestInput.expired !== expired) {
385
+ throw new TypeError('Domain control review document manifest currentness is inconsistent.');
386
+ }
387
+ const manifest = Object.freeze({
388
+ generatedAt: manifestGeneratedAt,
389
+ expiresAt,
390
+ digestSha256: manifestInput.digestSha256,
391
+ expired,
392
+ });
393
+ const countInput = exactReviewRecord(root.counts, REVIEW_COUNT_KEYS, 'Domain control review document counts');
394
+ 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) => [
396
+ state,
397
+ exactReviewInteger(countInput[state], maximumComparisons, `Domain control review document count ${state}`),
398
+ ]));
399
+ const domainInput = boundedDataArray(root.domains, MAX_DOMAIN_CONTROL_MANIFEST_ENTRIES);
400
+ if (!domainInput || domainInput.length < 1) {
401
+ throw new TypeError('Domain control review document domains must be a bounded non-empty array.');
402
+ }
403
+ 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]));
406
+ const domains = domainInput.map((domainValue, domainIndex) => {
407
+ const item = exactReviewRecord(domainValue, REVIEW_DOMAIN_KEYS, `Domain control review document domain ${domainIndex + 1}`);
408
+ const normalizedDomain = domain(item.domain);
409
+ if (!normalizedDomain || normalizedDomain !== item.domain) {
410
+ throw new TypeError(`Domain control review document domain ${domainIndex + 1} is invalid.`);
411
+ }
412
+ const comparisonsInput = boundedDataArray(item.comparisons, comparisonFields.length);
413
+ if (!comparisonsInput || comparisonsInput.length !== comparisonFields.length) {
414
+ throw new TypeError(`Domain control review document domain ${domainIndex + 1} comparisons are incomplete.`);
415
+ }
416
+ const comparisons = comparisonsInput.map((comparisonValue, comparisonIndex) => {
417
+ const comparison = exactReviewRecord(comparisonValue, REVIEW_COMPARISON_KEYS, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1}`);
418
+ const expectedField = comparisonFields[comparisonIndex];
419
+ if (comparison.field !== expectedField
420
+ || typeof comparison.state !== 'string'
421
+ || !comparisonStates.has(comparison.state)) {
422
+ throw new TypeError(`Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1} is unsupported.`);
423
+ }
424
+ const state = comparison.state;
425
+ calculatedCounts[state] += 1;
426
+ const maximumValueCharacters = expectedField === 'ds'
427
+ ? MAX_DOMAIN_CONTROL_DS_PRESENTATION_LENGTH
428
+ : expectedField === 'caa'
429
+ ? MAX_DOMAIN_CONTROL_CAA_PRESENTATION_LENGTH
430
+ : 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`);
433
+ const source = comparison.source === null
434
+ ? null
435
+ : exactReviewText(comparison.source, MAX_DOMAIN_CONTROL_REVIEW_SOURCE_LENGTH, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1} source`);
436
+ const observedAt = comparison.observedAt === null
437
+ ? null
438
+ : exactReviewTimestamp(comparison.observedAt, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1} observedAt`);
439
+ if (expectedField === 'renewalReviewAt' && (observed.length || source !== null || observedAt !== null)) {
440
+ throw new TypeError(`Domain control review document domain ${domainIndex + 1} renewal comparison is inconsistent.`);
441
+ }
442
+ return Object.freeze({
443
+ field: expectedField,
444
+ state,
445
+ desired,
446
+ observed,
447
+ source,
448
+ observedAt,
449
+ explanation: exactReviewText(comparison.explanation, MAX_DOMAIN_CONTROL_REVIEW_TEXT_LENGTH, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1} explanation`),
450
+ });
451
+ });
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';
459
+ if (item.state !== expectedState) {
460
+ throw new TypeError(`Domain control review document domain ${domainIndex + 1} state is inconsistent.`);
461
+ }
462
+ return Object.freeze({ domain: normalizedDomain, state: expectedState, comparisons: Object.freeze(comparisons) });
463
+ });
464
+ if (new Set(domains.map((item) => item.domain)).size !== domains.length) {
465
+ throw new TypeError('Domain control review document domains must be unique.');
466
+ }
467
+ for (const state of DOMAIN_CONTROL_REVIEW_COUNT_KEYS) {
468
+ if (suppliedCounts[state] !== calculatedCounts[state]) {
469
+ throw new TypeError('Domain control review document counts are inconsistent.');
470
+ }
471
+ }
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';
479
+ if (root.state !== expectedState)
480
+ throw new TypeError('Domain control review document state is inconsistent.');
481
+ 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);
483
+ if (!limitationsInput
484
+ || limitationsInput.length !== DOMAIN_CONTROL_REVIEW_LIMITATIONS.length
485
+ || limitationsInput.some((item, index) => item !== DOMAIN_CONTROL_REVIEW_LIMITATIONS[index])) {
486
+ throw new TypeError('Domain control review document limitations are invalid.');
487
+ }
488
+ return Object.freeze({
489
+ schema: DOMAIN_CONTROL_REVIEW_SCHEMA,
490
+ version: DOMAIN_CONTROL_REVIEW_VERSION,
491
+ generatedAt,
492
+ state: expectedState,
493
+ manifest,
494
+ counts: Object.freeze(suppliedCounts),
495
+ domains: Object.freeze(domains),
496
+ ignoredObservationCount,
497
+ limitations: DOMAIN_CONTROL_REVIEW_LIMITATIONS,
355
498
  });
356
499
  }
357
500
  export function formatDomainControlResult(value) {
@@ -10,10 +10,13 @@ const SPF_MAX_BRANCHES = 32;
10
10
  const MAX_DMARC_DESTINATIONS = 10;
11
11
  const MAX_DEPENDENCIES = 64;
12
12
  function strictHostname(value) {
13
- const raw = String(value || '').trim().toLowerCase().replace(/\.+$/u, '');
13
+ const input = String(value || '').trim().replace(/\.+$/u, '');
14
+ if (/[^\x00-\x7f]/u.test(input))
15
+ return null;
16
+ const raw = input.toLowerCase();
14
17
  if (!raw || raw.length > 253 || raw.includes('%'))
15
18
  return null;
16
- if (!raw.split('.').every((label) => /^(?:_?[a-z0-9](?:[a-z0-9_-]{0,61}[a-z0-9])?)$/iu.test(label)))
19
+ if (!raw.split('.').every((label) => /^(?:_?[a-z0-9](?:[a-z0-9_-]{0,61}[a-z0-9])?)$/u.test(label)))
17
20
  return null;
18
21
  return raw.includes('.') ? raw : null;
19
22
  }
@@ -602,15 +602,18 @@ function buildPostureReport(domain, input) {
602
602
  summary[item.status] += 1;
603
603
  return { domain, summary, checks };
604
604
  }
605
- async function fetchMtaStsPolicy(domain) {
605
+ async function fetchMtaStsPolicy(domain, fetcher = safeFetch) {
606
606
  const controller = new AbortController();
607
607
  const timeout = setTimeout(() => controller.abort(), POLICY_TIMEOUT_MS);
608
608
  try {
609
609
  const url = `https://mta-sts.${domain}/.well-known/mta-sts.txt`;
610
- const res = await safeFetch(url, {
610
+ // RFC 8461 section 3.3 requires a direct HTTPS 200 response and forbids
611
+ // following HTTP redirects for policy discovery. A zero-hop safe fetch
612
+ // also keeps the returned body bound to the required policy host/path.
613
+ const res = await fetcher(url, {
611
614
  signal: controller.signal,
612
615
  headers: whoisleuthRequestHeaders({ Accept: 'text/plain' }),
613
- });
616
+ }, 0);
614
617
  if (!res.ok) {
615
618
  // Not reading this body - release it explicitly instead of leaving an
616
619
  // unconsumed stream (and the connection it's tied to) open until
@@ -743,4 +746,4 @@ async function checkDomainPosture(domain, { dkimSelectors = [], retiredDkimSelec
743
746
  externalDependencies,
744
747
  };
745
748
  }
746
- export { normalizeAuditDomain, normalizeDkimSelectors, normalizeMailProtectionProfile, matchesMtaPattern, buildPostureReport, checkDomainPosture, };
749
+ export { normalizeAuditDomain, normalizeDkimSelectors, normalizeMailProtectionProfile, matchesMtaPattern, fetchMtaStsPolicy, buildPostureReport, checkDomainPosture, };
@@ -1,3 +1,4 @@
1
+ import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
1
2
  const ENCRYPTED_DNS_CONTRACT_SCHEMA = 'whoisleuth.encrypted-dns-adapter';
2
3
  const ENCRYPTED_DNS_CONTRACT_VERSION = 1;
3
4
  const DNS_TYPES = new Set(['A', 'AAAA', 'CAA', 'CNAME', 'DNSKEY', 'DS', 'HTTPS', 'MX', 'NS', 'RRSIG', 'SOA', 'SVCB', 'TLSA', 'TXT']);
@@ -28,9 +29,7 @@ function normalizeEncryptedDnsAdapter(value) {
28
29
  const endpoint = httpsUrl(source.endpoint);
29
30
  const termsUrl = httpsUrl(source.termsUrl);
30
31
  const privacyUrl = httpsUrl(source.privacyUrl);
31
- const reviewedAt = typeof source.reviewedAt === 'string' && Number.isFinite(Date.parse(source.reviewedAt))
32
- ? new Date(source.reviewedAt).toISOString()
33
- : null;
32
+ const reviewedAt = normalizeExplicitIsoTimestamp(source.reviewedAt);
34
33
  const method = source.method === 'GET' || source.method === 'POST' ? source.method : null;
35
34
  const representation = source.representation === 'dns-json' || source.representation === 'dns-wire'
36
35
  ? source.representation