@slicedearth/whoisleuth-cli 1.47.4 → 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 (235) hide show
  1. package/README.md +74 -177
  2. package/SECURITY.md +6 -3
  3. package/bin/whoisleuth.mjs +2 -1
  4. package/cli/archive-inspect.mjs +28 -10
  5. package/cli/arguments.mjs +204 -174
  6. package/cli/artifact-structure.mjs +837 -163
  7. package/cli/artifact-verify.mjs +90 -61
  8. package/cli/bounded-json.mjs +1 -1
  9. package/cli/bulk-checkpoint.mjs +33 -20
  10. package/cli/bulk.mjs +9 -2
  11. package/cli/case-pack.mjs +180 -576
  12. package/cli/ci-report.mjs +6 -1
  13. package/cli/collection-preflight.mjs +2 -2
  14. package/cli/command-catalogue.mjs +12 -3
  15. package/cli/command-reference.mjs +910 -92
  16. package/cli/completion.mjs +504 -226
  17. package/cli/config-profile.mjs +13 -3
  18. package/cli/discover.mjs +7 -1
  19. package/cli/discovery-observation-snapshot.mjs +24 -28
  20. package/cli/discovery-scan-command-runner.mjs +9 -9
  21. package/cli/discovery-scan.mjs +17 -3
  22. package/cli/discovery-snapshot.mjs +15 -7
  23. package/cli/domain-control-monitor.mjs +203 -30
  24. package/cli/domain-control-observations.mjs +15 -21
  25. package/cli/errors.mjs +8 -3
  26. package/cli/evidence-signing.mjs +10 -20
  27. package/cli/external-observation-mapping.mjs +3 -3
  28. package/cli/formatters/json.mjs +20 -32
  29. package/cli/formatters/terminal.mjs +583 -22
  30. package/cli/input.mjs +7 -2
  31. package/cli/interactive-launcher.mjs +226 -0
  32. package/cli/interchange-report.mjs +19 -19
  33. package/cli/investigation-manifest.mjs +1 -1
  34. package/cli/investigation-plan.mjs +167 -16
  35. package/cli/investigation-run.mjs +9 -1
  36. package/cli/lookalike-calibration.mjs +3 -1
  37. package/cli/lookup-brief.mjs +13 -8
  38. package/cli/lookup-browser.mjs +844 -0
  39. package/cli/lookup-command-runner.mjs +94 -31
  40. package/cli/lookup-diff.mjs +2 -2
  41. package/cli/lookup-plan.mjs +1 -0
  42. package/cli/mail-review.mjs +206 -94
  43. package/cli/manual.mjs +5 -5
  44. package/cli/offline-evidence-review.mjs +18 -7
  45. package/cli/open-asset-model-bridge.mjs +1 -1
  46. package/cli/output-file.mjs +70 -14
  47. package/cli/page-compare.mjs +11 -6
  48. package/cli/progress-events.mjs +2 -1
  49. package/cli/progress.mjs +3 -3
  50. package/cli/registry-cohort.mjs +9 -51
  51. package/cli/registry-doctor.mjs +67 -28
  52. package/cli/retained-artifact-diff.mjs +7 -8
  53. package/cli/risk-calibration.mjs +85 -38
  54. package/cli/runner.mjs +189 -75
  55. package/cli/saved-lookup.mjs +424 -57
  56. package/cli/sharing-review.mjs +6 -1
  57. package/cli/source-reliability.mjs +75 -36
  58. package/cli/terminal-presentation.mjs +43 -18
  59. package/docs/cli-reference.md +125 -1708
  60. package/docs/cli.md +86 -254
  61. package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -145
  62. package/frontend/src/lib/analysis/domain-control-records.js +3 -88
  63. package/lib/availability.mjs +58 -40
  64. package/lib/bounded-contract-normalizers.mjs +2 -4
  65. package/lib/bounded-file.mjs +78 -4
  66. package/lib/bounded-json.mjs +51 -13
  67. package/lib/browser-library-profile.mjs +1 -1
  68. package/lib/classify.mjs +56 -5
  69. package/lib/client-behavior-profile.mjs +1 -1
  70. package/lib/credential-surface-profile.mjs +1 -1
  71. package/lib/cryptographic-assurance.mjs +35 -17
  72. package/lib/ct-query.mjs +3 -3
  73. package/lib/ct-search.mjs +37 -21
  74. package/lib/distributed-operation-budget.mjs +4 -2
  75. package/lib/dns-delegation-health.mjs +9 -6
  76. package/lib/dns-intelligence.mjs +1 -1
  77. package/lib/dnssec-chain-validation.mjs +2 -3
  78. package/lib/dnssec-evidence-validation.mjs +13 -7
  79. package/lib/domain-assurance.mjs +12 -12
  80. package/lib/domain-change-packet.mjs +1 -1
  81. package/lib/domain-control-flight-recorder.mjs +442 -78
  82. package/lib/domain-control-manifest.mjs +314 -59
  83. package/lib/domain-posture-analysis.mjs +5 -2
  84. package/lib/encrypted-dns-contract.mjs +2 -3
  85. package/lib/evidence-export.mjs +518 -36
  86. package/lib/external-intelligence-risk.mjs +26 -10
  87. package/lib/feature-policy.mjs +1 -1
  88. package/lib/homepage-metadata-contract.mjs +290 -0
  89. package/lib/hostname.mjs +1 -1
  90. package/lib/html-signals.mjs +89 -5
  91. package/lib/http-intelligence.mjs +331 -2
  92. package/lib/interchange-fidelity-registry.mjs +28 -26
  93. package/lib/lookup-cache.mjs +6 -4
  94. package/lib/lookup.mjs +25 -2
  95. package/lib/observed-network-context.mjs +1 -1
  96. package/lib/page-fingerprints.mjs +2 -2
  97. package/lib/page-role-profile.mjs +1 -1
  98. package/lib/portable-generator.mjs +8 -0
  99. package/lib/provider-policy-admission.mjs +3 -1
  100. package/lib/rdap-bootstrap.mjs +76 -16
  101. package/lib/rdap-client.mjs +20 -12
  102. package/lib/rdap-normalization.mjs +306 -75
  103. package/lib/rdap-transport.mjs +2 -2
  104. package/lib/rdap-validation.mjs +65 -14
  105. package/lib/rdap.mjs +2 -2
  106. package/lib/registrable-domain.mjs +36 -0
  107. package/lib/registry-insights.mjs +7 -0
  108. package/lib/response-policy.mjs +27 -4
  109. package/lib/risk-calibration-summary.mjs +35 -33
  110. package/lib/risk-scoring.mjs +4 -3
  111. package/lib/safe-fetch.mjs +62 -40
  112. package/lib/security-txt.mjs +1 -1
  113. package/lib/smtp-transport-review.mjs +8 -5
  114. package/lib/sslbl-intelligence.mjs +6 -8
  115. package/lib/static-html-analysis.mjs +301 -4
  116. package/lib/structured-data-identity.mjs +51 -19
  117. package/lib/threat-intelligence-fixtures.mjs +2 -4
  118. package/lib/threat-intelligence-manifests.mjs +2 -7
  119. package/lib/threat-intelligence-runtime.mjs +3 -5
  120. package/lib/threat-intelligence-types.mjs +3 -0
  121. package/lib/threatfox-intelligence.mjs +7 -3
  122. package/lib/tls-intelligence.mjs +4 -4
  123. package/lib/urlhaus-intelligence.mjs +4 -2
  124. package/lib/urlscan-intelligence.mjs +6 -5
  125. package/lib/website-security-posture.mjs +1 -1
  126. package/lib/website-technology.mjs +1 -1
  127. package/lib/whois-authority.mjs +43 -8
  128. package/lib/whois-chain.mjs +25 -3
  129. package/lib/whois-normalization.mjs +3 -1
  130. package/lib/whois-transport.mjs +12 -3
  131. package/lib/zone-intent-review.mjs +1 -1
  132. package/package.json +12 -4
  133. package/{frontend/src/lib/analysis/case-brand-profile-references.js → packages/cases/case-brand-profile-references.mjs} +2 -2
  134. package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +35 -14
  135. package/{frontend/src/lib/analysis/case-investigation-branch-model.js → packages/cases/case-investigation-branch-model.mjs} +10 -12
  136. package/packages/cases/case-migration-model.mjs +476 -0
  137. package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
  138. package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -21
  139. package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
  140. package/packages/cases/case-record-model.mjs +9 -0
  141. package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +72 -29
  142. package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +108 -14
  143. package/packages/cases/case-response-model.mjs +1604 -0
  144. package/packages/cases/case-response-packet.mjs +1312 -0
  145. package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
  146. package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
  147. package/{frontend/src/lib/analysis/comparison-ledger-bulk.js → packages/comparison/comparison-ledger-bulk.mjs} +2 -2
  148. package/{frontend/src/lib/analysis/comparison-ledger-contract.js → packages/comparison/comparison-ledger-contract.mjs} +4 -4
  149. package/{frontend/src/lib/analysis/comparison-ledger-json.js → packages/comparison/comparison-ledger-json.mjs} +1 -0
  150. package/{frontend/src/lib/analysis/comparison-ledger-serialization.js → packages/comparison/comparison-ledger-serialization.mjs} +1 -0
  151. package/packages/comparison/favicon-similarity.mjs +78 -0
  152. package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
  153. package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
  154. package/packages/contracts/analyst-review-state-contract.mjs +61 -0
  155. package/packages/contracts/analyst-review-state.mjs +50 -0
  156. package/packages/contracts/case-portability.mjs +1310 -0
  157. package/packages/contracts/cli-command-catalogue.mjs +2 -0
  158. package/packages/contracts/cli-lookup.mjs +394 -0
  159. package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
  160. package/packages/contracts/domain-control-manifest.mjs +1027 -0
  161. package/packages/contracts/domain-control-monitor.mjs +310 -0
  162. package/packages/contracts/domain-control-review.mjs +828 -0
  163. package/packages/contracts/domain-name.mjs +4 -0
  164. package/packages/contracts/external-observation-interchange.mjs +224 -0
  165. package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
  166. package/packages/contracts/investigation-portability.mjs +331 -0
  167. package/packages/contracts/offline-comparison.mjs +160 -0
  168. package/packages/contracts/risk-calibration.mjs +1288 -0
  169. package/packages/contracts/schema-compatibility.mjs +71 -0
  170. package/packages/contracts/schema-lifecycle.mjs +1702 -0
  171. package/packages/contracts/sslbl-snapshot.mjs +17 -0
  172. package/packages/contracts/workspace-portability.mjs +1347 -0
  173. package/{frontend/src/lib/analysis/artifact-integrity.js → packages/evidence/artifact-integrity.mjs} +13 -11
  174. package/packages/evidence/decision-fact.mjs +735 -0
  175. package/packages/evidence/domain-control-runtime.mjs +547 -0
  176. package/packages/evidence/domain-name.mjs +41 -0
  177. package/packages/evidence/observation.mjs +384 -0
  178. package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +24 -22
  179. package/packages/interchange/retained-text.mjs +8 -0
  180. package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +9 -6
  181. package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +5 -6
  182. package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +4 -6
  183. package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +5 -5
  184. package/packages/investigation/bulk-review-policy.mjs +1 -0
  185. package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +30 -32
  186. package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
  187. package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
  188. package/{frontend/src/lib/analysis/lookup-claim-passport.js → packages/investigation/lookup-claim-passport.mjs} +6 -10
  189. package/{frontend/src/lib/analysis/lookup-claim-readiness.js → packages/investigation/lookup-claim-readiness.mjs} +9 -2
  190. package/packages/investigation/lookup-investigation-brief.mjs +172 -0
  191. package/packages/monitoring/analyst-review-state.mjs +680 -0
  192. package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +70 -47
  193. package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +13 -14
  194. package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +33 -59
  195. package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +8 -5
  196. package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
  197. package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
  198. package/packages/workspace/hostile-input.mjs +172 -0
  199. package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +8 -45
  200. package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +18 -21
  201. package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
  202. package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
  203. package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
  204. package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
  205. package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
  206. package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +13 -13
  207. package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
  208. package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +110 -89
  209. package/third-party-notices.txt +14 -14
  210. package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
  211. package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
  212. package/frontend/src/lib/analysis/case-migration-model.js +0 -305
  213. package/frontend/src/lib/analysis/case-record-model.js +0 -9
  214. package/frontend/src/lib/analysis/case-response-model.js +0 -728
  215. package/frontend/src/lib/analysis/case-response-packet.js +0 -583
  216. package/frontend/src/lib/analysis/lookup-decision-support.js +0 -542
  217. package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
  218. package/frontend/src/lib/analysis/lookup-presentation.js +0 -120
  219. package/frontend/src/lib/analysis/lookup-response.js +0 -2
  220. package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
  221. package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
  222. package/frontend/src/lib/analysis/scoring.js +0 -55
  223. package/frontend/src/lib/analysis/utils.js +0 -255
  224. package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
  225. package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
  226. package/frontend/src/lib/analysis/visualization-matrix.js +0 -131
  227. package/frontend/src/lib/analysis/visualization-models.js +0 -338
  228. package/frontend/src/lib/analysis/visualization-time-series.js +0 -185
  229. package/lib/bounded-json-response.mjs +0 -170
  230. package/lib/lookup-response-contract.mjs +0 -1034
  231. package/lib/observation.mjs +0 -115
  232. /package/{frontend/src/lib/analysis/opaque-reference-id.js → packages/cases/opaque-reference-id.mjs} +0 -0
  233. /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
  234. /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
  235. /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
@@ -1,37 +1,106 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { domainToASCII } from 'node:url';
3
- import { canonicalArtifactJsonV2, SORTED_JSON_V2, } from "../frontend/src/lib/analysis/artifact-integrity.js";
4
- import { buildUnsignedDomainControlPassport, DOMAIN_CONTROL_PASSPORT_INPUT_SCHEMA, DOMAIN_CONTROL_MANIFEST_VERSION, DOMAIN_CONTROL_PASSPORT_SCHEMA, MAX_DOMAIN_CONTROL_PASSPORT_ENTRIES, normalizeDomainControlPassportDocument, } from "../frontend/src/lib/analysis/domain-control-manifest-core.js";
5
- import { canonicalDomainControlRecordList } from "../frontend/src/lib/analysis/domain-control-records.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";
6
7
  import { exactKeys } from "./bounded-contract-normalizers.mjs";
7
- export const DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA = DOMAIN_CONTROL_PASSPORT_INPUT_SCHEMA;
8
- export const DOMAIN_CONTROL_MANIFEST_SCHEMA = DOMAIN_CONTROL_PASSPORT_SCHEMA;
9
- export { DOMAIN_CONTROL_MANIFEST_VERSION };
10
- export const DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA = 'whoisleuth.domain-control-review-input';
11
- export const DOMAIN_CONTROL_REVIEW_SCHEMA = 'whoisleuth.domain-control-review';
12
- export const DOMAIN_CONTROL_REVIEW_VERSION = 1;
13
- export const MAX_DOMAIN_CONTROL_ENTRIES = MAX_DOMAIN_CONTROL_PASSPORT_ENTRIES;
14
- export const MAX_DOMAIN_CONTROL_RECORDS = 32;
15
- const REVIEW_INPUT_KEYS = new Set(['schema', 'version', 'manifest', 'observations']);
16
- const OBSERVATION_KEYS = new Set(['domain', 'fields']);
17
- const OBSERVATION_FIELDS = new Set(['nameservers', 'ds', 'mx', 'caa', 'tlsIssuer', 'tlsSpkiSha256', 'registrarLock']);
18
- const OBSERVATION_FIELD_KEYS = new Set(['state', 'values', 'source', 'observedAt']);
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);
19
21
  function record(value) {
20
22
  return value && typeof value === 'object' && !Array.isArray(value)
21
23
  ? value
22
24
  : null;
23
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
+ }
24
86
  function boundedText(value, maximum = 300) {
25
- 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))
26
90
  return null;
27
91
  return value.replace(/\s+/gu, ' ').trim().slice(0, maximum) || null;
28
92
  }
29
- function timestamp(value) {
30
- const text = boundedText(value, 64);
31
- 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))
32
97
  return null;
33
- const parsed = Date.parse(text);
34
- return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
98
+ const text = value.replace(/\s+/gu, ' ').trim();
99
+ if (!text || text.length > MAX_DOMAIN_CONTROL_TIMESTAMP_LENGTH)
100
+ return null;
101
+ return text
102
+ ? normalizeExplicitIsoTimestamp(text) ?? (legacy ? normalizeLegacyIsoTimestamp(text) : null)
103
+ : null;
35
104
  }
36
105
  function domain(value) {
37
106
  const text = boundedText(value, 253)?.toLowerCase().replace(/\.$/u, '');
@@ -43,59 +112,110 @@ function domain(value) {
43
112
  return ascii;
44
113
  }
45
114
  function hostnameList(value) {
46
- if (!Array.isArray(value))
47
- return [];
48
- return [...new Set(value.slice(0, MAX_DOMAIN_CONTROL_RECORDS * 4)
115
+ return [...new Set(value
49
116
  .map(domain)
50
117
  .filter((item) => item !== null))]
51
118
  .sort()
52
119
  .slice(0, MAX_DOMAIN_CONTROL_RECORDS);
53
120
  }
54
121
  function textList(value, maximum = 300) {
55
- if (!Array.isArray(value))
56
- return [];
57
- return [...new Set(value.slice(0, MAX_DOMAIN_CONTROL_RECORDS * 4)
122
+ return [...new Set(value
58
123
  .map((item) => boundedText(item, maximum)?.toLowerCase() ?? null)
59
124
  .filter((item) => item !== null))]
60
125
  .sort()
61
126
  .slice(0, MAX_DOMAIN_CONTROL_RECORDS);
62
127
  }
63
128
  function spkiFingerprint(value) {
64
- const text = boundedText(value, 64)?.toLowerCase();
129
+ if (typeof value !== 'string'
130
+ || value.length > 64 * DOMAIN_CONTROL_TEXT_INPUT_BOUND_FACTOR
131
+ || /[\u0000-\u001f\u007f]/u.test(value))
132
+ return null;
133
+ const text = value.trim().toLowerCase();
65
134
  return text && /^[a-f0-9]{64}$/u.test(text) ? text : null;
66
135
  }
67
136
  export function buildDomainControlManifest(input, generatedAtValue = new Date().toISOString()) {
68
- const unsigned = buildUnsignedDomainControlPassport(input, generatedAtValue);
69
- return Object.freeze({
137
+ const unsigned = buildUnsignedDomainControlManifest(input, generatedAtValue);
138
+ const manifest = Object.freeze({
70
139
  ...unsigned,
71
140
  integrity: Object.freeze({
72
141
  algorithm: 'SHA-256',
73
- canonicalization: SORTED_JSON_V2,
74
- digestSha256: `sha256:${createHash('sha256').update(canonicalArtifactJsonV2(unsigned)).digest('hex')}`,
142
+ canonicalization: DOMAIN_CONTROL_MANIFEST_CURRENT_CANONICALIZATION,
143
+ digestSha256: `sha256:${createHash('sha256').update(canonicalArtifactJsonFor(unsigned, DOMAIN_CONTROL_MANIFEST_CURRENT_CANONICALIZATION)).digest('hex')}`,
75
144
  }),
76
145
  });
146
+ assertDomainControlManifestByteBudget(manifest);
147
+ return manifest;
77
148
  }
78
149
  export function verifyDomainControlManifest(value) {
79
- const normalized = normalizeDomainControlPassportDocument(value);
150
+ const normalized = normalizeDomainControlManifestDocument(value);
80
151
  if (`sha256:${createHash('sha256').update(normalized.canonicalUnsigned).digest('hex')}` !== normalized.manifest.integrity.digestSha256) {
81
152
  throw new TypeError('Domain control manifest failed its SHA-256 integrity check.');
82
153
  }
83
154
  return normalized.manifest;
84
155
  }
156
+ function boundedDataArray(value, maximum) {
157
+ try {
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;
186
+ }
187
+ catch {
188
+ return null;
189
+ }
190
+ }
85
191
  function fieldValues(field, value) {
86
- if (field === 'nameservers')
87
- return hostnameList(value);
88
- if (field === 'mx' || field === 'caa' || field === 'ds')
89
- return canonicalDomainControlRecordList(Array.isArray(value) ? value : [value], field);
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
+ }
90
204
  if (field === 'tlsSpkiSha256') {
91
- const normalized = Array.isArray(value) ? value.map(spkiFingerprint).filter(Boolean) : [spkiFingerprint(value)].filter(Boolean);
92
- 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);
93
209
  }
94
210
  if (field === 'registrarLock') {
95
- const values = Array.isArray(value) ? value : [value];
96
- 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;
97
214
  }
98
- 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);
99
219
  }
100
220
  function normalizeObservation(value) {
101
221
  const source = record(value);
@@ -106,22 +226,31 @@ function normalizeObservation(value) {
106
226
  exactKeys(source, OBSERVATION_KEYS, 'Domain control observation');
107
227
  exactKeys(rawFields, OBSERVATION_FIELDS, 'Domain control observation fields');
108
228
  const fields = {};
109
- 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;
110
232
  const raw = record(rawFields[field]);
111
233
  if (!raw)
112
- continue;
234
+ return null;
113
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;
114
238
  const state = raw.state;
115
239
  if (state !== 'observed' && state !== 'partial' && state !== 'unavailable' && state !== 'unsupported')
116
240
  return null;
117
- const sourceLabel = boundedText(raw.source, 120);
118
- 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))
119
248
  return null;
120
249
  fields[field] = Object.freeze({
121
250
  state,
122
- values: Object.freeze(fieldValues(field, raw.values)),
251
+ values: Object.freeze(normalizedValues),
123
252
  source: sourceLabel,
124
- observedAt: timestamp(raw.observedAt),
253
+ observedAt,
125
254
  });
126
255
  }
127
256
  return Object.freeze({ domain: normalizedDomain, fields: Object.freeze(fields) });
@@ -187,18 +316,19 @@ function renewalComparison(entry, now) {
187
316
  }
188
317
  export function reviewDomainControlManifest(input, generatedAtValue = new Date().toISOString()) {
189
318
  const source = record(input);
190
- if (!source || source.schema !== DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA || source.version !== 1) {
191
- 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}.`);
192
321
  }
193
322
  exactKeys(source, REVIEW_INPUT_KEYS, 'Domain control review input');
194
323
  const generatedAt = timestamp(generatedAtValue);
195
324
  if (!generatedAt)
196
- 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.');
197
326
  const manifest = verifyDomainControlManifest(source.manifest);
198
- 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) {
199
329
  throw new TypeError('Domain control observations must be a bounded array.');
200
330
  }
201
- const observations = source.observations.map(normalizeObservation);
331
+ const observations = rawObservations.map(normalizeObservation);
202
332
  if (observations.some((item) => item === null))
203
333
  throw new TypeError('Domain control review contains an invalid observation.');
204
334
  const normalizedObservations = observations;
@@ -206,7 +336,7 @@ export function reviewDomainControlManifest(input, generatedAtValue = new Date()
206
336
  throw new TypeError('Domain control observations must use unique domains.');
207
337
  }
208
338
  const observationMap = new Map(normalizedObservations.map((item) => [item.domain, item]));
209
- const fields = ['nameservers', 'ds', 'mx', 'caa', 'tlsIssuer', 'tlsSpkiSha256', 'registrarLock'];
339
+ const fields = DOMAIN_CONTROL_REVIEW_FIELDS;
210
340
  const domains = manifest.entries.map((entry) => {
211
341
  const observation = observationMap.get(entry.domain) ?? null;
212
342
  const comparisons = [...fields.map((field) => compareField(entry, observation, field)), renewalComparison(entry, generatedAt)];
@@ -235,11 +365,136 @@ export function reviewDomainControlManifest(input, generatedAtValue = new Date()
235
365
  counts: Object.freeze(counts),
236
366
  domains: Object.freeze(domains),
237
367
  ignoredObservationCount: normalizedObservations.filter((item) => !manifest.entries.some((entry) => entry.domain === item.domain)).length,
238
- limitations: Object.freeze([
239
- 'This local review compares analyst-authored desired state with separately supplied observations and performs no collection or configuration change.',
240
- 'Only complete observations can produce drift. Partial, unavailable, unsupported, or missing evidence remains inconclusive.',
241
- 'A valid manifest digest or signature establishes file integrity, not the correctness of the desired state or supplied observations.',
242
- ]),
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,
243
498
  });
244
499
  }
245
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
  }
@@ -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