@slicedearth/whoisleuth-cli 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +19 -15
  3. package/bin/whoisleuth.mjs +8 -1
  4. package/cli/archive-content-digest.mjs +14 -0
  5. package/cli/archive-inspect.mjs +20 -53
  6. package/cli/arguments.mjs +68 -6
  7. package/cli/artifact-validation/case-response.mjs +47 -41
  8. package/cli/artifact-validation/investigation-capsule.mjs +52 -31
  9. package/cli/artifact-validation/lookup-evidence.mjs +12 -6
  10. package/cli/artifact-validation/signed-review.mjs +83 -61
  11. package/cli/artifact-validation/structure-primitives.mjs +1 -105
  12. package/cli/artifact-verify.mjs +90 -24
  13. package/cli/assurance-command-runner.mjs +39 -4
  14. package/cli/bagit-folder.mjs +57 -0
  15. package/cli/bagit-review.mjs +23 -0
  16. package/cli/bounded-json.mjs +1 -1
  17. package/cli/bulk-command-runner.mjs +2 -0
  18. package/cli/bulk-output.mjs +29 -3
  19. package/cli/case-command.mjs +195 -0
  20. package/cli/case-pack.mjs +39 -31
  21. package/cli/command-argument-grammar.mjs +4 -0
  22. package/cli/command-reference.mjs +145 -75
  23. package/cli/completion.mjs +298 -79
  24. package/cli/config-profile.mjs +7 -5
  25. package/cli/ct-event-intake.mjs +12 -12
  26. package/cli/discover.mjs +4 -3
  27. package/cli/discovery-observation-snapshot.mjs +44 -16
  28. package/cli/discovery-scan-command-runner.mjs +2 -0
  29. package/cli/discovery-scan.mjs +7 -4
  30. package/cli/discovery-workflow.mjs +1 -1
  31. package/cli/domain-control-monitor.mjs +50 -20
  32. package/cli/domain-control-observations.mjs +149 -102
  33. package/cli/errors.mjs +33 -1
  34. package/cli/evidence-command-runner.mjs +16 -5
  35. package/cli/evidence-signing.mjs +2 -2
  36. package/cli/external-observation-mapping.mjs +7 -8
  37. package/cli/formatters/terminal-lookup.mjs +18 -46
  38. package/cli/formatters/terminal-shared.mjs +5 -3
  39. package/cli/formatters/terminal-technology.mjs +114 -0
  40. package/cli/inline-command-families.mjs +1 -0
  41. package/cli/interactive-launcher.mjs +2 -160
  42. package/cli/interchange-report.mjs +4 -8
  43. package/cli/investigation-artifacts.mjs +176 -0
  44. package/cli/investigation-checkpoint.mjs +10 -0
  45. package/cli/investigation-folder.mjs +114 -0
  46. package/cli/investigation-manifest.mjs +1 -112
  47. package/cli/investigation-package-review.mjs +103 -0
  48. package/cli/investigation-plan.mjs +32 -19
  49. package/cli/investigation-run.mjs +188 -33
  50. package/cli/local-document-checkpoint.mjs +170 -0
  51. package/cli/lookup-browser.mjs +5 -12
  52. package/cli/lookup-command-runner.mjs +17 -6
  53. package/cli/lookup-diff.mjs +37 -3
  54. package/cli/lookup-plan.mjs +11 -5
  55. package/cli/lookup-reconcile.mjs +10 -8
  56. package/cli/mail-review.mjs +66 -14
  57. package/cli/manual.mjs +1 -0
  58. package/cli/network-command-runner.mjs +2 -0
  59. package/cli/output-file.mjs +31 -5
  60. package/cli/registry-doctor.mjs +3 -1
  61. package/cli/registry-support.mjs +1 -1
  62. package/cli/retained-artifact-diff.mjs +2 -2
  63. package/cli/review-command-runner.mjs +45 -0
  64. package/cli/runner.mjs +55 -6
  65. package/cli/sharing-review.mjs +21 -34
  66. package/cli/signer-trust.mjs +104 -0
  67. package/cli/source-reliability.mjs +74 -83
  68. package/cli/terminal-input.mjs +168 -0
  69. package/cli/workflow-command-runner.mjs +68 -13
  70. package/docs/cli-reference.md +76 -30
  71. package/docs/cli.md +289 -25
  72. package/lib/abort.mjs +4 -1
  73. package/lib/availability.mjs +161 -112
  74. package/lib/bounded-file.mjs +3 -1
  75. package/lib/bounded-html-document.mjs +125 -0
  76. package/lib/bounded-json.mjs +43 -3
  77. package/lib/browser-library-profile.mjs +42 -51
  78. package/lib/browser-library-worker.mjs +112 -0
  79. package/lib/client-behavior-profile.mjs +3 -3
  80. package/lib/ct-search.mjs +11 -4
  81. package/lib/dns-delegation-health.mjs +24 -14
  82. package/lib/dns-inheritance-review.mjs +217 -0
  83. package/lib/dns-intelligence.mjs +36 -23
  84. package/lib/dns-mx.mjs +1 -1
  85. package/lib/dnssec-evidence-validation.mjs +11 -3
  86. package/lib/domain-change-packet.mjs +7 -2
  87. package/lib/domain-change-review.mjs +14 -8
  88. package/lib/domain-control-flight-recorder.mjs +219 -118
  89. package/lib/domain-control-manifest.mjs +117 -57
  90. package/lib/domain-posture-analysis.mjs +20 -7
  91. package/lib/domain-posture-parsers.mjs +88 -6
  92. package/lib/domain-posture.mjs +113 -26
  93. package/lib/evidence-export-privacy.mjs +561 -0
  94. package/lib/evidence-export.mjs +11 -592
  95. package/lib/evidence-report.mjs +3 -1
  96. package/lib/favicon.mjs +117 -41
  97. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  98. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  99. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  100. package/lib/hostname.mjs +2 -38
  101. package/lib/html-signals.mjs +82 -85
  102. package/lib/http-evidence-bounds.mjs +2 -1
  103. package/lib/http-intelligence.mjs +39 -26
  104. package/lib/lookup-cache.mjs +34 -11
  105. package/lib/lookup-child-profile-contract.mjs +24 -15
  106. package/lib/lookup-diagnostics.mjs +24 -1
  107. package/lib/lookup-progress.mjs +30 -13
  108. package/lib/lookup-response-contract.mjs +1048 -0
  109. package/lib/lookup-response.mjs +7 -10
  110. package/lib/lookup-source-progress.mjs +17 -14
  111. package/lib/lookup.mjs +48 -27
  112. package/lib/observed-network-context.mjs +43 -27
  113. package/lib/outbound-request-bounds.mjs +4 -1
  114. package/lib/page-fingerprints.mjs +69 -133
  115. package/lib/page-role-profile.mjs +12 -8
  116. package/lib/perceptual-hash.mjs +43 -14
  117. package/lib/rdap-bootstrap.mjs +72 -22
  118. package/lib/rdap-client.mjs +15 -7
  119. package/lib/rdap-normalization.mjs +33 -18
  120. package/lib/rdap-search-workbench.mjs +1 -1
  121. package/lib/rdap-transport.mjs +47 -20
  122. package/lib/rdap-types.mjs +2 -1
  123. package/lib/rdap.mjs +18 -3
  124. package/lib/registrable-domain.mjs +21 -1
  125. package/lib/registrar-standing-contract.mjs +1 -0
  126. package/lib/registry-capabilities.mjs +5 -1
  127. package/lib/registry-capability-catalogue.mjs +2 -1
  128. package/lib/registry-dates.mjs +2 -190
  129. package/lib/registry-insights.mjs +56 -18
  130. package/lib/response-policy.mjs +86 -48
  131. package/lib/risk-calibration-summary.mjs +17 -3
  132. package/lib/rpki-evidence.mjs +8 -5
  133. package/lib/safe-fetch.mjs +54 -47
  134. package/lib/security-txt.mjs +10 -7
  135. package/lib/service-binding-dns.mjs +8 -0
  136. package/lib/smtp-transport-review.mjs +27 -15
  137. package/lib/sslbl-certificates.generated.mjs +136 -135
  138. package/lib/static-html-analysis.mjs +117 -146
  139. package/lib/structured-data-identity.mjs +1 -0
  140. package/lib/technology-markup.mjs +100 -0
  141. package/lib/threat-intelligence-manifests.mjs +11 -139
  142. package/lib/threat-intelligence-runtime.mjs +24 -52
  143. package/lib/threat-intelligence-targets.mjs +6 -34
  144. package/lib/threat-intelligence-types.mjs +39 -0
  145. package/lib/typosquat-generator.mjs +19 -15
  146. package/lib/urlhaus-intelligence.mjs +8 -3
  147. package/lib/urlscan-intelligence.mjs +1 -1
  148. package/lib/website-security-posture.mjs +30 -6
  149. package/lib/website-technology.mjs +111 -71
  150. package/lib/whois-authority.mjs +14 -2
  151. package/lib/whois-chain.mjs +9 -2
  152. package/lib/whois-common-formats.mjs +5 -5
  153. package/lib/whois-contracts.mjs +2 -1
  154. package/lib/whois-dialects.mjs +2 -2
  155. package/lib/whois-parser-support.mjs +8 -1
  156. package/lib/whois-transport.mjs +23 -7
  157. package/package.json +2 -1
  158. package/packages/cases/case-attachment-model.mjs +83 -0
  159. package/packages/cases/case-evidence-model.mjs +156 -62
  160. package/packages/cases/case-export-input.mjs +53 -0
  161. package/packages/cases/case-migration-model.mjs +59 -25
  162. package/packages/cases/case-model.mjs +1 -0
  163. package/packages/cases/case-recheck-model.mjs +91 -0
  164. package/packages/cases/case-record-core.mjs +3 -2
  165. package/packages/cases/case-record-operations.mjs +89 -27
  166. package/packages/cases/case-record-projection.mjs +23 -20
  167. package/packages/cases/case-report.mjs +63 -52
  168. package/packages/cases/case-response-actions.mjs +654 -0
  169. package/packages/cases/case-response-model.mjs +41 -1129
  170. package/packages/cases/case-response-outcomes.mjs +317 -0
  171. package/packages/cases/case-response-packet-render.mjs +153 -0
  172. package/packages/cases/case-response-packet-types.mjs +1 -0
  173. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  174. package/packages/cases/case-response-packet.mjs +38 -587
  175. package/packages/cases/case-response-records.mjs +94 -0
  176. package/packages/cases/case-response-review-inputs.mjs +371 -0
  177. package/packages/cases/case-response-values.mjs +85 -0
  178. package/packages/cases/case-review-package.mjs +45 -0
  179. package/packages/cases/case-selection.mjs +46 -0
  180. package/packages/cases/case-storage-model.mjs +25 -0
  181. package/packages/cases/response-route-freshness.mjs +16 -0
  182. package/packages/comparison/capture-context.mjs +61 -0
  183. package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
  184. package/packages/comparison/page-similarity.mjs +2 -1
  185. package/packages/comparison/relationship-evidence.mjs +85 -8
  186. package/packages/comparison/relationship-provenance.mjs +93 -0
  187. package/packages/contracts/analyst-interchange.mjs +216 -0
  188. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  189. package/packages/contracts/analyst-review-state.mjs +5 -4
  190. package/packages/contracts/case-portability.mjs +279 -116
  191. package/packages/contracts/case-views-contract.mjs +12 -0
  192. package/packages/contracts/case-views.mjs +26 -0
  193. package/packages/contracts/cli-command-semantics.mjs +1 -0
  194. package/packages/contracts/cli-lookup.mjs +0 -1
  195. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  196. package/packages/contracts/domain-control-manifest.mjs +127 -73
  197. package/packages/contracts/domain-control-monitor.mjs +48 -45
  198. package/packages/contracts/domain-control-review.mjs +179 -121
  199. package/packages/contracts/domain-name.mjs +21 -0
  200. package/packages/contracts/external-observation-interchange.mjs +2 -1
  201. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  202. package/packages/contracts/http-url.mjs +2 -0
  203. package/packages/contracts/investigation-package-limits.mjs +24 -0
  204. package/packages/contracts/investigation-portability.mjs +56 -27
  205. package/packages/contracts/investigation-run.mjs +16 -0
  206. package/packages/contracts/ip-address.mjs +34 -0
  207. package/packages/contracts/lookup-evidence.mjs +35 -0
  208. package/packages/contracts/offline-comparison.mjs +1 -2
  209. package/packages/contracts/page-fingerprints.mjs +15 -0
  210. package/packages/contracts/risk-calibration.mjs +0 -1
  211. package/packages/contracts/schema-compatibility.mjs +1 -1
  212. package/packages/contracts/schema-lifecycle.mjs +83 -153
  213. package/packages/contracts/selected-file-limits.mjs +6 -0
  214. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  215. package/packages/contracts/web-capture.mjs +10 -0
  216. package/packages/contracts/workspace-portability.mjs +160 -225
  217. package/packages/evidence/artifact-integrity.mjs +8 -2
  218. package/packages/evidence/artifact-structure.mjs +105 -0
  219. package/packages/evidence/decision-fact.mjs +4 -1
  220. package/packages/evidence/domain-control-runtime.mjs +155 -27
  221. package/packages/evidence/domain-posture-context.mjs +79 -0
  222. package/packages/evidence/image-regions.mjs +56 -0
  223. package/packages/evidence/latest-observations.mjs +28 -0
  224. package/packages/evidence/lookup-target.mjs +104 -0
  225. package/packages/evidence/observation.mjs +16 -1
  226. package/packages/evidence/passphrase-encryption.mjs +44 -0
  227. package/packages/evidence/record-identity.mjs +72 -0
  228. package/packages/evidence/registry-dates.mjs +190 -0
  229. package/packages/evidence/retained-file.mjs +69 -0
  230. package/packages/evidence/review-age.mjs +14 -0
  231. package/packages/interchange/bagit.mjs +342 -0
  232. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  233. package/packages/interchange/crc32.mjs +17 -0
  234. package/packages/interchange/external-findings-import.mjs +89 -49
  235. package/packages/interchange/web-capture-import.mjs +382 -0
  236. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  237. package/packages/investigation/investigation-bagit.mjs +14 -0
  238. package/packages/investigation/investigation-capsule.mjs +33 -5
  239. package/packages/investigation/investigation-manifest.mjs +200 -0
  240. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  241. package/packages/investigation/investigation-package.mjs +200 -0
  242. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  243. package/packages/investigation/lookup-asset-graph.mjs +161 -141
  244. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  245. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  246. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  247. package/packages/monitoring/analyst-review-state.mjs +38 -90
  248. package/packages/workspace/brand-profile-model.mjs +158 -20
  249. package/packages/workspace/bulk-columns.mjs +17 -0
  250. package/packages/workspace/bulk-review-model.mjs +11 -5
  251. package/packages/workspace/bulk-session-model.mjs +95 -20
  252. package/packages/workspace/bulk-sort.mjs +1 -0
  253. package/packages/workspace/case-views.mjs +102 -0
  254. package/packages/workspace/hostile-input.mjs +29 -8
  255. package/packages/workspace/investigation-template-model.mjs +19 -4
  256. package/packages/workspace/page-baseline.mjs +18 -15
  257. package/packages/workspace/relationship-observation-model.mjs +41 -26
  258. package/packages/workspace/watchlist-history.mjs +2 -2
  259. package/packages/workspace/watchlist-store.mjs +8 -2
  260. package/packages/workspace/website-snapshot-model.mjs +70 -30
  261. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  262. package/packages/workspace/workspace-archive.mjs +85 -21
  263. package/third-party-notices.txt +29 -2
  264. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -39,7 +39,13 @@ function canonicalArtifactJsonV2(value) {
39
39
  }
40
40
  async function sha256ArtifactDigestV2(value) {
41
41
  const bytes = new TextEncoder().encode(canonicalArtifactJsonV2(value));
42
- const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes);
42
+ return sha256ArtifactBytes(bytes);
43
+ }
44
+ async function sha256ArtifactBytes(bytes) {
45
+ const buffer = bytes.buffer;
46
+ if (!(buffer instanceof ArrayBuffer))
47
+ throw new TypeError('Artefact digests require non-shared bytes.');
48
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', new Uint8Array(buffer, bytes.byteOffset, bytes.byteLength));
43
49
  return `sha256:${[...new Uint8Array(digest)]
44
50
  .map((byte) => byte.toString(16).padStart(2, '0'))
45
51
  .join('')}`;
@@ -62,4 +68,4 @@ function sha256ArtifactDigestFor(value, canonicalization) {
62
68
  ? sha256ArtifactDigest(value)
63
69
  : sha256ArtifactDigestV2(value);
64
70
  }
65
- export { SORTED_JSON_V1, SORTED_JSON_V2, canonicalArtifactJson, canonicalArtifactJsonFor, canonicalArtifactJsonV2, resolveArtifactCanonicalization, sha256ArtifactDigest, sha256ArtifactDigestFor, sha256ArtifactDigestV2, };
71
+ export { SORTED_JSON_V1, SORTED_JSON_V2, canonicalArtifactJson, canonicalArtifactJsonFor, canonicalArtifactJsonV2, resolveArtifactCanonicalization, sha256ArtifactDigest, sha256ArtifactDigestFor, sha256ArtifactDigestV2, sha256ArtifactBytes, };
@@ -0,0 +1,105 @@
1
+ const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
2
+ const DIGEST_RE = /^sha256:[a-f0-9]{64}$/u;
3
+ const DOMAIN_RE = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u;
4
+ export const HEX_DIGEST_RE = /^[a-f0-9]{64}$/u;
5
+ export const SEMANTIC_VERSION_RE = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u;
6
+ export function fail(label) {
7
+ throw new TypeError(`${label} has an unsupported or malformed structure.`);
8
+ }
9
+ export function record(value, label) {
10
+ if (!value || typeof value !== 'object' || Array.isArray(value))
11
+ fail(label);
12
+ return value;
13
+ }
14
+ export function exact(value, keys, label) {
15
+ const source = record(value, label);
16
+ const actual = Object.keys(source);
17
+ if (actual.length !== keys.length || actual.some((key) => !keys.includes(key)))
18
+ fail(label);
19
+ return source;
20
+ }
21
+ export function exactOptional(value, required, optional, label) {
22
+ const source = record(value, label);
23
+ const actual = Object.keys(source);
24
+ if (required.some((key) => !actual.includes(key))
25
+ || actual.some((key) => !required.includes(key) && !optional.includes(key)))
26
+ fail(label);
27
+ return source;
28
+ }
29
+ export function text(value, label, maximum = 2_000, allowEmpty = false) {
30
+ if (typeof value !== 'string' || value.length > maximum || CONTROL_RE.test(value) || (!allowEmpty && !value))
31
+ fail(label);
32
+ return value;
33
+ }
34
+ export function optionalText(value, label, maximum = 2_000) {
35
+ if (value !== null)
36
+ text(value, label, maximum);
37
+ }
38
+ export function iso(value, label, nullable = false) {
39
+ if (nullable && value === null)
40
+ return;
41
+ const candidate = text(value, label, 64);
42
+ const parsed = Date.parse(candidate);
43
+ if (!Number.isFinite(parsed) || new Date(parsed).toISOString() !== candidate)
44
+ fail(label);
45
+ }
46
+ export function integer(value, label, minimum = 0, maximum = Number.MAX_SAFE_INTEGER) {
47
+ if (!Number.isSafeInteger(value) || Number(value) < minimum || Number(value) > maximum)
48
+ fail(label);
49
+ return Number(value);
50
+ }
51
+ export function boolean(value, label) {
52
+ if (typeof value !== 'boolean')
53
+ fail(label);
54
+ }
55
+ export function enumeration(value, allowed, label) {
56
+ if (typeof value !== 'string' || !allowed.includes(value))
57
+ fail(label);
58
+ return value;
59
+ }
60
+ export function array(value, label, maximum, minimum = 0) {
61
+ if (!Array.isArray(value) || value.length < minimum || value.length > maximum)
62
+ fail(label);
63
+ return value;
64
+ }
65
+ export function strings(value, label, maximum, textMaximum = 2_000) {
66
+ const values = array(value, label, maximum);
67
+ for (const item of values)
68
+ text(item, label, textMaximum);
69
+ return values;
70
+ }
71
+ export function digest(value, label) {
72
+ if (typeof value !== 'string' || !DIGEST_RE.test(value))
73
+ fail(label);
74
+ }
75
+ export function sameValues(left, right) {
76
+ return left.length === right.length && left.every((value, index) => value === right[index]);
77
+ }
78
+ export function domain(value, label) {
79
+ if (typeof value !== 'string' || !DOMAIN_RE.test(value))
80
+ fail(label);
81
+ }
82
+ export function absoluteUrl(value, label, protocols) {
83
+ const candidate = text(value, label, 2_048);
84
+ try {
85
+ const parsed = new URL(candidate);
86
+ if (!protocols.includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash)
87
+ fail(label);
88
+ return parsed;
89
+ }
90
+ catch {
91
+ return fail(label);
92
+ }
93
+ }
94
+ export function nullableRecord(value, label) {
95
+ if (value !== null)
96
+ record(value, label);
97
+ }
98
+ export function validateIntegrity(value, label, version, currentVersion) {
99
+ if (version !== currentVersion)
100
+ fail(label);
101
+ const integrity = exact(value, ['algorithm', 'canonicalization', 'digestSha256'], label);
102
+ if (integrity.algorithm !== 'SHA-256' || integrity.canonicalization !== 'sorted-json-v2')
103
+ fail(label);
104
+ digest(integrity.digestSha256, label);
105
+ }
@@ -352,7 +352,10 @@ function boundedDataArray(value, maximum, label) {
352
352
  || Number(length) < 0) {
353
353
  throw new TypeError(`${label} must have a stable non-negative length.`);
354
354
  }
355
- const retainedLength = Math.min(Number(length), maximum);
355
+ if (Number(length) > maximum) {
356
+ throw new TypeError(`${label} exceeds its ${maximum}-item admission bound; no partial fact was retained.`);
357
+ }
358
+ const retainedLength = Number(length);
356
359
  const output = [];
357
360
  for (let index = 0; index < retainedLength; index += 1) {
358
361
  const descriptor = stableOwnDescriptor(value, String(index), label);
@@ -1,7 +1,9 @@
1
+ import { analystReviewAgeAt, ANALYST_REVIEW_STALE_AFTER_DAYS } from "./review-age.mjs";
1
2
  import { canonicalArtifactJsonFor, resolveArtifactCanonicalization, } from "./artifact-integrity.mjs";
2
3
  import { normalizeDomain } from "./domain-name.mjs";
3
4
  import { normalizeExplicitIsoTimestamp, normalizeLegacyIsoTimestamp } from "./observation.mjs";
4
- import { DOMAIN_CONTROL_CAA_RECORD_KEYS, DOMAIN_CONTROL_DIGEST_SHA256_HEX_LENGTH, DOMAIN_CONTROL_DS_RECORD_KEYS, DOMAIN_CONTROL_MANIFEST_ENTRY_KEYS, DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA as DOMAIN_CONTROL_PASSPORT_INPUT_SCHEMA, DOMAIN_CONTROL_MANIFEST_INPUT_KEYS, DOMAIN_CONTROL_MANIFEST_INPUT_VERSION, DOMAIN_CONTROL_MANIFEST_CANONICALIZATION_ROUTES, DOMAIN_CONTROL_MANIFEST_INTEGRITY_KEYS, DOMAIN_CONTROL_MANIFEST_LIMITATIONS, DOMAIN_CONTROL_MANIFEST_ROOT_KEYS, DOMAIN_CONTROL_MANIFEST_SCHEMA as DOMAIN_CONTROL_PASSPORT_SCHEMA, DOMAIN_CONTROL_MANIFEST_VERSION, DOMAIN_CONTROL_MX_RECORD_KEYS, DOMAIN_CONTROL_RECORD_LIST_FIELDS, DOMAIN_CONTROL_SPKI_SHA256_HEX_LENGTH, DOMAIN_CONTROL_TEXT_INPUT_BOUND_FACTOR, MAX_CANONICAL_DOMAIN_CONTROL_RECORDS, MAX_DOMAIN_CONTROL_CAA_FLAGS, MAX_DOMAIN_CONTROL_CAA_PRESENTATION_LENGTH, MAX_DOMAIN_CONTROL_CAA_TAG_LENGTH, MAX_DOMAIN_CONTROL_CAA_VALUE_LENGTH, MAX_DOMAIN_CONTROL_DOMAIN_LENGTH, MAX_DOMAIN_CONTROL_DS_ALGORITHM, MAX_DOMAIN_CONTROL_DS_DIGEST_LENGTH, MAX_DOMAIN_CONTROL_DS_DIGEST_TYPE, MAX_DOMAIN_CONTROL_DS_KEY_TAG, MAX_DOMAIN_CONTROL_DS_PRESENTATION_LENGTH, MAX_DOMAIN_CONTROL_INPUT_RECORDS, MAX_DOMAIN_CONTROL_JSON_DEPTH, MAX_DOMAIN_CONTROL_JSON_VALUES, MAX_DOMAIN_CONTROL_MANIFEST_BYTES, MAX_DOMAIN_CONTROL_MANIFEST_ENTRIES, MAX_DOMAIN_CONTROL_MX_PRIORITY, MAX_DOMAIN_CONTROL_MX_TEXT_LENGTH, MAX_DOMAIN_CONTROL_NAME_INPUT_LENGTH, MAX_DOMAIN_CONTROL_NOTE_LENGTH, MAX_DOMAIN_CONTROL_RECORD_INTEGER_TEXT_LENGTH, MAX_DOMAIN_CONTROL_TEXT_LENGTH, MAX_DOMAIN_CONTROL_TIMESTAMP_LENGTH, MIN_DOMAIN_CONTROL_DS_DIGEST_LENGTH, MIN_DOMAIN_CONTROL_MANIFEST_ENTRIES, MIN_DOMAIN_CONTROL_RECORDS, } from "../contracts/domain-control-manifest.mjs";
5
+ import { MAX_POSTURE_CHECK_RECORDS } from "./domain-posture-context.mjs";
6
+ import { DOMAIN_CONTROL_CAA_RECORD_KEYS, DOMAIN_CONTROL_DIGEST_SHA256_HEX_LENGTH, DOMAIN_CONTROL_DS_RECORD_KEYS, DOMAIN_CONTROL_MANIFEST_ENTRY_KEYS, DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA as DOMAIN_CONTROL_PASSPORT_INPUT_SCHEMA, DOMAIN_CONTROL_MANIFEST_INPUT_KEYS, DOMAIN_CONTROL_MANIFEST_INPUT_VERSION, DOMAIN_CONTROL_MANIFEST_CANONICALIZATION_ROUTES, DOMAIN_CONTROL_MANIFEST_INTEGRITY_KEYS, DOMAIN_CONTROL_MANIFEST_LIMITATIONS, DOMAIN_CONTROL_MANIFEST_ROOT_KEYS, DOMAIN_CONTROL_MANIFEST_SCHEMA as DOMAIN_CONTROL_PASSPORT_SCHEMA, DOMAIN_CONTROL_MANIFEST_VERSION, DOMAIN_CONTROL_MX_RECORD_KEYS, DOMAIN_CONTROL_RECORD_LIST_FIELDS, DOMAIN_CONTROL_RECORD_MODE_OPTIONS, DOMAIN_CONTROL_SPKI_SHA256_HEX_LENGTH, DOMAIN_CONTROL_TEXT_INPUT_BOUND_FACTOR, MAX_CANONICAL_DOMAIN_CONTROL_RECORDS, MAX_CURRENT_DOMAIN_CONTROL_RECORDS, MAX_DOMAIN_CONTROL_CAA_FLAGS, MAX_DOMAIN_CONTROL_CAA_PRESENTATION_LENGTH, MAX_DOMAIN_CONTROL_CAA_TAG_LENGTH, MAX_DOMAIN_CONTROL_CAA_VALUE_LENGTH, MAX_DOMAIN_CONTROL_DOMAIN_LENGTH, MAX_DOMAIN_CONTROL_DS_ALGORITHM, MAX_DOMAIN_CONTROL_DS_DIGEST_LENGTH, MAX_DOMAIN_CONTROL_DS_DIGEST_TYPE, MAX_DOMAIN_CONTROL_DS_KEY_TAG, MAX_DOMAIN_CONTROL_DS_PRESENTATION_LENGTH, MAX_DOMAIN_CONTROL_INPUT_RECORDS, MAX_DOMAIN_CONTROL_JSON_DEPTH, MAX_DOMAIN_CONTROL_JSON_VALUES, MAX_DOMAIN_CONTROL_MANIFEST_BYTES, MAX_DOMAIN_CONTROL_MANIFEST_ENTRIES, MAX_DOMAIN_CONTROL_MX_PRIORITY, MAX_DOMAIN_CONTROL_MX_TEXT_LENGTH, MAX_DOMAIN_CONTROL_NAME_INPUT_LENGTH, MAX_DOMAIN_CONTROL_NOTE_LENGTH, MAX_DOMAIN_CONTROL_RECORD_INTEGER_TEXT_LENGTH, MAX_DOMAIN_CONTROL_TEXT_LENGTH, MAX_DOMAIN_CONTROL_TIMESTAMP_LENGTH, MIN_DOMAIN_CONTROL_DS_DIGEST_LENGTH, MIN_DOMAIN_CONTROL_MANIFEST_ENTRIES, MIN_DOMAIN_CONTROL_RECORDS, PUBLIC_DOMAIN_CONTROL_MANIFEST_ENTRY_KEYS, PUBLIC_DOMAIN_CONTROL_MANIFEST_INPUT_VERSION, PUBLIC_DOMAIN_CONTROL_MANIFEST_LIMITATIONS, PUBLIC_DOMAIN_CONTROL_MANIFEST_VERSION, SUPPORTED_DOMAIN_CONTROL_MANIFEST_INPUT_VERSIONS, } from "../contracts/domain-control-manifest.mjs";
5
7
  const DOMAIN_CONTROL_PASSPORT_VERSION = DOMAIN_CONTROL_MANIFEST_INPUT_VERSION;
6
8
  export { DOMAIN_CONTROL_MANIFEST_VERSION, DOMAIN_CONTROL_PASSPORT_INPUT_SCHEMA, DOMAIN_CONTROL_PASSPORT_SCHEMA, DOMAIN_CONTROL_PASSPORT_VERSION, MAX_CANONICAL_DOMAIN_CONTROL_RECORDS, MAX_DOMAIN_CONTROL_MANIFEST_BYTES, };
7
9
  export const MAX_DOMAIN_CONTROL_PASSPORT_BYTES = MAX_DOMAIN_CONTROL_MANIFEST_BYTES;
@@ -10,6 +12,8 @@ export const DOMAIN_CONTROL_PASSPORT_LIMITATIONS = DOMAIN_CONTROL_MANIFEST_LIMIT
10
12
  const INPUT_KEYS = new Set(DOMAIN_CONTROL_MANIFEST_INPUT_KEYS);
11
13
  const MANIFEST_KEYS = new Set(DOMAIN_CONTROL_MANIFEST_ROOT_KEYS);
12
14
  const ENTRY_KEYS = new Set(DOMAIN_CONTROL_MANIFEST_ENTRY_KEYS);
15
+ const PUBLIC_ENTRY_KEYS = new Set(PUBLIC_DOMAIN_CONTROL_MANIFEST_ENTRY_KEYS);
16
+ const RECORD_MODE_KEYS = new Set(DOMAIN_CONTROL_RECORD_LIST_FIELDS);
13
17
  const INTEGRITY_KEYS = new Set(DOMAIN_CONTROL_MANIFEST_INTEGRITY_KEYS);
14
18
  const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
15
19
  const RECORD_LIST_FIELDS = DOMAIN_CONTROL_RECORD_LIST_FIELDS;
@@ -82,6 +86,7 @@ function boundedDataArray(value, label, minimum, maximum, maximumMessage) {
82
86
  return result;
83
87
  }
84
88
  const MX_RECORD_KEYS = new Set(DOMAIN_CONTROL_MX_RECORD_KEYS);
89
+ const RESOLVER_MX_RECORD_KEYS = new Set([...MX_RECORD_KEYS, 'type']);
85
90
  const CAA_RECORD_KEYS = new Set(DOMAIN_CONTROL_CAA_RECORD_KEYS);
86
91
  const DS_RECORD_KEYS = new Set(DOMAIN_CONTROL_DS_RECORD_KEYS);
87
92
  const DS_DIGEST_PATTERN = new RegExp(`^[a-f0-9]{${MIN_DOMAIN_CONTROL_DS_DIGEST_LENGTH},${MAX_DOMAIN_CONTROL_DS_DIGEST_LENGTH}}$`, 'u');
@@ -155,21 +160,48 @@ function canonicalIntegerAlias(source, keys, maximum, label, fallback = null) {
155
160
  }
156
161
  return normalised[0] ?? null;
157
162
  }
158
- export function canonicalMxRecord(value) {
159
- const item = structuredRecord(value, MX_RECORD_KEYS, 'Domain control MX record');
163
+ function mxRecordParts(value, resolverRecord = false) {
164
+ const item = structuredRecord(value, resolverRecord ? RESOLVER_MX_RECORD_KEYS : MX_RECORD_KEYS, 'Domain control MX record');
160
165
  if (item) {
161
- const host = canonicalStringAlias(item, ['exchange', 'host', 'value'], exchange, 'Domain control MX host');
166
+ if (resolverRecord && Object.hasOwn(item, 'type') && item.type !== 'MX')
167
+ return null;
168
+ const host = canonicalStringAlias(item, ['exchange', 'host', 'value'], (value) => value === '' ? '.' : exchange(value), 'Domain control MX host');
162
169
  const priority = canonicalIntegerAlias(item, ['priority', 'preference'], MAX_DOMAIN_CONTROL_MX_PRIORITY, 'Domain control MX priority');
163
- return host && priority !== null ? `${priority} ${host}` : '';
170
+ return host && priority !== null ? { priority, exchange: host } : null;
164
171
  }
165
172
  const candidate = recordText(value, MAX_DOMAIN_CONTROL_MX_TEXT_LENGTH).toLowerCase();
166
173
  const match = /^(\d{1,5})\s+(.+)$/u.exec(candidate);
167
174
  if (match) {
168
175
  const priority = recordInteger(match[1], MAX_DOMAIN_CONTROL_MX_PRIORITY);
169
176
  const host = exchange(match[2]);
170
- return priority !== null && host ? `${priority} ${host}` : '';
177
+ return priority !== null && host ? { priority, exchange: host } : null;
178
+ }
179
+ const host = exchange(candidate);
180
+ return host ? { priority: null, exchange: host } : null;
181
+ }
182
+ export function canonicalMxRecord(value) {
183
+ const parts = mxRecordParts(value);
184
+ return !parts ? '' : parts.priority === null ? parts.exchange : `${parts.priority} ${parts.exchange}`;
185
+ }
186
+ function completeMxRecord(value, resolverRecord) {
187
+ try {
188
+ const parts = mxRecordParts(value, resolverRecord);
189
+ return parts && parts.priority !== null
190
+ ? { priority: parts.priority, exchange: parts.exchange } : null;
191
+ }
192
+ catch (error) {
193
+ if (!(error instanceof TypeError))
194
+ throw error;
195
+ return null;
171
196
  }
172
- return exchange(candidate);
197
+ }
198
+ /** Complete canonical records require a preference and retain the root exchange as '.'. */
199
+ export function normalizeMxRecord(value) {
200
+ return completeMxRecord(value, false);
201
+ }
202
+ /** Accept the native resolver's optional record type without broadening import shapes. */
203
+ export function normalizeResolverMxRecord(value) {
204
+ return completeMxRecord(value, true);
173
205
  }
174
206
  function caaValue(value, tag) {
175
207
  const candidate = recordText(value, MAX_DOMAIN_CONTROL_CAA_VALUE_LENGTH)
@@ -265,6 +297,88 @@ export function canonicalDomainControlRecordList(value, kind) {
265
297
  .sort(compareCodeUnits)
266
298
  .slice(0, MAX_CANONICAL_DOMAIN_CONTROL_RECORDS);
267
299
  }
300
+ /** Complete field values for posture review; an invalid value never becomes an absent record. */
301
+ export function canonicalPostureRecords(checkId, values) {
302
+ if (values.length > MAX_POSTURE_CHECK_RECORDS)
303
+ return null;
304
+ const normalise = checkId === 'nameservers' ? domainControlName
305
+ : checkId === 'mx' ? canonicalMxRecord : checkId === 'caa' ? canonicalCaaRecord
306
+ : checkId === 'registration_lock'
307
+ ? (value) => typeof value === 'string' && value.length <= 120 && !/[\u0000-\u001f\u007f]/u.test(value)
308
+ ? value.trim().toLowerCase().replace(/[\s_-]+/gu, '') : ''
309
+ : null;
310
+ if (!normalise)
311
+ return null;
312
+ try {
313
+ const records = values.map(normalise);
314
+ if (records.some((value) => !value))
315
+ return null;
316
+ return [...new Set(records)].sort(compareCodeUnits);
317
+ }
318
+ catch {
319
+ return null;
320
+ }
321
+ }
322
+ /** Complete current record inputs are rejected, not shortened, when invalid or over capacity. */
323
+ export function canonicalDomainControlRecords(value, field) {
324
+ if (value === undefined)
325
+ return [];
326
+ const rows = boundedDataArray(value, `Domain control ${field}`, 0, MAX_DOMAIN_CONTROL_INPUT_RECORDS);
327
+ const normalise = field === 'nameservers' ? domainControlName : field === 'ds' ? canonicalDsRecord
328
+ : field === 'mx' ? canonicalMxRecord : canonicalCaaRecord;
329
+ const values = rows.map(normalise);
330
+ if (values.some((item) => !item))
331
+ throw new TypeError(`Domain control ${field} contains an invalid record.`);
332
+ const records = [...new Set(values)].sort(compareCodeUnits);
333
+ if (records.length > MAX_CURRENT_DOMAIN_CONTROL_RECORDS)
334
+ throw new TypeError(`Domain control ${field} exceeds ${MAX_CURRENT_DOMAIN_CONTROL_RECORDS} records.`);
335
+ return records;
336
+ }
337
+ export function domainControlRecordMode(entry, field) {
338
+ return entry.recordModes?.[field] ?? (entry[field].length ? 'expect_records' : 'unconfigured');
339
+ }
340
+ export function normalizeDomainControlRecordModes(value, records) {
341
+ const source = normalizeDeclaredDomainControlRecordModes(value);
342
+ return Object.freeze(Object.fromEntries(DOMAIN_CONTROL_RECORD_LIST_FIELDS.map((field) => {
343
+ const mode = Object.hasOwn(source, field) ? source[field] : (records[field].length ? 'expect_records' : 'unconfigured');
344
+ if ((mode === 'expect_records') !== (records[field].length > 0)) {
345
+ throw new TypeError(`Domain control ${field} requires records only in the specified-records mode.`);
346
+ }
347
+ return [field, mode];
348
+ })));
349
+ }
350
+ export function normalizeDeclaredDomainControlRecordModes(value) {
351
+ const source = value === undefined ? {} : boundedDataRecord(value, RECORD_MODE_KEYS, 'Domain control record modes');
352
+ if (!source)
353
+ throw new TypeError('Domain control record modes must be an ordinary object.');
354
+ const modes = {};
355
+ for (const field of DOMAIN_CONTROL_RECORD_LIST_FIELDS) {
356
+ if (!Object.hasOwn(source, field))
357
+ continue;
358
+ const mode = source[field];
359
+ if (!DOMAIN_CONTROL_RECORD_MODE_OPTIONS.some((option) => option.value === mode))
360
+ throw new TypeError(`Domain control ${field} has an unsupported record mode.`);
361
+ modes[field] = mode;
362
+ }
363
+ return Object.freeze(modes);
364
+ }
365
+ export function normalizeDomainControlRecordSettings(value) {
366
+ const records = {
367
+ nameservers: canonicalDomainControlRecords(value.nameservers, 'nameservers'),
368
+ ds: canonicalDomainControlRecords(value.ds, 'ds'),
369
+ mx: canonicalDomainControlRecords(value.mx, 'mx'),
370
+ caa: canonicalDomainControlRecords(value.caa, 'caa'),
371
+ };
372
+ return { ...records, recordModes: normalizeDomainControlRecordModes(value.recordModes, records) };
373
+ }
374
+ export function domainControlEvidenceAdmission(observedAt, now) {
375
+ const age = analystReviewAgeAt(observedAt, now);
376
+ if (age === 'unknown')
377
+ return 'A valid source observation time at or before the review is required.';
378
+ if (age === 'stale')
379
+ return `The source observation is more than ${ANALYST_REVIEW_STALE_AFTER_DAYS} days old; collect a current observation before comparing expected settings.`;
380
+ return null;
381
+ }
268
382
  class DomainControlJsonBudgetError extends Error {
269
383
  }
270
384
  class DomainControlJsonStructureError extends Error {
@@ -397,10 +511,10 @@ export function domainControlPassportSerialisedBytes(value, label = 'Domain cont
397
511
  export function assertDomainControlPassportByteBudget(value, label = 'Domain control manifest') {
398
512
  domainControlPassportSerialisedBytes(value, label);
399
513
  }
400
- function boundedDomainControlPassportEntries(value, label) {
514
+ function boundedDomainControlPassportEntries(value, label, legacy = false) {
401
515
  const entries = boundedDataArray(value, label, MIN_DOMAIN_CONTROL_MANIFEST_ENTRIES, MAX_DOMAIN_CONTROL_MANIFEST_ENTRIES);
402
516
  return entries.map((entry) => {
403
- const source = boundedDataRecord(entry, ENTRY_KEYS, `${label} entry`);
517
+ const source = boundedDataRecord(entry, legacy ? PUBLIC_ENTRY_KEYS : ENTRY_KEYS, `${label} entry`);
404
518
  if (!source)
405
519
  return entry;
406
520
  for (const field of RECORD_LIST_FIELDS) {
@@ -408,6 +522,11 @@ function boundedDomainControlPassportEntries(value, label) {
408
522
  continue;
409
523
  source[field] = boundedDataArray(source[field], `${label} entry ${field}`, MIN_DOMAIN_CONTROL_RECORDS, MAX_DOMAIN_CONTROL_INPUT_RECORDS, `${label} entry ${field} exceeds ${MAX_DOMAIN_CONTROL_INPUT_RECORDS} input records.`);
410
524
  }
525
+ if (Object.hasOwn(source, 'recordModes')) {
526
+ source.recordModes = boundedDataRecord(source.recordModes, RECORD_MODE_KEYS, `${label} entry record modes`);
527
+ if (!source.recordModes)
528
+ throw new TypeError(`${label} entry record modes must be an ordinary object.`);
529
+ }
411
530
  return source;
412
531
  });
413
532
  }
@@ -442,7 +561,7 @@ function digest(value) {
442
561
  const candidate = value.trim().toLowerCase();
443
562
  return SPKI_DIGEST_PATTERN.test(candidate) ? candidate : null;
444
563
  }
445
- function normalizeEntry(value) {
564
+ function normalizeEntry(value, legacyRecords, includeModes) {
446
565
  if (!value || typeof value !== 'object' || Array.isArray(value))
447
566
  return null;
448
567
  const source = value;
@@ -455,38 +574,44 @@ function normalizeEntry(value) {
455
574
  && !tlsSpkiSha256) {
456
575
  throw new TypeError('Domain control TLS public-key fingerprint must be exactly 64 hexadecimal characters.');
457
576
  }
577
+ const records = {
578
+ nameservers: Object.freeze(legacyRecords ? hostnames(source.nameservers) : canonicalDomainControlRecords(source.nameservers, 'nameservers')),
579
+ ds: Object.freeze(legacyRecords ? canonicalDomainControlRecordList(source.ds, 'ds') : canonicalDomainControlRecords(source.ds, 'ds')),
580
+ mx: Object.freeze(legacyRecords ? canonicalDomainControlRecordList(source.mx, 'mx') : canonicalDomainControlRecords(source.mx, 'mx')),
581
+ caa: Object.freeze(legacyRecords ? canonicalDomainControlRecordList(source.caa, 'caa') : canonicalDomainControlRecords(source.caa, 'caa')),
582
+ };
458
583
  return Object.freeze({
459
584
  domain,
460
- nameservers: Object.freeze(hostnames(source.nameservers)),
461
- ds: Object.freeze(canonicalDomainControlRecordList(source.ds, 'ds')),
462
- mx: Object.freeze(canonicalDomainControlRecordList(source.mx, 'mx')),
463
- caa: Object.freeze(canonicalDomainControlRecordList(source.caa, 'caa')),
585
+ ...records,
586
+ ...(includeModes ? { recordModes: normalizeDomainControlRecordModes(source.recordModes, records) } : {}),
464
587
  tlsIssuer: text(source.tlsIssuer, MAX_DOMAIN_CONTROL_TEXT_LENGTH)?.toLowerCase() ?? null,
465
588
  tlsSpkiSha256,
466
589
  registrarLock: source.registrarLock === 'required' || source.registrarLock === 'not_required'
467
590
  ? source.registrarLock
468
591
  : null,
469
- renewalReviewAt: timestamp(source.renewalReviewAt, true),
592
+ renewalReviewAt: timestamp(source.renewalReviewAt, legacyRecords),
470
593
  note: text(source.note, MAX_DOMAIN_CONTROL_NOTE_LENGTH),
471
594
  });
472
595
  }
473
596
  function buildUnsignedDomainControlPassportInternal(input, generatedAtValue, options = {}) {
474
597
  const source = boundedDataRecord(input, INPUT_KEYS, 'Domain control manifest input');
475
- if (!source || source.schema !== DOMAIN_CONTROL_PASSPORT_INPUT_SCHEMA || source.version !== DOMAIN_CONTROL_MANIFEST_INPUT_VERSION) {
476
- throw new TypeError(`Domain control manifest input must use ${DOMAIN_CONTROL_PASSPORT_INPUT_SCHEMA} version ${DOMAIN_CONTROL_MANIFEST_INPUT_VERSION}.`);
598
+ if (!source || source.schema !== DOMAIN_CONTROL_PASSPORT_INPUT_SCHEMA || !SUPPORTED_DOMAIN_CONTROL_MANIFEST_INPUT_VERSIONS.some((version) => version === source.version)) {
599
+ throw new TypeError(`Domain control manifest input must use ${DOMAIN_CONTROL_PASSPORT_INPUT_SCHEMA} version ${SUPPORTED_DOMAIN_CONTROL_MANIFEST_INPUT_VERSIONS.join(' or ')}.`);
477
600
  }
478
- const inputEntries = boundedDomainControlPassportEntries(source.entries, 'Domain control manifest input');
601
+ const legacy = source.version === PUBLIC_DOMAIN_CONTROL_MANIFEST_INPUT_VERSION;
602
+ const version = options.manifestVersion ?? DOMAIN_CONTROL_MANIFEST_VERSION;
603
+ const inputEntries = boundedDomainControlPassportEntries(source.entries, 'Domain control manifest input', legacy);
479
604
  source.entries = inputEntries;
480
605
  assertDomainControlPassportByteBudget(source, 'Domain control manifest input');
481
606
  const generatedAt = timestamp(generatedAtValue, options.legacyGeneratedAt === true);
482
- const expiresAt = timestamp(source.expiresAt, true);
607
+ const expiresAt = timestamp(source.expiresAt, legacy);
483
608
  if (!generatedAt) {
484
609
  throw new TypeError('Domain control manifest generation time must be valid and include an explicit timezone.');
485
610
  }
486
611
  if (!expiresAt || Date.parse(expiresAt) <= Date.parse(generatedAt)) {
487
612
  throw new TypeError('Domain control manifest expiry must be a valid time after generation.');
488
613
  }
489
- const entries = inputEntries.map(normalizeEntry);
614
+ const entries = inputEntries.map((entry) => normalizeEntry(entry, legacy, version === DOMAIN_CONTROL_MANIFEST_VERSION));
490
615
  if (entries.some((entry) => entry === null))
491
616
  throw new TypeError('Domain control manifest contains an invalid entry.');
492
617
  const normalizedEntries = entries;
@@ -498,11 +623,11 @@ function buildUnsignedDomainControlPassportInternal(input, generatedAtValue, opt
498
623
  : [...normalizedEntries].sort((left, right) => compareCodeUnits(left.domain, right.domain));
499
624
  return Object.freeze({
500
625
  schema: DOMAIN_CONTROL_PASSPORT_SCHEMA,
501
- version: DOMAIN_CONTROL_MANIFEST_VERSION,
626
+ version,
502
627
  generatedAt,
503
628
  expiresAt,
504
629
  entries: Object.freeze(orderedEntries),
505
- limitations: DOMAIN_CONTROL_PASSPORT_LIMITATIONS,
630
+ limitations: version === PUBLIC_DOMAIN_CONTROL_MANIFEST_VERSION ? PUBLIC_DOMAIN_CONTROL_MANIFEST_LIMITATIONS : DOMAIN_CONTROL_PASSPORT_LIMITATIONS,
506
631
  });
507
632
  }
508
633
  export function buildUnsignedDomainControlPassport(input, generatedAtValue, options = {}) {
@@ -519,29 +644,32 @@ function normalizeDomainControlPassportDocumentInternal(value) {
519
644
  || !MANIFEST_DIGEST_PATTERN.test(integrity.digestSha256)) {
520
645
  throw new TypeError('Domain control manifest has an unsupported or malformed structure.');
521
646
  }
647
+ const canonicalization = resolveArtifactCanonicalization(source.version, integrity.canonicalization, DOMAIN_CONTROL_MANIFEST_CANONICALIZATION_ROUTES, 'Domain control manifest');
648
+ const legacy = source.version === PUBLIC_DOMAIN_CONTROL_MANIFEST_VERSION;
649
+ const expectedLimitations = legacy ? PUBLIC_DOMAIN_CONTROL_MANIFEST_LIMITATIONS : DOMAIN_CONTROL_PASSPORT_LIMITATIONS;
522
650
  let limitations;
523
651
  try {
524
- limitations = boundedDataArray(source.limitations, 'Domain control manifest limitations', DOMAIN_CONTROL_PASSPORT_LIMITATIONS.length, DOMAIN_CONTROL_PASSPORT_LIMITATIONS.length);
652
+ limitations = boundedDataArray(source.limitations, 'Domain control manifest limitations', expectedLimitations.length, expectedLimitations.length);
525
653
  }
526
654
  catch {
527
655
  throw new TypeError('Domain control manifest has an unsupported or malformed structure.');
528
656
  }
529
- if (limitations.some((item, index) => item !== DOMAIN_CONTROL_PASSPORT_LIMITATIONS[index])) {
657
+ if (limitations.some((item, index) => item !== expectedLimitations[index])) {
530
658
  throw new TypeError('Domain control manifest has an unsupported or malformed structure.');
531
659
  }
532
660
  source.limitations = limitations;
533
- const manifestEntries = boundedDomainControlPassportEntries(source.entries, 'Domain control manifest');
661
+ const manifestEntries = boundedDomainControlPassportEntries(source.entries, 'Domain control manifest', legacy);
534
662
  source.entries = manifestEntries;
535
663
  source.integrity = integrity;
536
664
  assertDomainControlPassportByteBudget(source);
537
- const canonicalization = resolveArtifactCanonicalization(source.version, integrity.canonicalization, DOMAIN_CONTROL_MANIFEST_CANONICALIZATION_ROUTES, 'Domain control manifest');
538
665
  const baseUnsigned = buildUnsignedDomainControlPassportInternal({
539
666
  schema: DOMAIN_CONTROL_PASSPORT_INPUT_SCHEMA,
540
- version: DOMAIN_CONTROL_MANIFEST_INPUT_VERSION,
667
+ version: legacy ? PUBLIC_DOMAIN_CONTROL_MANIFEST_INPUT_VERSION : DOMAIN_CONTROL_MANIFEST_INPUT_VERSION,
541
668
  expiresAt: source.expiresAt,
542
669
  entries: manifestEntries,
543
670
  }, source.generatedAt, {
544
671
  preserveEntryOrder: true,
672
+ manifestVersion: legacy ? PUBLIC_DOMAIN_CONTROL_MANIFEST_VERSION : DOMAIN_CONTROL_MANIFEST_VERSION,
545
673
  });
546
674
  const unsigned = baseUnsigned;
547
675
  const { integrity: _integrity, ...suppliedUnsigned } = source;
@@ -0,0 +1,79 @@
1
+ import { normalizeExplicitIsoTimestamp } from "./observation.mjs";
2
+ import { normalizeDomain } from "./domain-name.mjs";
3
+ export const DOMAIN_POSTURE_COMPARISON_VERSION = 1;
4
+ export const MAX_POSTURE_CHECKS = 32;
5
+ export const MAX_POSTURE_CHECK_RECORDS = 64;
6
+ export const MAX_POSTURE_RECORD_LENGTH = 4096;
7
+ function contextRecord(value, keys, label) {
8
+ if (!value || typeof value !== 'object' || Array.isArray(value)
9
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(value))
10
+ || Reflect.ownKeys(value).length !== keys.length)
11
+ throw new TypeError(`${label} has an invalid structure.`);
12
+ const result = {};
13
+ for (const key of keys) {
14
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
15
+ if (!descriptor?.enumerable || !('value' in descriptor))
16
+ throw new TypeError(`${label} requires ordinary data fields.`);
17
+ result[key] = descriptor.value;
18
+ }
19
+ if (result.version !== DOMAIN_POSTURE_COMPARISON_VERSION)
20
+ throw new TypeError(`${label} has an unsupported comparison version.`);
21
+ return result;
22
+ }
23
+ /** Missing legacy provenance stays missing; declared malformed or future context is rejected. */
24
+ export function normalizeDomainPostureSourceContext(value) {
25
+ if (value === undefined)
26
+ return undefined;
27
+ const source = contextRecord(value, ['version', 'source', 'observedAt', 'state', 'omittedRecords'], 'Posture source context');
28
+ const observedAt = normalizeExplicitIsoTimestamp(source.observedAt);
29
+ if (typeof source.source !== 'string' || !['dns_ns', 'dns_mx', 'dns_caa', 'registry_rdap'].includes(source.source)
30
+ || typeof source.state !== 'string' || !['complete', 'partial', 'unavailable'].includes(source.state)
31
+ || (source.observedAt !== null && observedAt === null)
32
+ || (source.omittedRecords !== null && (!Number.isSafeInteger(source.omittedRecords) || Number(source.omittedRecords) < 0))
33
+ || (source.state === 'complete' && source.omittedRecords !== 0)) {
34
+ throw new TypeError('Posture source context has invalid provenance or omission counts.');
35
+ }
36
+ return Object.freeze({ ...source, observedAt });
37
+ }
38
+ export function normalizeDomainPostureProfileContext(value) {
39
+ if (value === undefined)
40
+ return undefined;
41
+ const source = contextRecord(value, ['version', 'domain', 'profileId', 'profileFingerprint'], 'Posture profile context');
42
+ const domain = typeof source.domain === 'string' ? normalizeDomain(source.domain) : '';
43
+ if (!domain || domain !== source.domain || typeof source.profileId !== 'string'
44
+ || !source.profileId.trim() || source.profileId.length > 200 || /[\u0000-\u001f\u007f]/u.test(source.profileId)
45
+ || typeof source.profileFingerprint !== 'string' || !/^[a-f0-9]{64}$/u.test(source.profileFingerprint)) {
46
+ throw new TypeError('Posture profile context has an invalid target or collection identity.');
47
+ }
48
+ return Object.freeze(source);
49
+ }
50
+ export const POSTURE_CHECK_SOURCES = Object.freeze({
51
+ nameservers: 'dns_ns', mx: 'dns_mx', caa: 'dns_caa', registration_lock: 'registry_rdap',
52
+ });
53
+ export const POSTURE_SOURCE_LABELS = Object.freeze({
54
+ dns_ns: 'DNS NS', dns_mx: 'DNS MX', dns_caa: 'DNS CAA', registry_rdap: 'Registry RDAP',
55
+ });
56
+ export function postureTransferRestriction(records) {
57
+ if (!records.length)
58
+ return null;
59
+ return records.some((value) => value === 'clienttransferprohibited' || value === 'servertransferprohibited')
60
+ ? 'required' : 'not_required';
61
+ }
62
+ export function postureSourceAdmission(check, capturedAt, now) {
63
+ if (!check?.sourceContext)
64
+ return 'Source comparison context is unavailable for this retained check.';
65
+ const context = check.sourceContext;
66
+ const expected = POSTURE_CHECK_SOURCES[check.id];
67
+ if (context.version !== DOMAIN_POSTURE_COMPARISON_VERSION || !expected || expected !== context.source)
68
+ return 'The source or comparison version does not match this field.';
69
+ const captured = normalizeExplicitIsoTimestamp(capturedAt);
70
+ const observed = normalizeExplicitIsoTimestamp(context.observedAt);
71
+ const reviewed = normalizeExplicitIsoTimestamp(now);
72
+ if (!captured || !observed || !reviewed)
73
+ return 'A source, capture or review time is unavailable.';
74
+ if (Date.parse(observed) > Date.parse(captured) || Date.parse(captured) > Date.parse(reviewed))
75
+ return 'The source or capture time is later than its review.';
76
+ if (context.state !== 'complete' || context.omittedRecords !== 0)
77
+ return 'The source observation is incomplete; retained values cannot establish alignment or drift.';
78
+ return null;
79
+ }
@@ -0,0 +1,56 @@
1
+ import { array, enumeration, exact, integer, text } from "./artifact-structure.mjs";
2
+ import { readRetainedFileReference } from "./retained-file.mjs";
3
+ // The decoded RGBA surface is bounded independently of compressed file bytes.
4
+ export const MAX_EVIDENCE_IMAGE_PIXELS = 16 * 1024 * 1024;
5
+ export const MAX_EVIDENCE_IMAGE_DIMENSION = 10_000;
6
+ // Each selected region emits at most four solid rectangles; no nested effects.
7
+ export const MAX_EVIDENCE_IMAGE_REGIONS = 64;
8
+ export function readEvidenceImageDimensions(width, height) {
9
+ const result = {
10
+ width: integer(width, 'Image width', 1, MAX_EVIDENCE_IMAGE_DIMENSION),
11
+ height: integer(height, 'Image height', 1, MAX_EVIDENCE_IMAGE_DIMENSION),
12
+ };
13
+ if (result.width * result.height > MAX_EVIDENCE_IMAGE_PIXELS)
14
+ throw new TypeError('This image exceeds the decoded-image pixel bound. Its original bytes are unchanged.');
15
+ return Object.freeze(result);
16
+ }
17
+ /** Pixel coordinates are integers, remain inside the source, and are never clipped silently. */
18
+ export function readImageRegionPlan(raw) {
19
+ const value = exact(raw, ['width', 'height', 'regions'], 'Image region plan');
20
+ const dimensions = readEvidenceImageDimensions(value.width, value.height);
21
+ const regions = Array.from(array(value.regions, 'Image regions', MAX_EVIDENCE_IMAGE_REGIONS, 1), candidate => {
22
+ const region = exact(candidate, ['kind', 'x', 'y', 'width', 'height'], 'Image region');
23
+ const kind = enumeration(region.kind, ['redact', 'outline'], 'Image region kind');
24
+ const x = integer(region.x, 'Region left coordinate', 0, dimensions.width - 1);
25
+ const y = integer(region.y, 'Region top coordinate', 0, dimensions.height - 1);
26
+ const width = integer(region.width, 'Region width', 1, dimensions.width - x);
27
+ const height = integer(region.height, 'Region height', 1, dimensions.height - y);
28
+ return Object.freeze({ kind, x, y, width, height });
29
+ });
30
+ return Object.freeze({ ...dimensions, regions: Object.freeze(regions) });
31
+ }
32
+ export function readImageDerivation(raw) {
33
+ const value = exact(raw, ['method', 'sourceAttachmentId', 'source', 'plan'], 'Image derivation');
34
+ if (value.method !== 'png-regions-v1')
35
+ throw new TypeError('This image derivation method is unsupported.');
36
+ return Object.freeze({
37
+ method: value.method,
38
+ sourceAttachmentId: text(value.sourceAttachmentId, 'Source attachment ID', 128),
39
+ source: readRetainedFileReference(value.source),
40
+ plan: readImageRegionPlan(value.plan),
41
+ });
42
+ }
43
+ /** Opaque redactions are always last, including where annotations overlap them. */
44
+ export function imageRegionPaintRectangles(raw) {
45
+ const plan = readImageRegionPlan(raw);
46
+ const result = [];
47
+ for (const region of plan.regions.filter(region => region.kind === 'outline')) {
48
+ const thickness = Math.min(3, region.width, region.height);
49
+ const { x, y, width, height } = region;
50
+ result.push({ x, y, width, height: thickness, colour: '#ffbf00' }, { x, y: y + height - thickness, width, height: thickness, colour: '#ffbf00' }, { x, y, width: thickness, height, colour: '#ffbf00' }, { x: x + width - thickness, y, width: thickness, height, colour: '#ffbf00' });
51
+ }
52
+ for (const region of plan.regions.filter(region => region.kind === 'redact')) {
53
+ result.push({ x: region.x, y: region.y, width: region.width, height: region.height, colour: '#000000' });
54
+ }
55
+ return result;
56
+ }
@@ -0,0 +1,28 @@
1
+ import { normalizeExplicitIsoTimestamp } from "./observation.mjs";
2
+ /** Select a time cohort from an already bounded source; never rank undated evidence as older. */
3
+ export function latestObservationCohort(values, observedAt) {
4
+ let latestTime = null;
5
+ let latestMillis = -Infinity;
6
+ let latest = [];
7
+ const undated = [];
8
+ let superseded = 0;
9
+ for (const value of values) {
10
+ const time = normalizeExplicitIsoTimestamp(observedAt(value));
11
+ if (time === null) {
12
+ undated.push(value);
13
+ continue;
14
+ }
15
+ const millis = Date.parse(time);
16
+ if (millis > latestMillis) {
17
+ superseded += latest.length;
18
+ latestTime = time;
19
+ latestMillis = millis;
20
+ latest = [value];
21
+ }
22
+ else if (millis === latestMillis)
23
+ latest.push(value);
24
+ else
25
+ superseded += 1;
26
+ }
27
+ return { observedAt: latestTime, latest, undated, superseded };
28
+ }