@slicedearth/whoisleuth-cli 1.45.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +74 -124
  4. package/SECURITY.md +6 -3
  5. package/bin/whoisleuth.mjs +7 -4
  6. package/cli/archive-inspect.mjs +28 -10
  7. package/cli/arguments.mjs +450 -173
  8. package/cli/artifact-structure.mjs +2507 -0
  9. package/cli/artifact-verify.mjs +286 -64
  10. package/cli/bounded-json.mjs +1 -0
  11. package/cli/bulk-checkpoint.mjs +35 -23
  12. package/cli/bulk-command-runner.mjs +2 -5
  13. package/cli/bulk.mjs +9 -2
  14. package/cli/case-pack.mjs +435 -46
  15. package/cli/ci-report.mjs +6 -1
  16. package/cli/collection-preflight.mjs +2 -2
  17. package/cli/command-catalogue.mjs +12 -3
  18. package/cli/command-reference.mjs +948 -106
  19. package/cli/completion.mjs +504 -218
  20. package/cli/config-profile.mjs +13 -3
  21. package/cli/ct-event-intake.mjs +32 -2
  22. package/cli/discover.mjs +7 -1
  23. package/cli/discovery-observation-snapshot.mjs +24 -28
  24. package/cli/discovery-scan-command-runner.mjs +13 -14
  25. package/cli/discovery-scan.mjs +17 -3
  26. package/cli/discovery-snapshot.mjs +15 -7
  27. package/cli/discovery-workflow.mjs +2 -3
  28. package/cli/doctor.mjs +2 -2
  29. package/cli/domain-control-monitor.mjs +215 -36
  30. package/cli/domain-control-observations.mjs +34 -17
  31. package/cli/errors.mjs +8 -3
  32. package/cli/evidence-command-runner.mjs +17 -11
  33. package/cli/evidence-signing.mjs +68 -50
  34. package/cli/export-evidence.mjs +11 -4
  35. package/cli/external-observation-mapping.mjs +3 -3
  36. package/cli/formatters/html.mjs +3 -3
  37. package/cli/formatters/json.mjs +20 -32
  38. package/cli/formatters/terminal.mjs +586 -22
  39. package/cli/input.mjs +62 -0
  40. package/cli/interactive-launcher.mjs +226 -0
  41. package/cli/interchange-report.mjs +226 -0
  42. package/cli/investigation-manifest.mjs +16 -17
  43. package/cli/investigation-plan.mjs +167 -16
  44. package/cli/investigation-run.mjs +80 -24
  45. package/cli/local-mmdb-review.mjs +45 -14
  46. package/cli/lookalike-calibration.mjs +3 -1
  47. package/cli/lookup-brief.mjs +13 -8
  48. package/cli/lookup-browser.mjs +844 -0
  49. package/cli/lookup-command-runner.mjs +96 -33
  50. package/cli/lookup-diff.mjs +11 -2
  51. package/cli/lookup-plan.mjs +1 -0
  52. package/cli/mail-review.mjs +206 -94
  53. package/cli/manual.mjs +5 -5
  54. package/cli/network-command-runner.mjs +78 -0
  55. package/cli/offline-evidence-review.mjs +45 -14
  56. package/cli/open-asset-model-bridge.mjs +1 -1
  57. package/cli/output-file.mjs +70 -14
  58. package/cli/page-compare.mjs +18 -10
  59. package/cli/progress-events.mjs +2 -1
  60. package/cli/progress.mjs +3 -3
  61. package/cli/registry-cohort.mjs +343 -61
  62. package/cli/registry-doctor.mjs +67 -28
  63. package/cli/registry-support.mjs +2 -1
  64. package/cli/retained-artifact-diff.mjs +375 -0
  65. package/cli/risk-calibration.mjs +89 -40
  66. package/cli/runner.mjs +322 -246
  67. package/cli/saved-lookup.mjs +439 -49
  68. package/cli/sharing-review.mjs +18 -7
  69. package/cli/source-reliability.mjs +75 -36
  70. package/cli/terminal-presentation.mjs +43 -18
  71. package/docs/cli-reference.md +125 -1334
  72. package/docs/cli.md +87 -236
  73. package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -0
  74. package/frontend/src/lib/analysis/domain-control-records.js +3 -0
  75. package/lib/application-version.mjs +3 -0
  76. package/lib/availability.mjs +72 -52
  77. package/lib/bounded-contract-normalizers.mjs +2 -4
  78. package/lib/bounded-file.mjs +91 -4
  79. package/lib/bounded-json.mjs +287 -0
  80. package/lib/browser-library-profile.mjs +1 -1
  81. package/lib/certificate-extension-profile.mjs +2 -1
  82. package/lib/classify.mjs +56 -5
  83. package/lib/client-behavior-profile.mjs +1 -1
  84. package/lib/credential-surface-profile.mjs +1 -1
  85. package/lib/cryptographic-assurance.mjs +173 -0
  86. package/lib/ct-query.mjs +3 -3
  87. package/lib/ct-search.mjs +120 -64
  88. package/lib/distributed-operation-budget.mjs +4 -2
  89. package/lib/dns-delegation-health.mjs +9 -6
  90. package/lib/dns-intelligence.mjs +4 -3
  91. package/lib/dns-resolver-selection.mjs +6 -4
  92. package/lib/dnssec-chain-validation.mjs +1052 -0
  93. package/lib/dnssec-evidence-validation.mjs +23 -10
  94. package/lib/domain-assurance.mjs +12 -12
  95. package/lib/domain-change-packet.mjs +7 -5
  96. package/lib/domain-control-flight-recorder.mjs +442 -78
  97. package/lib/domain-control-manifest.mjs +314 -171
  98. package/lib/domain-posture-analysis.mjs +5 -2
  99. package/lib/domain-posture.mjs +7 -4
  100. package/lib/encrypted-dns-contract.mjs +2 -3
  101. package/lib/evidence-export.mjs +1243 -88
  102. package/lib/evidence-report-markdown.mjs +3 -0
  103. package/lib/evidence-report.mjs +5 -0
  104. package/lib/external-intelligence-risk.mjs +26 -10
  105. package/lib/favicon.mjs +8 -5
  106. package/lib/feature-policy.mjs +1 -1
  107. package/lib/homepage-metadata-contract.mjs +290 -0
  108. package/lib/hostname.mjs +1 -1
  109. package/lib/html-signals.mjs +89 -5
  110. package/lib/http-evidence-bounds.mjs +3 -0
  111. package/lib/http-intelligence.mjs +332 -6
  112. package/lib/interchange-fidelity-registry.mjs +124 -0
  113. package/lib/local-geoip-evidence.mjs +2 -1
  114. package/lib/lookup-cache.mjs +6 -4
  115. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  116. package/lib/lookup.mjs +27 -4
  117. package/lib/observed-network-context.mjs +1 -1
  118. package/lib/outbound-identity.mjs +2 -3
  119. package/lib/page-fingerprints.mjs +2 -2
  120. package/lib/page-role-profile.mjs +1 -1
  121. package/lib/perceptual-hash.mjs +12 -4
  122. package/lib/portable-generator.mjs +29 -0
  123. package/lib/provider-policy-admission.mjs +11 -4
  124. package/lib/rdap-bootstrap.mjs +76 -16
  125. package/lib/rdap-client.mjs +20 -12
  126. package/lib/rdap-normalization.mjs +324 -80
  127. package/lib/rdap-registrar.mjs +5 -1
  128. package/lib/rdap-transport.mjs +2 -2
  129. package/lib/rdap-validation.mjs +65 -14
  130. package/lib/rdap.mjs +2 -2
  131. package/lib/registrable-domain.mjs +36 -0
  132. package/lib/registry-capabilities.mjs +3 -0
  133. package/lib/registry-capability-catalogue.mjs +12 -2
  134. package/lib/registry-comparison.mjs +28 -11
  135. package/lib/registry-insights.mjs +7 -0
  136. package/lib/response-policy.mjs +27 -4
  137. package/lib/risk-calibration-summary.mjs +340 -0
  138. package/lib/risk-scoring.mjs +4 -3
  139. package/lib/rpki-evidence.mjs +9 -8
  140. package/lib/safe-fetch.mjs +121 -50
  141. package/lib/security-txt.mjs +27 -4
  142. package/lib/service-binding-dns.mjs +6 -1
  143. package/lib/smtp-transport-review.mjs +787 -0
  144. package/lib/sslbl-certificates.generated.mjs +136 -134
  145. package/lib/sslbl-intelligence.mjs +6 -8
  146. package/lib/static-html-analysis.mjs +301 -4
  147. package/lib/structured-data-identity.mjs +51 -19
  148. package/lib/threat-intelligence-fixtures.mjs +2 -4
  149. package/lib/threat-intelligence-manifests.mjs +2 -7
  150. package/lib/threat-intelligence-runtime.mjs +5 -15
  151. package/lib/threat-intelligence-types.mjs +21 -1
  152. package/lib/threatfox-intelligence.mjs +7 -3
  153. package/lib/tls-intelligence.mjs +15 -9
  154. package/lib/tlsa-evidence.mjs +37 -7
  155. package/lib/urlhaus-intelligence.mjs +4 -2
  156. package/lib/urlscan-intelligence.mjs +6 -5
  157. package/lib/website-security-posture.mjs +1 -1
  158. package/lib/website-technology.mjs +1 -1
  159. package/lib/whois-authority.mjs +43 -8
  160. package/lib/whois-chain.mjs +25 -3
  161. package/lib/whois-normalization.mjs +3 -1
  162. package/lib/whois-transport.mjs +12 -3
  163. package/lib/zone-intent-review.mjs +1 -1
  164. package/package.json +12 -4
  165. package/packages/cases/case-brand-profile-references.mjs +85 -0
  166. package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +43 -14
  167. package/packages/cases/case-investigation-branch-model.mjs +132 -0
  168. package/packages/cases/case-migration-model.mjs +476 -0
  169. package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
  170. package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -19
  171. package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
  172. package/packages/cases/case-record-model.mjs +9 -0
  173. package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +106 -33
  174. package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +143 -16
  175. package/packages/cases/case-response-model.mjs +1604 -0
  176. package/packages/cases/case-response-packet.mjs +1312 -0
  177. package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
  178. package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
  179. package/packages/cases/opaque-reference-id.mjs +9 -0
  180. package/packages/comparison/comparison-ledger-bulk.mjs +366 -0
  181. package/packages/comparison/comparison-ledger-contract.mjs +241 -0
  182. package/packages/comparison/comparison-ledger-json.mjs +80 -0
  183. package/packages/comparison/comparison-ledger-serialization.mjs +52 -0
  184. package/packages/comparison/favicon-similarity.mjs +78 -0
  185. package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
  186. package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
  187. package/packages/contracts/analyst-review-state-contract.mjs +61 -0
  188. package/packages/contracts/analyst-review-state.mjs +50 -0
  189. package/packages/contracts/case-portability.mjs +1310 -0
  190. package/packages/contracts/cli-command-catalogue.mjs +2 -0
  191. package/packages/contracts/cli-lookup.mjs +394 -0
  192. package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
  193. package/packages/contracts/domain-control-manifest.mjs +1027 -0
  194. package/packages/contracts/domain-control-monitor.mjs +310 -0
  195. package/packages/contracts/domain-control-review.mjs +828 -0
  196. package/packages/contracts/domain-name.mjs +4 -0
  197. package/packages/contracts/external-observation-interchange.mjs +224 -0
  198. package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
  199. package/packages/contracts/investigation-portability.mjs +331 -0
  200. package/packages/contracts/offline-comparison.mjs +160 -0
  201. package/packages/contracts/risk-calibration.mjs +1288 -0
  202. package/packages/contracts/schema-compatibility.mjs +71 -0
  203. package/packages/contracts/schema-lifecycle.mjs +1702 -0
  204. package/packages/contracts/sslbl-snapshot.mjs +17 -0
  205. package/packages/contracts/workspace-portability.mjs +1347 -0
  206. package/packages/evidence/artifact-integrity.mjs +65 -0
  207. package/packages/evidence/decision-fact.mjs +735 -0
  208. package/packages/evidence/domain-control-runtime.mjs +547 -0
  209. package/packages/evidence/domain-name.mjs +41 -0
  210. package/packages/evidence/observation.mjs +384 -0
  211. package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +86 -20
  212. package/packages/interchange/retained-text.mjs +8 -0
  213. package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +11 -8
  214. package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +28 -9
  215. package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +67 -18
  216. package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +10 -9
  217. package/packages/investigation/bulk-review-policy.mjs +1 -0
  218. package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +50 -18
  219. package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
  220. package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
  221. package/packages/investigation/lookup-claim-passport.mjs +232 -0
  222. package/packages/investigation/lookup-claim-readiness.mjs +287 -0
  223. package/packages/investigation/lookup-investigation-brief.mjs +172 -0
  224. package/packages/monitoring/analyst-review-state.mjs +680 -0
  225. package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +97 -50
  226. package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +15 -15
  227. package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +179 -39
  228. package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +12 -5
  229. package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
  230. package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
  231. package/packages/workspace/hostile-input.mjs +172 -0
  232. package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +51 -46
  233. package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +15 -15
  234. package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
  235. package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
  236. package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
  237. package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
  238. package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
  239. package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +18 -12
  240. package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
  241. package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +195 -84
  242. package/third-party-notices.txt +112 -5834
  243. package/frontend/src/lib/analysis/artifact-integrity.js +0 -21
  244. package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
  245. package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
  246. package/frontend/src/lib/analysis/case-migration-model.js +0 -253
  247. package/frontend/src/lib/analysis/case-record-model.js +0 -7
  248. package/frontend/src/lib/analysis/case-response-model.js +0 -669
  249. package/frontend/src/lib/analysis/case-response-packet.js +0 -573
  250. package/frontend/src/lib/analysis/lookup-decision-support.js +0 -487
  251. package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
  252. package/frontend/src/lib/analysis/lookup-presentation.js +0 -58
  253. package/frontend/src/lib/analysis/lookup-response.js +0 -2
  254. package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
  255. package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
  256. package/frontend/src/lib/analysis/scoring.js +0 -55
  257. package/frontend/src/lib/analysis/utils.js +0 -232
  258. package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
  259. package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
  260. package/frontend/src/lib/analysis/visualization-matrix.js +0 -88
  261. package/frontend/src/lib/analysis/visualization-models.js +0 -369
  262. package/frontend/src/lib/analysis/visualization-time-series.js +0 -144
  263. package/lib/bounded-json-response.mjs +0 -157
  264. package/lib/lookup-response-contract.mjs +0 -346
  265. package/lib/observation.mjs +0 -114
  266. /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
  267. /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
  268. /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
@@ -0,0 +1,2507 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { canonicalArtifactJsonV2 } from "../packages/evidence/artifact-integrity.mjs";
3
+ import { ACQUISITION_DECISIONS, ACQUISITION_MANUAL_CHECKS, ACQUISITION_DECISION_PACKET_SCHEMA, ACQUISITION_DECISION_PACKET_VERSION, } from "../packages/investigation/acquisition-decision-packet.mjs";
4
+ import { BULK_DOMAIN_COMPARISON_EXPORT_VERSION, BULK_DOMAIN_COMPARISON_SCHEMA, } from "../packages/investigation/bulk-domain-comparison.mjs";
5
+ import { BULK_MAIL_EXPOSURE_EXPORT_VERSION, BULK_MAIL_EXPOSURE_SCHEMA, MAX_BULK_MAIL_EXPOSURE_ROWS, } from "../packages/investigation/bulk-mail-exposure.mjs";
6
+ import { BULK_REVIEW_MANIFEST_SCHEMA, BULK_REVIEW_MANIFEST_VERSION, } from "../packages/investigation/bulk-review-export.mjs";
7
+ import { CASE_RESPONSE_PACKET_SCHEMA, CASE_RESPONSE_PACKET_VERSION, PUBLIC_CASE_RESPONSE_PACKET_VERSION, CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, CASE_RESPONSE_REVIEW_INPUTS_VERSION, MAX_ABUSIVE_URLS, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_CONTACTS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_SELECTED_EVIDENCE, RESPONSE_AUTHORISATION_CONFIRMATION_IDS, RESPONSE_PACKET_PROFILES, RESPONSE_CONTACT_KINDS, RESPONSE_PACKET_PROFILE_IDS, RESPONSE_READINESS_ROW_IDS, RESPONSE_READINESS_STATES, RESPONSE_ROUTE_STALE_AFTER_DAYS, } from "../packages/cases/case-response-packet.mjs";
8
+ import { CASE_ACTION_EVENT_SOURCE_CLASSES, CASE_ACTION_STATES, CASE_ACTION_TYPES, CASE_CLOSURE_REASONS, CASE_OBSERVED_EFFECT_SOURCE_CLASSES, CASE_OBSERVED_EFFECT_STATES, CASE_PROVIDER_OUTCOMES, isLegalCaseActionTransition, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_DECISION_PIN_REFERENCES, MAX_CASE_ACTIONS, MAX_CASE_ASSERTIONS, MAX_CASE_DECISIONS, MAX_CASE_EVIDENCE_PINS, MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_LIMITATION_LENGTH, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_RECIPIENT_LENGTH, } from "../packages/cases/case-response-model.mjs";
9
+ import { INVESTIGATION_CAPSULE_ANALYST_RECORDS_SCHEMA, INVESTIGATION_CAPSULE_ANALYST_RECORDS_VERSION, INVESTIGATION_CAPSULE_SCHEMA, INVESTIGATION_CAPSULE_VERSION, PUBLIC_INVESTIGATION_CAPSULE_VERSION, } from "../packages/investigation/investigation-capsule.mjs";
10
+ import { LOOKUP_ASSET_GRAPH_SCHEMA, LOOKUP_ASSET_GRAPH_VERSION, } from "../packages/investigation/lookup-asset-graph.mjs";
11
+ import { PUBLIC_LOOKUP_INVESTIGATION_BRIEF_VERSION, LOOKUP_INVESTIGATION_BRIEF_SCHEMA, LOOKUP_INVESTIGATION_BRIEF_VERSION, MAX_LOOKUP_INVESTIGATION_BRIEF_BYTES, } from "../packages/investigation/lookup-investigation-brief.mjs";
12
+ import { DECISION_FACT_CONSISTENCY_STATES, DECISION_FACT_EVIDENCE_STATES, DECISION_FACT_FRESHNESS_STATES, DECISION_FACT_PROVENANCE_STATES, DECISION_FACT_PROJECTION_VERSION, DECISION_FACT_VERSION, MAX_DECISION_FACT_CONTRIBUTORS, MAX_DECISION_FACT_CONTRADICTIONS, MAX_DECISION_FACT_LIMITATIONS, MAX_DECISION_FACT_NEXT_ACTIONS, MAX_DECISION_FACT_PROJECTION_CONTRADICTIONS, MAX_DECISION_FACT_PROJECTION_BYTES, MAX_DECISION_FACT_PROJECTION_FACTS, MAX_DECISION_FACT_PROJECTION_LIMITATIONS, MAX_DECISION_FACT_PROJECTION_NEXT_ACTIONS, MAX_DECISION_FACT_PROJECTION_REFERENCES, MAX_DECISION_FACT_PROJECTION_SOURCES, MAX_DECISION_FACT_PROJECTION_SOURCE_LIMITATIONS, MAX_DECISION_FACT_PROJECTION_SOURCE_REFERENCES, MAX_DECISION_FACT_REFERENCES, MAX_DECISION_FACTS, decisionFactCompleteness, } from "../packages/evidence/decision-fact.mjs";
13
+ import { LOOKUP_CLAIM_PASSPORT_SCHEMA, LOOKUP_CLAIM_PASSPORT_TARGET_TYPES, LOOKUP_CLAIM_PASSPORT_VERSION, MAX_LOOKUP_CLAIM_PASSPORT_LIMITATIONS, MAX_LOOKUP_CLAIM_PASSPORT_REQUIREMENTS, } from "../packages/investigation/lookup-claim-passport.mjs";
14
+ import { LOOKUP_CLAIM_IDS, LOOKUP_CLAIM_READINESS_VERSION, LOOKUP_CLAIM_REQUIREMENT_IDS, } from "../packages/investigation/lookup-claim-readiness.mjs";
15
+ import { normalizeDomainControlManifestDocument } from "../packages/evidence/domain-control-runtime.mjs";
16
+ import { DOMAIN_CHANGE_PACKET_SCHEMA, DOMAIN_CHANGE_PACKET_VERSION } from "../lib/domain-change-packet.mjs";
17
+ import { DOMAIN_CONTROL_MANIFEST_SCHEMA } from "../lib/domain-control-manifest.mjs";
18
+ import { INVESTIGATION_MANIFEST_SCHEMA, INVESTIGATION_MANIFEST_VERSION, MAX_INVESTIGATION_MANIFEST_ARTIFACTS, MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES, } from "./investigation-manifest.mjs";
19
+ import { assertLookupEvidencePrivacySafeTree, assertLookupEvidencePortableTree, LOOKUP_EVIDENCE_SCHEMA, LOOKUP_EVIDENCE_SCHEMA_VERSION, PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION, projectLookupEvidenceAvailability, projectLookupEvidenceAvailabilityPublic, projectLookupEvidenceQuery, projectLookupEvidenceRdapPublication, projectLookupEvidenceRegistryInsights, projectLookupEvidenceWhoisPublication, SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS, } from "../lib/evidence-export.mjs";
20
+ import { compareRegistrySources } from "../lib/registry-comparison.mjs";
21
+ import { buildRegistryInsights } from "../lib/registry-insights.mjs";
22
+ import { WHOISLEUTH_SOURCE_REPOSITORY_URL } from "../lib/project-metadata.mjs";
23
+ import { validHttpDeliveryMetadata, validPagePublicationMetadata, } from "../lib/homepage-metadata-contract.mjs";
24
+ import { isDeepStrictEqual } from 'node:util';
25
+ const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
26
+ const DIGEST_RE = /^sha256:[a-f0-9]{64}$/u;
27
+ const HEX_DIGEST_RE = /^[a-f0-9]{64}$/u;
28
+ 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;
29
+ 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;
30
+ function fail(label) {
31
+ throw new TypeError(`${label} has an unsupported or malformed structure.`);
32
+ }
33
+ function record(value, label) {
34
+ if (!value || typeof value !== 'object' || Array.isArray(value))
35
+ fail(label);
36
+ return value;
37
+ }
38
+ function exact(value, keys, label) {
39
+ const source = record(value, label);
40
+ const actual = Object.keys(source);
41
+ if (actual.length !== keys.length || actual.some((key) => !keys.includes(key)))
42
+ fail(label);
43
+ return source;
44
+ }
45
+ function exactOptional(value, required, optional, label) {
46
+ const source = record(value, label);
47
+ const actual = Object.keys(source);
48
+ if (required.some((key) => !actual.includes(key))
49
+ || actual.some((key) => !required.includes(key) && !optional.includes(key)))
50
+ fail(label);
51
+ return source;
52
+ }
53
+ function text(value, label, maximum = 2_000, allowEmpty = false) {
54
+ if (typeof value !== 'string' || value.length > maximum || CONTROL_RE.test(value) || (!allowEmpty && !value))
55
+ fail(label);
56
+ return value;
57
+ }
58
+ function optionalText(value, label, maximum = 2_000) {
59
+ if (value !== null)
60
+ text(value, label, maximum);
61
+ }
62
+ function iso(value, label, nullable = false) {
63
+ if (nullable && value === null)
64
+ return;
65
+ const candidate = text(value, label, 64);
66
+ const parsed = Date.parse(candidate);
67
+ if (!Number.isFinite(parsed) || new Date(parsed).toISOString() !== candidate)
68
+ fail(label);
69
+ }
70
+ function integer(value, label, minimum = 0, maximum = Number.MAX_SAFE_INTEGER) {
71
+ if (!Number.isSafeInteger(value) || Number(value) < minimum || Number(value) > maximum)
72
+ fail(label);
73
+ return Number(value);
74
+ }
75
+ function boolean(value, label) {
76
+ if (typeof value !== 'boolean')
77
+ fail(label);
78
+ }
79
+ function enumeration(value, allowed, label) {
80
+ if (typeof value !== 'string' || !allowed.includes(value))
81
+ fail(label);
82
+ return value;
83
+ }
84
+ function array(value, label, maximum, minimum = 0) {
85
+ if (!Array.isArray(value) || value.length < minimum || value.length > maximum)
86
+ fail(label);
87
+ return value;
88
+ }
89
+ function strings(value, label, maximum, textMaximum = 2_000) {
90
+ const values = array(value, label, maximum);
91
+ for (const item of values)
92
+ text(item, label, textMaximum);
93
+ return values;
94
+ }
95
+ function digest(value, label) {
96
+ if (typeof value !== 'string' || !DIGEST_RE.test(value))
97
+ fail(label);
98
+ }
99
+ function sameValues(left, right) {
100
+ return left.length === right.length && left.every((value, index) => value === right[index]);
101
+ }
102
+ function domain(value, label) {
103
+ if (typeof value !== 'string' || !DOMAIN_RE.test(value))
104
+ fail(label);
105
+ }
106
+ function absoluteUrl(value, label, protocols) {
107
+ const candidate = text(value, label, 2_048);
108
+ try {
109
+ const parsed = new URL(candidate);
110
+ if (!protocols.includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash)
111
+ fail(label);
112
+ return parsed;
113
+ }
114
+ catch {
115
+ return fail(label);
116
+ }
117
+ }
118
+ function nullableRecord(value, label) {
119
+ if (value !== null)
120
+ record(value, label);
121
+ }
122
+ const LOOKUP_RDAP_ATTEMPT_OUTCOMES = [
123
+ 'success', 'not_found', 'no_results', 'unsupported', 'rate_limited',
124
+ 'server_error', 'client_error', 'invalid_json', 'invalid_response',
125
+ 'timeout', 'network_error', 'unknown',
126
+ ];
127
+ const LOOKUP_TIMING_SOURCES = [
128
+ 'rdap', 'whois', 'domain_evidence', 'reverse_dns', 'registrar_rdap',
129
+ 'network_context', 'security_txt', 'external_intelligence',
130
+ 'malware_host_intelligence', 'malware_ioc_intelligence',
131
+ ];
132
+ const PUBLIC_LOOKUP_AVAILABILITY_ANALYSIS_KEYS = [
133
+ 'applicable', 'type', 'domain', 'state', 'confidence', 'detail', 'source',
134
+ 'rdapServer', 'nameservers', 'statuses', 'registrar', 'registrant', 'abuse',
135
+ 'createdDate', 'expiryDate', 'createdDateIso', 'expiryDateIso', 'domainAgeDays',
136
+ 'expiresInDays', 'privacyProtected', 'dnssec', 'activityStatus',
137
+ 'websiteProbeStatus', 'websiteProbeDetail', 'http', 'deepScanComplete',
138
+ 'faviconHash', 'faviconPHash', 'pageTitle', 'hasPasswordField',
139
+ 'phishingLanguageMatch', 'hasExternalFormAction', 'externalAssetHosts',
140
+ 'pageIdentity', 'credentialSurfaceProfile', 'structuredDataIdentity',
141
+ 'technologyProfile', 'pageRoleProfile', 'clientBehaviorProfile',
142
+ 'securityPosture', 'dns', 'tls', 'hasMx', 'hasNullMx', 'mxHosts', 'hasSpf',
143
+ 'hasDmarc', 'bulkComparison', 'limitations',
144
+ ];
145
+ const LOOKUP_AVAILABILITY_ANALYSIS_KEYS = PUBLIC_LOOKUP_AVAILABILITY_ANALYSIS_KEYS.filter((key) => !['registrar', 'registrant', 'abuse'].includes(key));
146
+ const LOOKUP_IDN_ANALYSIS_KEYS = [
147
+ 'version', 'mappingVersion', 'asciiDomain', 'unicodeDomain', 'hasIdn',
148
+ 'scripts', 'labels', 'mixedScript', 'skeleton', 'referenceMatches', 'findings',
149
+ 'truncated', 'limitations',
150
+ ];
151
+ function validateLookupEvidenceHomepageMetadata(availability, version) {
152
+ const pageIdentity = availability.pageIdentity === null || availability.pageIdentity === undefined
153
+ ? null
154
+ : record(availability.pageIdentity, 'Lookup evidence page identity');
155
+ const http = availability.http === null || availability.http === undefined
156
+ ? null
157
+ : record(availability.http, 'Lookup evidence HTTP analysis');
158
+ const response = http?.response === null || http?.response === undefined
159
+ ? null
160
+ : record(http.response, 'Lookup evidence HTTP response');
161
+ const publicationPresent = Boolean(pageIdentity && Object.hasOwn(pageIdentity, 'publicationMetadata'));
162
+ const deliveryPresent = Boolean(response && Object.hasOwn(response, 'deliveryMetadata'));
163
+ if (version < LOOKUP_EVIDENCE_SCHEMA_VERSION && (publicationPresent || deliveryPresent)) {
164
+ fail('Lookup evidence homepage metadata epoch');
165
+ }
166
+ if (publicationPresent && !validPagePublicationMetadata(pageIdentity?.publicationMetadata)) {
167
+ fail('Lookup evidence page publication metadata');
168
+ }
169
+ if (publicationPresent && !['success', 'partial'].includes(String(pageIdentity?.status))) {
170
+ fail('Lookup evidence page publication metadata source state');
171
+ }
172
+ if (deliveryPresent && !validHttpDeliveryMetadata(response?.deliveryMetadata)) {
173
+ fail('Lookup evidence HTTP delivery metadata');
174
+ }
175
+ if (deliveryPresent && !['success', 'partial'].includes(String(http?.status))) {
176
+ fail('Lookup evidence HTTP delivery metadata source state');
177
+ }
178
+ }
179
+ function validateLookupEvidenceRdapAttempt(value, label) {
180
+ const attempt = exact(value, ['endpoint', 'transportSecurity', 'status', 'outcome', 'detail', 'selected'], label);
181
+ let endpoint = null;
182
+ if (attempt.endpoint !== null)
183
+ endpoint = absoluteUrl(attempt.endpoint, `${label} endpoint`, ['http:', 'https:']);
184
+ if (attempt.transportSecurity !== null)
185
+ enumeration(attempt.transportSecurity, ['http', 'https'], `${label} transport`);
186
+ if (endpoint && attempt.transportSecurity !== endpoint.protocol.slice(0, -1))
187
+ fail(`${label} transport`);
188
+ if (!endpoint && attempt.transportSecurity !== null)
189
+ fail(`${label} transport`);
190
+ if (attempt.status !== null)
191
+ integer(attempt.status, `${label} status`, 100, 599);
192
+ enumeration(attempt.outcome, LOOKUP_RDAP_ATTEMPT_OUTCOMES, `${label} outcome`);
193
+ optionalText(attempt.detail, `${label} detail`, 240);
194
+ boolean(attempt.selected, `${label} selection`);
195
+ }
196
+ function validateLookupEvidenceWhoisHop(value, label) {
197
+ const hop = exact(value, ['server', 'address', 'queriedAt', 'queryProfile', 'responseEncoding', 'status', 'detail'], label);
198
+ optionalText(hop.server, `${label} server`, 253);
199
+ optionalText(hop.address, `${label} address`, 64);
200
+ iso(hop.queriedAt, `${label} queriedAt`, true);
201
+ if (hop.queryProfile !== null)
202
+ enumeration(hop.queryProfile, [
203
+ 'plain-domain', 'denic-domain-ace', 'jprs-domain-english',
204
+ 'registry-domain-unicode', 'not-issued',
205
+ ], `${label} query profile`);
206
+ if (hop.responseEncoding !== null)
207
+ enumeration(hop.responseEncoding, ['utf-8'], `${label} response encoding`);
208
+ enumeration(hop.status, ['success', 'error', 'not_issued', 'unknown'], `${label} status`);
209
+ optionalText(hop.detail, `${label} detail`, 240);
210
+ }
211
+ function validateLookupEvidenceTiming(value) {
212
+ if (value === null)
213
+ return;
214
+ const timing = exact(value, ['version', 'totalMs', 'sources'], 'Lookup evidence timing');
215
+ if (timing.version !== 1)
216
+ fail('Lookup evidence timing');
217
+ if (timing.totalMs !== null)
218
+ integer(timing.totalMs, 'Lookup evidence total timing', 0, 120_000);
219
+ const seen = new Set();
220
+ array(timing.sources, 'Lookup evidence timing sources', LOOKUP_TIMING_SOURCES.length)
221
+ .forEach((item, index) => {
222
+ const entry = exact(item, ['source', 'outcome', 'durationMs', 'completedAfterMs'], `Lookup evidence timing source ${index + 1}`);
223
+ const source = enumeration(entry.source, LOOKUP_TIMING_SOURCES, `Lookup evidence timing source ${index + 1}`);
224
+ if (seen.has(source))
225
+ fail('Lookup evidence timing source');
226
+ seen.add(source);
227
+ enumeration(entry.outcome, ['fulfilled', 'rejected'], `Lookup evidence timing outcome ${index + 1}`);
228
+ if (entry.durationMs !== null)
229
+ integer(entry.durationMs, `Lookup evidence timing duration ${index + 1}`, 0, 120_000);
230
+ if (entry.completedAfterMs !== null)
231
+ integer(entry.completedAfterMs, `Lookup evidence timing completion ${index + 1}`, 0, 120_000);
232
+ });
233
+ }
234
+ function validateLookupEvidenceRegistryAccess(value) {
235
+ if (value === null)
236
+ return;
237
+ const access = exactOptional(value, [
238
+ 'suffix', 'coverageState', 'whoisAccessProfile', 'rdapAccessProfile',
239
+ 'limitation', 'authority',
240
+ ], ['officialLookupUrl'], 'Lookup evidence registry access');
241
+ optionalText(access.suffix, 'Lookup evidence registry suffix', 253);
242
+ optionalText(access.coverageState, 'Lookup evidence registry coverage', 40);
243
+ optionalText(access.whoisAccessProfile, 'Lookup evidence WHOIS access profile', 80);
244
+ optionalText(access.rdapAccessProfile, 'Lookup evidence RDAP access profile', 80);
245
+ if (access.officialLookupUrl !== undefined && access.officialLookupUrl !== null)
246
+ absoluteUrl(access.officialLookupUrl, 'Lookup evidence official lookup URL', ['http:', 'https:']);
247
+ optionalText(access.limitation, 'Lookup evidence registry limitation', 500);
248
+ if (access.authority !== null)
249
+ enumeration(access.authority, ['context_only'], 'Lookup evidence registry authority');
250
+ }
251
+ function validateLookupEvidenceRegistrarDiagnostic(value) {
252
+ if (value === null)
253
+ return;
254
+ const registrar = exact(value, ['status', 'endpoint', 'transportSecurity', 'httpStatus', 'fetchedAt', 'attempt'], 'Lookup evidence registrar diagnostic');
255
+ enumeration(registrar.status, ['success', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled'], 'Lookup evidence registrar diagnostic status');
256
+ let endpoint = null;
257
+ if (registrar.endpoint !== null)
258
+ endpoint = absoluteUrl(registrar.endpoint, 'Lookup evidence registrar diagnostic endpoint', ['http:', 'https:']);
259
+ if (registrar.transportSecurity !== null)
260
+ enumeration(registrar.transportSecurity, ['http', 'https'], 'Lookup evidence registrar diagnostic transport');
261
+ if (endpoint && registrar.transportSecurity !== endpoint.protocol.slice(0, -1))
262
+ fail('Lookup evidence registrar diagnostic transport');
263
+ if (!endpoint && registrar.transportSecurity !== null)
264
+ fail('Lookup evidence registrar diagnostic transport');
265
+ if (registrar.httpStatus !== null)
266
+ integer(registrar.httpStatus, 'Lookup evidence registrar diagnostic HTTP status', 100, 599);
267
+ iso(registrar.fetchedAt, 'Lookup evidence registrar diagnostic fetchedAt', true);
268
+ if (registrar.attempt !== null)
269
+ validateLookupEvidenceRdapAttempt(registrar.attempt, 'Lookup evidence registrar diagnostic attempt');
270
+ }
271
+ function validateLookupEvidenceCurrentDiagnostics(value) {
272
+ const diagnostics = exactOptional(value, ['rdap', 'whois'], [
273
+ 'version', 'timing', 'registryAccess', 'availability', 'reverseDns',
274
+ 'network', 'securityTxt', 'sslbl',
275
+ ], 'Lookup evidence diagnostics');
276
+ if (diagnostics.version !== undefined)
277
+ integer(diagnostics.version, 'Lookup evidence diagnostics version', 1, 10);
278
+ if (diagnostics.timing !== undefined)
279
+ validateLookupEvidenceTiming(diagnostics.timing);
280
+ if (diagnostics.registryAccess !== undefined)
281
+ validateLookupEvidenceRegistryAccess(diagnostics.registryAccess);
282
+ const rdap = exactOptional(diagnostics.rdap, ['status', 'errorCode', 'endpoint', 'transportSecurity', 'httpStatus', 'fetchedAt', 'attempts'], ['registrar'], 'Lookup evidence RDAP diagnostics');
283
+ const rdapStatus = enumeration(rdap.status, ['success', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled'], 'Lookup evidence RDAP diagnostic status');
284
+ optionalText(rdap.errorCode, 'Lookup evidence RDAP diagnostic error', 80);
285
+ let rdapEndpoint = null;
286
+ if (rdap.endpoint !== null)
287
+ rdapEndpoint = absoluteUrl(rdap.endpoint, 'Lookup evidence RDAP diagnostic endpoint', ['http:', 'https:']);
288
+ if (rdap.transportSecurity !== null)
289
+ enumeration(rdap.transportSecurity, ['http', 'https'], 'Lookup evidence RDAP diagnostic transport');
290
+ if (rdapEndpoint && rdap.transportSecurity !== rdapEndpoint.protocol.slice(0, -1))
291
+ fail('Lookup evidence RDAP diagnostic transport');
292
+ if (!rdapEndpoint && rdap.transportSecurity !== null)
293
+ fail('Lookup evidence RDAP diagnostic transport');
294
+ if (rdap.httpStatus !== null)
295
+ integer(rdap.httpStatus, 'Lookup evidence RDAP diagnostic HTTP status', 100, 599);
296
+ iso(rdap.fetchedAt, 'Lookup evidence RDAP diagnostic fetchedAt', true);
297
+ array(rdap.attempts, 'Lookup evidence RDAP diagnostic attempts', 16)
298
+ .forEach((item, index) => validateLookupEvidenceRdapAttempt(item, `Lookup evidence RDAP diagnostic attempt ${index + 1}`));
299
+ if (rdap.registrar !== undefined)
300
+ validateLookupEvidenceRegistrarDiagnostic(rdap.registrar);
301
+ const whois = exact(diagnostics.whois, ['status', 'errorCode', 'queriedAt', 'authoritativeHop', 'failedHop', 'conflictingHop'], 'Lookup evidence WHOIS diagnostics');
302
+ const whoisStatus = enumeration(whois.status, ['complete', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled'], 'Lookup evidence WHOIS diagnostic status');
303
+ optionalText(whois.errorCode, 'Lookup evidence WHOIS diagnostic error', 80);
304
+ iso(whois.queriedAt, 'Lookup evidence WHOIS diagnostic queriedAt', true);
305
+ for (const key of ['authoritativeHop', 'failedHop', 'conflictingHop'])
306
+ optionalText(whois[key], `Lookup evidence WHOIS diagnostic ${key}`, 253);
307
+ if (diagnostics.availability !== undefined) {
308
+ const availability = exact(diagnostics.availability, ['status', 'errorCode', 'resultState'], 'Lookup evidence availability diagnostics');
309
+ enumeration(availability.status, ['complete', 'error', 'disabled', 'not_applicable'], 'Lookup evidence availability diagnostic status');
310
+ optionalText(availability.errorCode, 'Lookup evidence availability diagnostic error', 80);
311
+ optionalText(availability.resultState, 'Lookup evidence availability result', 40);
312
+ }
313
+ let reverseDnsState = null;
314
+ if (diagnostics.reverseDns !== undefined) {
315
+ const reverseDns = exact(diagnostics.reverseDns, ['status', 'observedAt', 'complete', 'truncated'], 'Lookup evidence reverse DNS diagnostics');
316
+ const status = enumeration(reverseDns.status, ['success', 'partial', 'not_found', 'unsupported', 'skipped', 'error'], 'Lookup evidence reverse DNS diagnostic status');
317
+ iso(reverseDns.observedAt, 'Lookup evidence reverse DNS diagnostic observedAt', true);
318
+ boolean(reverseDns.complete, 'Lookup evidence reverse DNS diagnostic completeness');
319
+ boolean(reverseDns.truncated, 'Lookup evidence reverse DNS diagnostic truncation');
320
+ const expectedComplete = reverseDns.truncated !== true && (status === 'success' || status === 'not_found');
321
+ if (reverseDns.complete !== expectedComplete)
322
+ fail('Lookup evidence reverse DNS diagnostic completeness');
323
+ reverseDnsState = { status, complete: Boolean(reverseDns.complete), truncated: Boolean(reverseDns.truncated) };
324
+ }
325
+ let networkState = null;
326
+ if (diagnostics.network !== undefined) {
327
+ const network = exact(diagnostics.network, ['status', 'address', 'family', 'addressSource', 'endpoint', 'transportSecurity', 'httpStatus', 'fetchedAt', 'attempts'], 'Lookup evidence network diagnostics');
328
+ networkState = enumeration(network.status, ['success', 'partial', 'not_found', 'unsupported', 'error'], 'Lookup evidence network diagnostic status');
329
+ optionalText(network.address, 'Lookup evidence network diagnostic address', 64);
330
+ if (network.family !== null && network.family !== 4 && network.family !== 6)
331
+ fail('Lookup evidence network diagnostic family');
332
+ if (network.addressSource !== null)
333
+ enumeration(network.addressSource, ['tls_connection', 'dns_a', 'dns_aaaa'], 'Lookup evidence network diagnostic address source');
334
+ let endpoint = null;
335
+ if (network.endpoint !== null)
336
+ endpoint = absoluteUrl(network.endpoint, 'Lookup evidence network diagnostic endpoint', ['http:', 'https:']);
337
+ if (network.transportSecurity !== null)
338
+ enumeration(network.transportSecurity, ['http', 'https'], 'Lookup evidence network diagnostic transport');
339
+ if (endpoint && network.transportSecurity !== endpoint.protocol.slice(0, -1))
340
+ fail('Lookup evidence network diagnostic transport');
341
+ if (!endpoint && network.transportSecurity !== null)
342
+ fail('Lookup evidence network diagnostic transport');
343
+ if (network.httpStatus !== null)
344
+ integer(network.httpStatus, 'Lookup evidence network diagnostic HTTP status', 100, 599);
345
+ iso(network.fetchedAt, 'Lookup evidence network diagnostic fetchedAt', true);
346
+ array(network.attempts, 'Lookup evidence network diagnostic attempts', 3)
347
+ .forEach((item, index) => validateLookupEvidenceRdapAttempt(item, `Lookup evidence network diagnostic attempt ${index + 1}`));
348
+ }
349
+ if (diagnostics.securityTxt !== undefined) {
350
+ const securityTxt = exact(diagnostics.securityTxt, ['status', 'state', 'endpoint', 'httpStatus', 'observedAt', 'complete', 'truncated'], 'Lookup evidence security.txt diagnostics');
351
+ optionalText(securityTxt.status, 'Lookup evidence security.txt diagnostic status', 40);
352
+ if (securityTxt.state !== null)
353
+ enumeration(securityTxt.state, ['present', 'stale', 'partial', 'absent', 'malformed', 'unsupported', 'unavailable'], 'Lookup evidence security.txt diagnostic state');
354
+ if (securityTxt.endpoint !== null)
355
+ absoluteUrl(securityTxt.endpoint, 'Lookup evidence security.txt diagnostic endpoint', ['https:']);
356
+ if (securityTxt.httpStatus !== null)
357
+ integer(securityTxt.httpStatus, 'Lookup evidence security.txt diagnostic HTTP status', 100, 599);
358
+ iso(securityTxt.observedAt, 'Lookup evidence security.txt diagnostic observedAt', true);
359
+ boolean(securityTxt.complete, 'Lookup evidence security.txt diagnostic completeness');
360
+ boolean(securityTxt.truncated, 'Lookup evidence security.txt diagnostic truncation');
361
+ }
362
+ if (diagnostics.sslbl !== undefined) {
363
+ const sslbl = exact(diagnostics.sslbl, ['status', 'verdict', 'observedAt', 'complete', 'snapshotUpdatedAt'], 'Lookup evidence SSLBL diagnostics');
364
+ if (sslbl.status !== null)
365
+ enumeration(sslbl.status, ['success', 'stale', 'unavailable'], 'Lookup evidence SSLBL diagnostic status');
366
+ if (sslbl.verdict !== null)
367
+ enumeration(sslbl.verdict, ['listed', 'not_listed', 'inconclusive'], 'Lookup evidence SSLBL diagnostic verdict');
368
+ iso(sslbl.observedAt, 'Lookup evidence SSLBL diagnostic observedAt', true);
369
+ boolean(sslbl.complete, 'Lookup evidence SSLBL diagnostic completeness');
370
+ iso(sslbl.snapshotUpdatedAt, 'Lookup evidence SSLBL diagnostic snapshot timestamp', true);
371
+ }
372
+ return { rdap: rdapStatus, whois: whoisStatus, reverseDns: reverseDnsState, network: networkState };
373
+ }
374
+ function validateLookupEvidenceTree(value) {
375
+ try {
376
+ assertLookupEvidencePortableTree(value);
377
+ }
378
+ catch {
379
+ fail('Lookup evidence portable bounds');
380
+ }
381
+ }
382
+ function validateLookupEvidenceRdap(value, version) {
383
+ const source = record(value, 'Lookup evidence RDAP source');
384
+ if (source.status === 'error') {
385
+ const error = exact(source, ['status', 'error', 'attempts'], 'Lookup evidence RDAP source');
386
+ text(error.error, 'Lookup evidence RDAP error', 10_000);
387
+ array(error.attempts, 'Lookup evidence RDAP attempts', 16).forEach((item, index) => {
388
+ validateLookupEvidenceRdapAttempt(item, `Lookup evidence RDAP attempt ${index + 1}`);
389
+ });
390
+ return 'error';
391
+ }
392
+ const complete = exact(source, version >= LOOKUP_EVIDENCE_SCHEMA_VERSION
393
+ ? ['status', 'endpoint', 'transportSecurity', 'httpStatus', 'fetchedAt', 'attempts', 'parsed']
394
+ : ['status', 'endpoint', 'transportSecurity', 'httpStatus', 'fetchedAt', 'attempts', 'parsed', 'raw'], 'Lookup evidence RDAP source');
395
+ const status = enumeration(complete.status, ['success', 'partial', 'not_found', 'unsupported', 'skipped', 'disabled'], 'Lookup evidence RDAP status');
396
+ let endpoint = null;
397
+ if (complete.endpoint !== null)
398
+ endpoint = absoluteUrl(complete.endpoint, 'Lookup evidence RDAP endpoint', ['http:', 'https:']);
399
+ if (complete.transportSecurity !== null)
400
+ enumeration(complete.transportSecurity, ['http', 'https'], 'Lookup evidence RDAP transport');
401
+ if (endpoint && complete.transportSecurity !== endpoint.protocol.slice(0, -1))
402
+ fail('Lookup evidence RDAP transport');
403
+ if (!endpoint && complete.transportSecurity !== null)
404
+ fail('Lookup evidence RDAP transport');
405
+ if (complete.httpStatus !== null)
406
+ integer(complete.httpStatus, 'Lookup evidence RDAP HTTP status', 100, 599);
407
+ iso(complete.fetchedAt, 'Lookup evidence RDAP fetchedAt', true);
408
+ array(complete.attempts, 'Lookup evidence RDAP attempts', 16).forEach((item, index) => {
409
+ validateLookupEvidenceRdapAttempt(item, `Lookup evidence RDAP attempt ${index + 1}`);
410
+ });
411
+ nullableRecord(complete.parsed, 'Lookup evidence RDAP parsed data');
412
+ if (version < LOOKUP_EVIDENCE_SCHEMA_VERSION)
413
+ nullableRecord(complete.raw, 'Lookup evidence RDAP raw data');
414
+ if (version >= LOOKUP_EVIDENCE_SCHEMA_VERSION
415
+ && !isDeepStrictEqual(complete.parsed, projectLookupEvidenceRdapPublication(complete.parsed))) {
416
+ fail('Lookup evidence RDAP portable publication');
417
+ }
418
+ if (status === 'success' && complete.parsed === null)
419
+ fail('Lookup evidence RDAP publication');
420
+ if (!['success', 'partial'].includes(status)
421
+ && (complete.parsed !== null || (version < LOOKUP_EVIDENCE_SCHEMA_VERSION && complete.raw !== null)))
422
+ fail('Lookup evidence RDAP unavailable publication');
423
+ return status;
424
+ }
425
+ function validateLookupEvidenceWhois(value, version) {
426
+ const source = record(value, 'Lookup evidence WHOIS source');
427
+ if (source.status === 'error') {
428
+ const error = exact(source, ['status', 'error'], 'Lookup evidence WHOIS source');
429
+ text(error.error, 'Lookup evidence WHOIS error', 10_000);
430
+ return 'error';
431
+ }
432
+ const complete = exact(source, ['status', 'queriedAt', 'authoritativeHop', 'failedHop', 'conflictingHop', 'parsed', 'chain'], 'Lookup evidence WHOIS source');
433
+ const status = enumeration(complete.status, ['complete', 'partial', 'not_found', 'unsupported', 'skipped', 'disabled'], 'Lookup evidence WHOIS status');
434
+ iso(complete.queriedAt, 'Lookup evidence WHOIS queriedAt', true);
435
+ optionalText(complete.authoritativeHop, 'Lookup evidence WHOIS authoritative hop', 253);
436
+ optionalText(complete.failedHop, 'Lookup evidence WHOIS failed hop', 253);
437
+ optionalText(complete.conflictingHop, 'Lookup evidence WHOIS conflicting hop', 253);
438
+ nullableRecord(complete.parsed, 'Lookup evidence WHOIS parsed data');
439
+ if (version >= LOOKUP_EVIDENCE_SCHEMA_VERSION
440
+ && !isDeepStrictEqual(complete.parsed, projectLookupEvidenceWhoisPublication(complete.parsed))) {
441
+ fail('Lookup evidence WHOIS portable publication');
442
+ }
443
+ array(complete.chain, 'Lookup evidence WHOIS chain', 16).forEach((item, index) => {
444
+ validateLookupEvidenceWhoisHop(item, `Lookup evidence WHOIS chain item ${index + 1}`);
445
+ });
446
+ if (status === 'complete' && complete.parsed === null)
447
+ fail('Lookup evidence WHOIS publication');
448
+ if (!['complete', 'partial'].includes(status)
449
+ && (complete.parsed !== null || complete.chain.length !== 0))
450
+ fail('Lookup evidence WHOIS unavailable publication');
451
+ return status;
452
+ }
453
+ function validateLookupEvidenceReverseDns(value) {
454
+ if (value === null)
455
+ return null;
456
+ const source = exact(value, ['version', 'status', 'observedAt', 'scanMode', 'source', 'durationMs', 'complete', 'truncated', 'limitations', 'diagnostics', 'records'], 'Lookup evidence reverse DNS source');
457
+ if (source.version !== 1 || source.source !== 'reverse_dns')
458
+ fail('Lookup evidence reverse DNS source');
459
+ const status = enumeration(source.status, ['success', 'partial', 'not_found', 'unsupported', 'skipped', 'error'], 'Lookup evidence reverse DNS status');
460
+ iso(source.observedAt, 'Lookup evidence reverse DNS observedAt', true);
461
+ if (source.scanMode !== null)
462
+ enumeration(source.scanMode, ['deep'], 'Lookup evidence reverse DNS mode');
463
+ if (source.durationMs !== null)
464
+ integer(source.durationMs, 'Lookup evidence reverse DNS duration', 0, 120_000);
465
+ boolean(source.complete, 'Lookup evidence reverse DNS completeness');
466
+ boolean(source.truncated, 'Lookup evidence reverse DNS truncation');
467
+ strings(source.limitations, 'Lookup evidence reverse DNS limitations', 10, 300);
468
+ if (source.diagnostics !== null) {
469
+ const diagnostics = exact(source.diagnostics, ['ptr'], 'Lookup evidence reverse DNS diagnostics');
470
+ const ptr = exact(diagnostics.ptr, ['status', 'error', 'truncated', 'discarded'], 'Lookup evidence reverse DNS PTR diagnostics');
471
+ optionalText(ptr.status, 'Lookup evidence reverse DNS PTR status', 40);
472
+ optionalText(ptr.error, 'Lookup evidence reverse DNS PTR error', 180);
473
+ boolean(ptr.truncated, 'Lookup evidence reverse DNS PTR truncation');
474
+ if (ptr.discarded !== null)
475
+ integer(ptr.discarded, 'Lookup evidence reverse DNS PTR discarded count', 0, 1_000);
476
+ }
477
+ const records = exact(source.records, ['ptr'], 'Lookup evidence reverse DNS records');
478
+ const ptr = strings(records.ptr, 'Lookup evidence reverse DNS PTR records', 8, 253);
479
+ if (!['success', 'partial'].includes(status) && ptr.length)
480
+ fail('Lookup evidence reverse DNS unavailable records');
481
+ const expectedComplete = source.truncated !== true && (status === 'success' || status === 'not_found');
482
+ if (source.complete !== expectedComplete)
483
+ fail('Lookup evidence reverse DNS completeness');
484
+ return { status, complete: Boolean(source.complete), truncated: Boolean(source.truncated) };
485
+ }
486
+ function validateLookupEvidenceNetwork(value) {
487
+ if (value === null)
488
+ return null;
489
+ const source = exact(value, ['contextVersion', 'version', 'status', 'observedAt', 'scanMode', 'source', 'durationMs', 'complete', 'truncated', 'limitations', 'diagnostics', 'detail', 'endpoint', 'rdap', 'network'], 'Lookup evidence network source');
490
+ if (source.contextVersion !== 1 || (source.version !== 1 && source.version !== null))
491
+ fail('Lookup evidence network source');
492
+ const status = enumeration(source.status, ['success', 'partial', 'not_found', 'unsupported', 'error'], 'Lookup evidence network status');
493
+ iso(source.observedAt, 'Lookup evidence network observedAt', true);
494
+ if (source.scanMode !== null)
495
+ enumeration(source.scanMode, ['deep'], 'Lookup evidence network mode');
496
+ if (source.source !== null)
497
+ enumeration(source.source, ['ip_rdap'], 'Lookup evidence network kind');
498
+ if (source.durationMs !== null)
499
+ integer(source.durationMs, 'Lookup evidence network duration', 0, 120_000);
500
+ boolean(source.complete, 'Lookup evidence network completeness');
501
+ boolean(source.truncated, 'Lookup evidence network truncation');
502
+ strings(source.limitations, 'Lookup evidence network limitations', 10, 300);
503
+ if (source.diagnostics !== null) {
504
+ const diagnostics = exact(source.diagnostics, ['requestCount', 'addressSource', 'httpStatus', 'cidrCount'], 'Lookup evidence network diagnostics');
505
+ if (diagnostics.requestCount !== null)
506
+ integer(diagnostics.requestCount, 'Lookup evidence network request count', 0, 1);
507
+ if (diagnostics.addressSource !== null)
508
+ enumeration(diagnostics.addressSource, ['tls_connection', 'dns_a', 'dns_aaaa'], 'Lookup evidence network address source');
509
+ if (diagnostics.httpStatus !== null)
510
+ integer(diagnostics.httpStatus, 'Lookup evidence network HTTP status', 100, 599);
511
+ if (diagnostics.cidrCount !== null)
512
+ integer(diagnostics.cidrCount, 'Lookup evidence network CIDR count', 0, 16);
513
+ }
514
+ optionalText(source.detail, 'Lookup evidence network detail', 300);
515
+ if (source.endpoint !== null) {
516
+ const endpoint = exact(source.endpoint, ['address', 'family', 'selectedFrom'], 'Lookup evidence network endpoint');
517
+ optionalText(endpoint.address, 'Lookup evidence network address', 64);
518
+ if (endpoint.family !== null)
519
+ integer(endpoint.family, 'Lookup evidence network address family', 4, 6);
520
+ if (endpoint.family !== null && endpoint.family !== 4 && endpoint.family !== 6)
521
+ fail('Lookup evidence network address family');
522
+ if (endpoint.selectedFrom !== null)
523
+ enumeration(endpoint.selectedFrom, ['tls_connection', 'dns_a', 'dns_aaaa'], 'Lookup evidence network selected source');
524
+ }
525
+ if (source.rdap !== null) {
526
+ const rdap = exact(source.rdap, ['endpoint', 'transportSecurity', 'httpStatus', 'fetchedAt', 'attempts'], 'Lookup evidence network RDAP');
527
+ if (rdap.endpoint !== null)
528
+ absoluteUrl(rdap.endpoint, 'Lookup evidence network RDAP endpoint', ['http:', 'https:']);
529
+ if (rdap.transportSecurity !== null)
530
+ enumeration(rdap.transportSecurity, ['http', 'https'], 'Lookup evidence network RDAP transport');
531
+ if (rdap.httpStatus !== null)
532
+ integer(rdap.httpStatus, 'Lookup evidence network RDAP HTTP status', 100, 599);
533
+ iso(rdap.fetchedAt, 'Lookup evidence network RDAP fetchedAt', true);
534
+ array(rdap.attempts, 'Lookup evidence network RDAP attempts', 3).forEach((item, index) => {
535
+ const attempt = exact(item, ['endpoint', 'transportSecurity', 'status', 'outcome', 'detail', 'selected'], `Lookup evidence network RDAP attempt ${index + 1}`);
536
+ if (attempt.endpoint !== null)
537
+ absoluteUrl(attempt.endpoint, 'Lookup evidence network RDAP attempt endpoint', ['http:', 'https:']);
538
+ if (attempt.transportSecurity !== null)
539
+ enumeration(attempt.transportSecurity, ['http', 'https'], 'Lookup evidence network RDAP attempt transport');
540
+ if (attempt.status !== null)
541
+ integer(attempt.status, 'Lookup evidence network RDAP attempt status', 100, 599);
542
+ optionalText(attempt.outcome, 'Lookup evidence network RDAP attempt outcome', 40);
543
+ optionalText(attempt.detail, 'Lookup evidence network RDAP attempt detail', 240);
544
+ boolean(attempt.selected, 'Lookup evidence network RDAP attempt selection');
545
+ });
546
+ }
547
+ if (source.network !== null) {
548
+ const network = exact(source.network, ['handle', 'name', 'holder', 'cidrs', 'startAddress', 'endAddress', 'country', 'networkType', 'databaseUpdatedAt'], 'Lookup evidence network registration');
549
+ for (const key of ['handle', 'name', 'holder'])
550
+ optionalText(network[key], `Lookup evidence network ${key}`, 300);
551
+ strings(network.cidrs, 'Lookup evidence network CIDRs', 16, 96);
552
+ optionalText(network.startAddress, 'Lookup evidence network start address', 64);
553
+ optionalText(network.endAddress, 'Lookup evidence network end address', 64);
554
+ if (network.country !== null && !/^[A-Z]{2}$/u.test(String(network.country)))
555
+ fail('Lookup evidence network country');
556
+ optionalText(network.networkType, 'Lookup evidence network type', 160);
557
+ iso(network.databaseUpdatedAt, 'Lookup evidence network database timestamp', true);
558
+ }
559
+ if (!['success', 'partial'].includes(status)
560
+ && (source.endpoint !== null || source.rdap !== null || source.network !== null)) {
561
+ fail('Lookup evidence unavailable network publication');
562
+ }
563
+ const expectedComplete = source.truncated !== true && (status === 'success' || status === 'not_found');
564
+ if (source.complete !== expectedComplete)
565
+ fail('Lookup evidence network completeness');
566
+ return { status, complete: Boolean(source.complete), truncated: Boolean(source.truncated) };
567
+ }
568
+ function validateLookupEvidenceSecurityTxt(value) {
569
+ if (value === null)
570
+ return;
571
+ const source = exact(value, ['securityTxtVersion', 'version', 'state', 'status', 'observedAt', 'scanMode', 'source', 'durationMs', 'complete', 'truncated', 'limitations', 'detail', 'requestedUrl', 'finalUrl', 'httpStatus', 'redirectCount', 'expiresAt', 'signed', 'canonicalMatches', 'contacts', 'policies', 'encryption', 'canonical', 'preferredLanguages'], 'Lookup evidence security.txt source');
572
+ if (source.securityTxtVersion !== 1 || (source.version !== 1 && source.version !== null) || source.source !== 'security_txt')
573
+ fail('Lookup evidence security.txt source');
574
+ enumeration(source.state, ['present', 'stale', 'partial', 'absent', 'malformed', 'unsupported', 'unavailable'], 'Lookup evidence security.txt state');
575
+ optionalText(source.status, 'Lookup evidence security.txt status', 40);
576
+ iso(source.observedAt, 'Lookup evidence security.txt observedAt', true);
577
+ if (source.scanMode !== null)
578
+ enumeration(source.scanMode, ['deep'], 'Lookup evidence security.txt mode');
579
+ if (source.durationMs !== null)
580
+ integer(source.durationMs, 'Lookup evidence security.txt duration', 0, 120_000);
581
+ boolean(source.complete, 'Lookup evidence security.txt completeness');
582
+ boolean(source.truncated, 'Lookup evidence security.txt truncation');
583
+ strings(source.limitations, 'Lookup evidence security.txt limitations', 10, 300);
584
+ optionalText(source.detail, 'Lookup evidence security.txt detail', 300);
585
+ optionalText(source.requestedUrl, 'Lookup evidence security.txt requested URL', 2_048);
586
+ optionalText(source.finalUrl, 'Lookup evidence security.txt final URL', 2_048);
587
+ if (source.httpStatus !== null)
588
+ integer(source.httpStatus, 'Lookup evidence security.txt HTTP status', 100, 599);
589
+ if (source.redirectCount !== null)
590
+ integer(source.redirectCount, 'Lookup evidence security.txt redirects', 0, 3);
591
+ iso(source.expiresAt, 'Lookup evidence security.txt expiry', true);
592
+ boolean(source.signed, 'Lookup evidence security.txt signed state');
593
+ if (source.canonicalMatches !== null)
594
+ boolean(source.canonicalMatches, 'Lookup evidence security.txt canonical state');
595
+ strings(source.contacts, 'Lookup evidence security.txt contacts', 10, 2_048);
596
+ strings(source.policies, 'Lookup evidence security.txt policies', 10, 2_048);
597
+ strings(source.encryption, 'Lookup evidence security.txt encryption', 10, 2_048);
598
+ strings(source.canonical, 'Lookup evidence security.txt canonical URLs', 10, 2_048);
599
+ strings(source.preferredLanguages, 'Lookup evidence security.txt languages', 10, 40);
600
+ const expectedStatus = source.state === 'present'
601
+ ? 'success'
602
+ : source.state === 'absent'
603
+ ? 'not_found'
604
+ : source.state === 'unsupported'
605
+ ? 'unsupported'
606
+ : source.state === 'unavailable' || source.state === 'malformed'
607
+ ? 'error'
608
+ : 'partial';
609
+ if (source.status !== expectedStatus)
610
+ fail('Lookup evidence security.txt state');
611
+ if (source.complete !== (source.state === 'present' || source.state === 'absent'))
612
+ fail('Lookup evidence security.txt completeness');
613
+ }
614
+ function validateLookupEvidenceSslbl(value) {
615
+ if (value === null)
616
+ return;
617
+ const source = exact(value, ['sslblVersion', 'source', 'status', 'verdict', 'complete', 'observedAt', 'fingerprintSha1', 'referenceUrl', 'snapshot', 'detail', 'limitations'], 'Lookup evidence SSLBL source');
618
+ if (source.sslblVersion !== 1 || source.source !== 'sslbl')
619
+ fail('Lookup evidence SSLBL source');
620
+ enumeration(source.status, ['success', 'stale', 'unavailable'], 'Lookup evidence SSLBL status');
621
+ enumeration(source.verdict, ['listed', 'not_listed', 'inconclusive'], 'Lookup evidence SSLBL verdict');
622
+ boolean(source.complete, 'Lookup evidence SSLBL completeness');
623
+ iso(source.observedAt, 'Lookup evidence SSLBL observedAt', true);
624
+ if (source.fingerprintSha1 !== null && !/^[a-f0-9]{40}$/u.test(String(source.fingerprintSha1)))
625
+ fail('Lookup evidence SSLBL fingerprint');
626
+ optionalText(source.referenceUrl, 'Lookup evidence SSLBL reference', 2_048);
627
+ if (source.snapshot === null)
628
+ fail('Lookup evidence SSLBL snapshot');
629
+ const snapshot = exact(source.snapshot, ['sourceUpdatedAt', 'generatedAt', 'ageSeconds', 'entryCount', 'digestSha256'], 'Lookup evidence SSLBL snapshot');
630
+ iso(snapshot.sourceUpdatedAt, 'Lookup evidence SSLBL source timestamp', true);
631
+ iso(snapshot.generatedAt, 'Lookup evidence SSLBL generated timestamp', true);
632
+ if (snapshot.ageSeconds !== null)
633
+ integer(snapshot.ageSeconds, 'Lookup evidence SSLBL age', 0, 31_536_000);
634
+ if (snapshot.entryCount !== null)
635
+ integer(snapshot.entryCount, 'Lookup evidence SSLBL entry count', 0, 50_000);
636
+ if (snapshot.digestSha256 !== null && !HEX_DIGEST_RE.test(String(snapshot.digestSha256)))
637
+ fail('Lookup evidence SSLBL digest');
638
+ optionalText(source.detail, 'Lookup evidence SSLBL detail', 300);
639
+ strings(source.limitations, 'Lookup evidence SSLBL limitations', 10, 300);
640
+ const validState = (source.verdict === 'not_listed' && source.status === 'success' && source.complete === true)
641
+ || (source.verdict === 'listed'
642
+ && (source.status === 'success' || source.status === 'stale')
643
+ && source.complete === (source.status === 'success'))
644
+ || (source.verdict === 'inconclusive'
645
+ && (source.status === 'stale' || source.status === 'unavailable')
646
+ && source.complete === false);
647
+ if (!validState)
648
+ fail('Lookup evidence SSLBL state');
649
+ if (source.referenceUrl !== null) {
650
+ if (source.verdict !== 'listed' || source.fingerprintSha1 === null)
651
+ fail('Lookup evidence SSLBL reference');
652
+ const reference = absoluteUrl(source.referenceUrl, 'Lookup evidence SSLBL reference', ['https:']);
653
+ if (reference.hostname !== 'sslbl.abuse.ch'
654
+ || reference.port
655
+ || reference.pathname !== `/ssl-certificates/sha1/${String(source.fingerprintSha1)}/`)
656
+ fail('Lookup evidence SSLBL reference');
657
+ }
658
+ }
659
+ const LOOKUP_REGISTRAR_COMPARISON_STATUSES = [
660
+ 'equivalent', 'conflict', 'registry_only', 'registrar_only',
661
+ 'registry_redacted', 'registrar_redacted', 'registry_unavailable',
662
+ 'registrar_unavailable', 'registry_incomplete', 'registrar_incomplete',
663
+ ];
664
+ function lookupPublicationCondition(status) {
665
+ if (status === 'partial')
666
+ return 'incomplete';
667
+ return ['error', 'unsupported', 'not_found', 'skipped', 'disabled'].includes(status)
668
+ ? 'unavailable'
669
+ : 'complete';
670
+ }
671
+ function validateLookupEvidenceRegistrarComparison(value) {
672
+ if (value === null)
673
+ return;
674
+ const comparison = exact(value, ['fields', 'counts', 'sourceHealth'], 'Lookup evidence registrar comparison');
675
+ const counts = exact(comparison.counts, LOOKUP_REGISTRAR_COMPARISON_STATUSES, 'Lookup evidence registrar comparison counts');
676
+ const observedCounts = new Map();
677
+ const labels = new Set();
678
+ array(comparison.fields, 'Lookup evidence registrar comparison fields', 9).forEach((item, index) => {
679
+ const field = exact(item, [
680
+ 'label', 'status', 'registryState', 'registrarState', 'registryDisplay', 'registrarDisplay',
681
+ ], `Lookup evidence registrar comparison field ${index + 1}`);
682
+ const label = enumeration(field.label, [
683
+ 'Domain', 'Registrar', 'Registrar IANA ID', 'Created', 'Expires',
684
+ 'Last updated', 'DNSSEC', 'Statuses', 'Name servers',
685
+ ], `Lookup evidence registrar comparison field ${index + 1} label`);
686
+ if (labels.has(label))
687
+ fail('Lookup evidence registrar comparison fields');
688
+ labels.add(label);
689
+ const status = enumeration(field.status, LOOKUP_REGISTRAR_COMPARISON_STATUSES, `Lookup evidence registrar comparison field ${index + 1} status`);
690
+ enumeration(field.registryState, ['absent', 'redacted', 'value', 'incomplete', 'unavailable'], `Lookup evidence registrar comparison field ${index + 1} registry state`);
691
+ enumeration(field.registrarState, ['absent', 'redacted', 'value', 'incomplete', 'unavailable'], `Lookup evidence registrar comparison field ${index + 1} registrar state`);
692
+ text(field.registryDisplay, `Lookup evidence registrar comparison field ${index + 1} registry display`, 2_000);
693
+ text(field.registrarDisplay, `Lookup evidence registrar comparison field ${index + 1} registrar display`, 2_000);
694
+ observedCounts.set(status, (observedCounts.get(status) ?? 0) + 1);
695
+ });
696
+ for (const status of LOOKUP_REGISTRAR_COMPARISON_STATUSES) {
697
+ const count = integer(counts[status], `Lookup evidence registrar comparison ${status} count`, 0, 9);
698
+ if (count !== (observedCounts.get(status) ?? 0))
699
+ fail('Lookup evidence registrar comparison counts');
700
+ }
701
+ const sourceHealth = exact(comparison.sourceHealth, ['registry', 'registrar'], 'Lookup evidence registrar comparison source health');
702
+ for (const source of ['registry', 'registrar']) {
703
+ const health = exact(sourceHealth[source], ['status', 'condition'], `Lookup evidence registrar comparison ${source} health`);
704
+ const status = enumeration(health.status, [
705
+ 'success', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled',
706
+ ], `Lookup evidence registrar comparison ${source} status`);
707
+ if (health.condition !== lookupPublicationCondition(status))
708
+ fail(`Lookup evidence registrar comparison ${source} condition`);
709
+ }
710
+ }
711
+ export function validateLookupEvidenceArtifactStructure(value) {
712
+ validateLookupEvidenceTree(value);
713
+ const root = exact(value, ['schema', 'schemaVersion', 'generatedAt', 'application', 'query', 'diagnostics', 'sources', 'analysis'], 'Lookup evidence');
714
+ if (root.schema !== LOOKUP_EVIDENCE_SCHEMA
715
+ || !SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS.includes(Number(root.schemaVersion)))
716
+ fail('Lookup evidence');
717
+ const version = integer(root.schemaVersion, 'Lookup evidence version', PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION, LOOKUP_EVIDENCE_SCHEMA_VERSION);
718
+ iso(root.generatedAt, 'Lookup evidence generatedAt');
719
+ const application = exact(root.application, ['name', 'version', 'projectUrl'], 'Lookup evidence application');
720
+ if (application.name !== 'WHOISleuth' || application.projectUrl !== WHOISLEUTH_SOURCE_REPOSITORY_URL)
721
+ fail('Lookup evidence application');
722
+ if (application.version !== null && (typeof application.version !== 'string' || !SEMANTIC_VERSION_RE.test(application.version)))
723
+ fail('Lookup evidence application version');
724
+ const query = exact(root.query, ['submitted', 'type', 'inputHostname', 'registrableDomain', 'isSubdomain'], 'Lookup evidence query');
725
+ text(query.submitted, 'Lookup evidence submitted query', 1_024);
726
+ const queryType = enumeration(query.type, ['domain', 'ipv4', 'ipv6', 'asn'], 'Lookup evidence query type');
727
+ optionalText(query.inputHostname, 'Lookup evidence input hostname', 253);
728
+ optionalText(query.registrableDomain, 'Lookup evidence registrable domain', 253);
729
+ boolean(query.isSubdomain, 'Lookup evidence subdomain state');
730
+ if (queryType === 'domain') {
731
+ if (query.inputHostname !== null)
732
+ domain(query.inputHostname, 'Lookup evidence input hostname');
733
+ domain(query.registrableDomain, 'Lookup evidence registrable domain');
734
+ }
735
+ else if (query.inputHostname !== null || query.registrableDomain !== null || query.isSubdomain !== false)
736
+ fail('Lookup evidence query');
737
+ if (!isDeepStrictEqual(query, projectLookupEvidenceQuery(query)))
738
+ fail('Lookup evidence query');
739
+ let diagnostics;
740
+ let rdapDiagnosticStatus;
741
+ let whoisDiagnosticStatus;
742
+ diagnostics = record(root.diagnostics, 'Lookup evidence diagnostics');
743
+ const currentDiagnosticStates = validateLookupEvidenceCurrentDiagnostics(diagnostics);
744
+ rdapDiagnosticStatus = currentDiagnosticStates.rdap;
745
+ whoisDiagnosticStatus = currentDiagnosticStates.whois;
746
+ const sources = exact(root.sources, ['rdap', 'whois', 'reverseDns', 'network', 'securityTxt', 'sslbl'], 'Lookup evidence sources');
747
+ const rdapSourceStatus = validateLookupEvidenceRdap(sources.rdap, version);
748
+ const whoisSourceStatus = validateLookupEvidenceWhois(sources.whois, version);
749
+ if (rdapSourceStatus !== rdapDiagnosticStatus)
750
+ fail('Lookup evidence RDAP source state');
751
+ if (whoisSourceStatus !== whoisDiagnosticStatus)
752
+ fail('Lookup evidence WHOIS source state');
753
+ const reverseDnsState = validateLookupEvidenceReverseDns(sources.reverseDns);
754
+ const networkState = validateLookupEvidenceNetwork(sources.network);
755
+ if (currentDiagnosticStates.reverseDns
756
+ && !isDeepStrictEqual(reverseDnsState, currentDiagnosticStates.reverseDns)) {
757
+ fail('Lookup evidence reverse DNS source state');
758
+ }
759
+ if (currentDiagnosticStates.network !== null
760
+ && (networkState?.status ?? null) !== currentDiagnosticStates.network) {
761
+ fail('Lookup evidence network source state');
762
+ }
763
+ validateLookupEvidenceSecurityTxt(sources.securityTxt);
764
+ validateLookupEvidenceSslbl(sources.sslbl);
765
+ const analysis = exact(root.analysis, ['availability', 'idn', 'registryInsights', 'registryComparison', 'registrarPublicationComparison'], 'Lookup evidence analysis');
766
+ if (analysis.availability !== null) {
767
+ const currentAvailability = version >= LOOKUP_EVIDENCE_SCHEMA_VERSION;
768
+ const availability = exactOptional(analysis.availability, currentAvailability ? ['registryContactsExcluded'] : [], currentAvailability ? LOOKUP_AVAILABILITY_ANALYSIS_KEYS : PUBLIC_LOOKUP_AVAILABILITY_ANALYSIS_KEYS, 'Lookup evidence availability analysis');
769
+ if (currentAvailability && availability.registryContactsExcluded !== true) {
770
+ fail('Lookup evidence availability contact exclusion');
771
+ }
772
+ validateLookupEvidenceHomepageMetadata(availability, version);
773
+ if (!isDeepStrictEqual(analysis.availability, currentAvailability
774
+ ? projectLookupEvidenceAvailability(analysis.availability)
775
+ : projectLookupEvidenceAvailabilityPublic(analysis.availability)))
776
+ fail('Lookup evidence availability analysis');
777
+ }
778
+ if (analysis.idn !== null) {
779
+ exactOptional(analysis.idn, [], LOOKUP_IDN_ANALYSIS_KEYS, 'Lookup evidence IDN analysis');
780
+ }
781
+ const registryInsights = record(analysis.registryInsights, 'Lookup evidence registry insights');
782
+ const registryComparison = record(analysis.registryComparison, 'Lookup evidence registry comparison');
783
+ validateLookupEvidenceRegistrarComparison(analysis.registrarPublicationComparison);
784
+ {
785
+ const rdap = record(sources.rdap, 'Lookup evidence RDAP source');
786
+ const whois = record(sources.whois, 'Lookup evidence WHOIS source');
787
+ const rdapParsed = ['success', 'partial'].includes(rdapDiagnosticStatus) ? rdap.parsed : null;
788
+ const whoisParsed = ['complete', 'partial'].includes(whoisDiagnosticStatus) ? whois.parsed : null;
789
+ const expectedComparison = compareRegistrySources(rdapParsed, whoisParsed, {
790
+ rdapStatus: rdapDiagnosticStatus,
791
+ whoisStatus: whoisDiagnosticStatus,
792
+ });
793
+ if (!isDeepStrictEqual(registryComparison, expectedComparison))
794
+ fail('Lookup evidence registry comparison derivation');
795
+ const rebuiltInsights = buildRegistryInsights({
796
+ rdapParsed,
797
+ rdapStatus: rdapDiagnosticStatus,
798
+ rdapFetchedAt: rdap.fetchedAt,
799
+ whoisParsed,
800
+ whoisStatus: whoisDiagnosticStatus,
801
+ whoisQueriedAt: whois.queriedAt,
802
+ });
803
+ const expectedInsights = version >= LOOKUP_EVIDENCE_SCHEMA_VERSION
804
+ ? projectLookupEvidenceRegistryInsights(rebuiltInsights)
805
+ : rebuiltInsights;
806
+ if (!isDeepStrictEqual(registryInsights, expectedInsights))
807
+ fail('Lookup evidence registry insight derivation');
808
+ try {
809
+ assertLookupEvidencePrivacySafeTree(root);
810
+ }
811
+ catch {
812
+ fail('Lookup evidence privacy boundary');
813
+ }
814
+ }
815
+ }
816
+ function validateIntegrity(value, label, version, currentVersion) {
817
+ if (version !== currentVersion)
818
+ fail(label);
819
+ const integrity = exact(value, ['algorithm', 'canonicalization', 'digestSha256'], label);
820
+ if (integrity.algorithm !== 'SHA-256' || integrity.canonicalization !== 'sorted-json-v2')
821
+ fail(label);
822
+ digest(integrity.digestSha256, label);
823
+ }
824
+ function validateAcquisitionItem(value, label) {
825
+ const item = exact(value, ['id', 'label', 'state', 'detail', 'provenance'], label);
826
+ const id = enumeration(item.id, ['availability', 'contacts', 'lifecycle', 'mail', 'nameservers', 'operations', 'policy_eligibility', 'policy_lifecycle', 'policy_transfer', 'tls', 'transfer', 'web'], label);
827
+ enumeration(item.state, ['authoritative', 'observed', 'review', 'unavailable'], label);
828
+ text(item.label, label, 200);
829
+ text(item.detail, label, 2_000);
830
+ text(item.provenance, label, 300);
831
+ return id;
832
+ }
833
+ function validateAcquisition(value) {
834
+ const root = exact(value, ['schema', 'version', 'generatedAt', 'target', 'synthetic', 'evidenceObservedAt', 'analystReview', 'evidenceReview', 'limitations', 'integrity'], 'Acquisition decision packet');
835
+ iso(root.generatedAt, 'Acquisition decision packet generatedAt');
836
+ domain(root.target, 'Acquisition decision packet target');
837
+ boolean(root.synthetic, 'Acquisition decision packet synthetic');
838
+ iso(root.evidenceObservedAt, 'Acquisition decision packet evidenceObservedAt', true);
839
+ const review = exact(root.analystReview, ['decision', 'rationale', 'reviewedChecks', 'outstandingChecks', 'state'], 'Acquisition analyst review');
840
+ const decision = enumeration(review.decision, ACQUISITION_DECISIONS, 'Acquisition analyst review decision');
841
+ text(review.rationale, 'Acquisition analyst review rationale', 2_000, true);
842
+ const reviewed = strings(review.reviewedChecks, 'Acquisition reviewed checks', ACQUISITION_MANUAL_CHECKS.length, 40);
843
+ const outstanding = strings(review.outstandingChecks, 'Acquisition outstanding checks', ACQUISITION_MANUAL_CHECKS.length, 40);
844
+ const expectedReviewed = ACQUISITION_MANUAL_CHECKS.filter((item) => reviewed.includes(item));
845
+ const expectedOutstanding = ACQUISITION_MANUAL_CHECKS.filter((item) => !reviewed.includes(item));
846
+ if (!sameValues(reviewed, expectedReviewed) || !sameValues(outstanding, expectedOutstanding))
847
+ fail('Acquisition analyst review checks');
848
+ const expectedState = decision === 'unresolved' || reviewed.length < ACQUISITION_MANUAL_CHECKS.length ? 'draft' : 'reviewed';
849
+ if (review.state !== expectedState)
850
+ fail('Acquisition analyst review state');
851
+ const evidence = exact(root.evidenceReview, ['version', 'label', 'state', 'items', 'transitionDependencies', 'policyChecks', 'nextSteps', 'limitations'], 'Acquisition evidence review');
852
+ if (evidence.version !== 2)
853
+ fail('Acquisition evidence review');
854
+ text(evidence.label, 'Acquisition evidence review label', 200);
855
+ enumeration(evidence.state, ['incomplete', 'registered', 'review_transition', 'sale_signal', 'unregistered_observation'], 'Acquisition evidence review state');
856
+ const expectedEvidenceIds = {
857
+ items: ['availability', 'lifecycle', 'transfer', 'operations', 'contacts'],
858
+ transitionDependencies: ['nameservers', 'web', 'mail', 'tls'],
859
+ policyChecks: ['policy_eligibility', 'policy_lifecycle', 'policy_transfer'],
860
+ };
861
+ for (const key of ['items', 'transitionDependencies', 'policyChecks']) {
862
+ const expected = expectedEvidenceIds[key];
863
+ const values = array(evidence[key], `Acquisition evidence review ${key}`, expected.length, expected.length);
864
+ const ids = values.map((item, index) => validateAcquisitionItem(item, `Acquisition evidence review ${key}[${index}]`));
865
+ if (!sameValues(ids, expected))
866
+ fail(`Acquisition evidence review ${key}`);
867
+ }
868
+ strings(evidence.nextSteps, 'Acquisition next steps', 6, 600);
869
+ strings(evidence.limitations, 'Acquisition evidence limitations', 12, 600);
870
+ strings(root.limitations, 'Acquisition limitations', 8, 600);
871
+ validateIntegrity(root.integrity, 'Acquisition integrity', root.version, ACQUISITION_DECISION_PACKET_VERSION);
872
+ }
873
+ const CLAIM_PASSPORT_SOURCE_STATES = ['complete', 'not_found', 'partial', 'skipped', 'unavailable', 'unknown', 'unsupported'];
874
+ function validateClaimPassportTarget(type, value) {
875
+ const candidate = text(value, 'Claim passport target', 253);
876
+ if (type === 'domain') {
877
+ domain(candidate, 'Claim passport target');
878
+ return;
879
+ }
880
+ if (type === 'ipv4') {
881
+ const parts = candidate.split('.');
882
+ if (parts.length !== 4 || parts.some((part) => !/^\d{1,3}$/u.test(part) || Number(part) > 255 || String(Number(part)) !== part))
883
+ fail('Claim passport target');
884
+ return;
885
+ }
886
+ if (type === 'ipv6') {
887
+ if (!candidate.includes(':') || /[\[\]%/?#@]/u.test(candidate))
888
+ fail('Claim passport target');
889
+ try {
890
+ const hostname = new URL(`http://[${candidate}]/`).hostname;
891
+ const normalized = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
892
+ if (normalized !== candidate)
893
+ fail('Claim passport target');
894
+ }
895
+ catch {
896
+ fail('Claim passport target');
897
+ }
898
+ return;
899
+ }
900
+ if (!/^AS(?:[1-9]\d{0,9})$/u.test(candidate) || Number(candidate.slice(2)) > 4_294_967_295)
901
+ fail('Claim passport target');
902
+ }
903
+ function validateLookupClaimPassport(value) {
904
+ const root = exact(value, ['schema', 'version', 'generatedAt', 'application', 'target', 'observation', 'claim', 'models', 'limitations', 'integrity'], 'Lookup claim passport');
905
+ if (root.version !== LOOKUP_CLAIM_PASSPORT_VERSION)
906
+ fail('Lookup claim passport');
907
+ iso(root.generatedAt, 'Lookup claim passport generatedAt');
908
+ const application = exact(root.application, ['name', 'version'], 'Lookup claim passport application');
909
+ if (application.name !== 'WHOISleuth' || typeof application.version !== 'string'
910
+ || !/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(application.version))
911
+ fail('Lookup claim passport application');
912
+ const target = exact(root.target, ['type', 'value'], 'Lookup claim passport target');
913
+ const type = enumeration(target.type, LOOKUP_CLAIM_PASSPORT_TARGET_TYPES, 'Lookup claim passport target');
914
+ validateClaimPassportTarget(type, target.value);
915
+ const observation = exact(root.observation, ['observedAt', 'lookupDepth'], 'Lookup claim passport observation');
916
+ iso(observation.observedAt, 'Lookup claim passport observation', true);
917
+ enumeration(observation.lookupDepth, ['fast', 'deep'], 'Lookup claim passport observation');
918
+ const claim = exact(root.claim, ['id', 'label', 'state', 'conclusion', 'requiredEvidenceIds', 'missingEvidenceIds', 'requirements', 'limitations'], 'Lookup claim passport claim');
919
+ enumeration(claim.id, LOOKUP_CLAIM_IDS, 'Lookup claim passport claim');
920
+ text(claim.label, 'Lookup claim passport claim label', 160);
921
+ text(claim.conclusion, 'Lookup claim passport claim conclusion', 600);
922
+ const requirements = array(claim.requirements, 'Lookup claim passport requirements', MAX_LOOKUP_CLAIM_PASSPORT_REQUIREMENTS, 1);
923
+ const requirementIds = [];
924
+ const missingIds = [];
925
+ const seen = new Set();
926
+ let observedCount = 0;
927
+ let hasLimited = false;
928
+ for (const [index, candidate] of requirements.entries()) {
929
+ const item = exact(candidate, ['id', 'label', 'evidenceId', 'mode', 'state', 'observedAt', 'limitations'], `Lookup claim passport requirement ${index + 1}`);
930
+ const id = enumeration(item.id, LOOKUP_CLAIM_REQUIREMENT_IDS, 'Lookup claim passport requirement id');
931
+ if (seen.has(id))
932
+ fail('Lookup claim passport requirements');
933
+ seen.add(id);
934
+ requirementIds.push(id);
935
+ text(item.label, 'Lookup claim passport requirement label', 160);
936
+ if (item.evidenceId !== null && (typeof item.evidenceId !== 'string' || !/^[a-z][a-z0-9_-]{0,63}$/u.test(item.evidenceId)))
937
+ fail('Lookup claim passport evidence id');
938
+ enumeration(item.mode, ['network_collection', 'local_review'], 'Lookup claim passport requirement mode');
939
+ const state = enumeration(item.state, CLAIM_PASSPORT_SOURCE_STATES, 'Lookup claim passport requirement state');
940
+ if (state !== 'complete')
941
+ missingIds.push(id);
942
+ if (state !== 'skipped' && state !== 'unsupported' && state !== 'unknown')
943
+ observedCount += 1;
944
+ if (state === 'partial' || state === 'unavailable' || state === 'unknown')
945
+ hasLimited = true;
946
+ iso(item.observedAt, 'Lookup claim passport requirement observedAt', true);
947
+ if (item.evidenceId === null && item.observedAt !== null)
948
+ fail('Lookup claim passport requirement observedAt');
949
+ strings(item.limitations, 'Lookup claim passport requirement limitations', 8, 400);
950
+ }
951
+ const required = strings(claim.requiredEvidenceIds, 'Lookup claim passport required evidence', MAX_LOOKUP_CLAIM_PASSPORT_REQUIREMENTS, 64);
952
+ const missing = strings(claim.missingEvidenceIds, 'Lookup claim passport missing evidence', MAX_LOOKUP_CLAIM_PASSPORT_REQUIREMENTS, 64);
953
+ if (!sameValues(required, requirementIds) || !sameValues(missing, missingIds))
954
+ fail('Lookup claim passport evidence identifiers');
955
+ const expectedState = missingIds.length === 0 ? 'ready' : observedCount > 0 && hasLimited ? 'limited' : 'not_ready';
956
+ if (claim.state !== expectedState)
957
+ fail('Lookup claim passport claim state');
958
+ strings(claim.limitations, 'Lookup claim passport claim limitations', MAX_LOOKUP_CLAIM_PASSPORT_LIMITATIONS, 400);
959
+ const models = exact(root.models, ['claimReadiness', 'risk'], 'Lookup claim passport models');
960
+ if (models.claimReadiness !== LOOKUP_CLAIM_READINESS_VERSION)
961
+ fail('Lookup claim passport models');
962
+ if (models.risk !== null)
963
+ integer(models.risk, 'Lookup claim passport Risk model', 1, 1_000);
964
+ strings(root.limitations, 'Lookup claim passport limitations', MAX_LOOKUP_CLAIM_PASSPORT_LIMITATIONS, 600);
965
+ validateIntegrity(root.integrity, 'Lookup claim passport integrity', root.version, LOOKUP_CLAIM_PASSPORT_VERSION);
966
+ }
967
+ const COMPARISON_STATES = ['conflicting', 'different', 'equal', 'missing', 'not_recorded', 'unavailable'];
968
+ const SOURCE_STATES = ['complete', 'error', 'not_found', 'not_recorded', 'partial', 'skipped', 'unavailable', 'unsupported'];
969
+ const DOMAIN_COMPARISON_ROW_IDS = [
970
+ 'registration', 'registrar', 'created', 'expires', 'nameservers', 'dns-a', 'dns-aaaa', 'dns-cname',
971
+ 'dns-caa', 'dnssec', 'ip-addresses', 'tls-source', 'certificate', 'tls-issuer', 'tls-spki', 'mail',
972
+ 'null-mx', 'spf', 'dmarc', 'website', 'page-title', 'favicon', 'tracking', 'password-field',
973
+ 'phishing-language', 'official-assets', 'technology', 'source-health',
974
+ ];
975
+ function validateDomainComparison(value) {
976
+ const root = exact(value, ['schema', 'version', 'generatedAt', 'comparison', 'integrity'], 'Domain comparison artefact');
977
+ iso(root.generatedAt, 'Domain comparison generatedAt');
978
+ const comparison = exact(root.comparison, ['version', 'leftDomain', 'rightDomain', 'observedAt', 'freshness', 'rows', 'counts', 'limitations'], 'Domain comparison');
979
+ if (comparison.version !== 3)
980
+ fail('Domain comparison');
981
+ domain(comparison.leftDomain, 'Domain comparison left domain');
982
+ domain(comparison.rightDomain, 'Domain comparison right domain');
983
+ iso(comparison.observedAt, 'Domain comparison observedAt', true);
984
+ const freshness = exact(comparison.freshness, ['state', 'ageDays'], 'Domain comparison freshness');
985
+ enumeration(freshness.state, ['current', 'stale', 'unknown'], 'Domain comparison freshness');
986
+ if (freshness.ageDays !== null)
987
+ integer(freshness.ageDays, 'Domain comparison age', 0, 1_000_000);
988
+ const rows = array(comparison.rows, 'Domain comparison rows', DOMAIN_COMPARISON_ROW_IDS.length, DOMAIN_COMPARISON_ROW_IDS.length);
989
+ const counts = exact(comparison.counts, [...COMPARISON_STATES], 'Domain comparison counts');
990
+ const actual = new Map(COMPARISON_STATES.map((state) => [state, 0]));
991
+ for (const [index, candidate] of rows.entries()) {
992
+ const row = exact(candidate, ['id', 'category', 'label', 'left', 'right', 'state', 'method', 'source', 'leftSourceState', 'rightSourceState', 'observedAt', 'leftEvidenceHref', 'rightEvidenceHref', 'limitations'], `Domain comparison row ${index + 1}`);
993
+ const id = text(row.id, 'Domain comparison row id', 80);
994
+ if (id !== DOMAIN_COMPARISON_ROW_IDS[index])
995
+ fail('Domain comparison row order');
996
+ enumeration(row.category, ['certificate', 'dns', 'identity', 'infrastructure', 'lifecycle', 'mail', 'registration', 'source', 'technology', 'web'], 'Domain comparison category');
997
+ text(row.label, 'Domain comparison label', 120);
998
+ text(row.left, 'Domain comparison left value', 2_000);
999
+ text(row.right, 'Domain comparison right value', 2_000);
1000
+ const state = enumeration(row.state, COMPARISON_STATES, 'Domain comparison state');
1001
+ actual.set(state, (actual.get(state) ?? 0) + 1);
1002
+ text(row.method, 'Domain comparison method', 320);
1003
+ text(row.source, 'Domain comparison source', 80);
1004
+ enumeration(row.leftSourceState, SOURCE_STATES, 'Domain comparison left source state');
1005
+ enumeration(row.rightSourceState, SOURCE_STATES, 'Domain comparison right source state');
1006
+ iso(row.observedAt, 'Domain comparison row observedAt', true);
1007
+ if (row.observedAt !== comparison.observedAt)
1008
+ fail('Domain comparison row observedAt');
1009
+ if (typeof row.leftEvidenceHref !== 'string' || !/^(?:|#bulk-result-\d{1,4})$/u.test(row.leftEvidenceHref))
1010
+ fail('Domain comparison evidence link');
1011
+ if (typeof row.rightEvidenceHref !== 'string' || !/^(?:|#bulk-result-\d{1,4})$/u.test(row.rightEvidenceHref))
1012
+ fail('Domain comparison evidence link');
1013
+ strings(row.limitations, 'Domain comparison row limitations', 6, 320);
1014
+ }
1015
+ for (const state of COMPARISON_STATES) {
1016
+ if (integer(counts[state], `Domain comparison ${state} count`, 0, rows.length) !== actual.get(state))
1017
+ fail('Domain comparison counts');
1018
+ }
1019
+ strings(comparison.limitations, 'Domain comparison limitations', 12, 600);
1020
+ validateIntegrity(root.integrity, 'Domain comparison integrity', root.version, BULK_DOMAIN_COMPARISON_EXPORT_VERSION);
1021
+ }
1022
+ const MAIL_STATES = ['authenticated_mail', 'evidence_incomplete', 'mail_auth_gap', 'mail_auth_incomplete', 'no_explicit_mx', 'null_mx'];
1023
+ function validateSourceCoverage(value, label) {
1024
+ const source = exact(value, ['source', 'state'], label);
1025
+ if (typeof source.source !== 'string' || !/^[a-z][a-z0-9_-]{0,39}$/u.test(source.source))
1026
+ fail(label);
1027
+ enumeration(source.state, SOURCE_STATES.filter((state) => state !== 'not_recorded'), label);
1028
+ }
1029
+ function validateProfileContext(value, label) {
1030
+ const context = exact(value, ['sourceState', 'activeProfileId', 'profileUpdatedAt', 'limitation'], label);
1031
+ enumeration(context.sourceState, ['mixed', 'ready', 'unavailable'], label);
1032
+ optionalText(context.activeProfileId, label, 128);
1033
+ iso(context.profileUpdatedAt, label, true);
1034
+ text(context.limitation, label, 300, true);
1035
+ }
1036
+ function validateMailExposure(value) {
1037
+ const root = exact(value, ['schema', 'version', 'report', 'integrity'], 'Bulk mail exposure artefact');
1038
+ const report = exact(root.report, ['version', 'generatedAt', 'observedAt', 'baseline', 'rows', 'counts', 'profileContextUnevaluatedCount', 'limitations'], 'Bulk mail exposure report');
1039
+ if (report.version !== 1)
1040
+ fail('Bulk mail exposure report');
1041
+ iso(report.generatedAt, 'Bulk mail exposure generatedAt');
1042
+ iso(report.observedAt, 'Bulk mail exposure observedAt', true);
1043
+ const baseline = exact(report.baseline, ['profile', 'officialDomains', 'label', 'limitations'], 'Bulk mail exposure baseline');
1044
+ if (baseline.profile !== null)
1045
+ enumeration(baseline.profile, ['defensive_no_mail', 'parked', 'standard'], 'Bulk mail exposure baseline');
1046
+ const officialDomains = strings(baseline.officialDomains, 'Bulk mail exposure official domains', 20, 253);
1047
+ officialDomains.forEach((item) => domain(item, 'Bulk mail exposure official domain'));
1048
+ text(baseline.label, 'Bulk mail exposure baseline label', 200);
1049
+ strings(baseline.limitations, 'Bulk mail exposure baseline limitations', 8, 600);
1050
+ const rows = array(report.rows, 'Bulk mail exposure rows', MAX_BULK_MAIL_EXPOSURE_ROWS);
1051
+ const counts = exact(report.counts, [...MAIL_STATES], 'Bulk mail exposure counts');
1052
+ const actual = new Map(MAIL_STATES.map((state) => [state, 0]));
1053
+ let unevaluated = 0;
1054
+ for (const [index, candidate] of rows.entries()) {
1055
+ const row = exact(candidate, ['domain', 'state', 'label', 'detail', 'baselineRelation', 'baselineDetail', 'mutationTypes', 'registration', 'sourceCoverage', 'profileContextState', 'profileContextLimitation', 'limitations'], `Bulk mail exposure row ${index + 1}`);
1056
+ domain(row.domain, 'Bulk mail exposure row domain');
1057
+ const state = enumeration(row.state, MAIL_STATES, 'Bulk mail exposure row state');
1058
+ actual.set(state, (actual.get(state) ?? 0) + 1);
1059
+ text(row.label, 'Bulk mail exposure row label', 200);
1060
+ text(row.detail, 'Bulk mail exposure row detail', 600);
1061
+ enumeration(row.baselineRelation, ['aligned', 'inconclusive', 'review'], 'Bulk mail exposure baseline relation');
1062
+ text(row.baselineDetail, 'Bulk mail exposure baseline detail', 600);
1063
+ strings(row.mutationTypes, 'Bulk mail exposure mutation types', 40, 120);
1064
+ text(row.registration, 'Bulk mail exposure registration', 160, true);
1065
+ array(row.sourceCoverage, 'Bulk mail exposure source coverage', 12).forEach((item, sourceIndex) => validateSourceCoverage(item, `Bulk mail exposure source ${sourceIndex + 1}`));
1066
+ const profileState = enumeration(row.profileContextState, ['mixed', 'ready', 'unavailable'], 'Bulk mail exposure profile context state');
1067
+ if (profileState !== 'ready')
1068
+ unevaluated += 1;
1069
+ text(row.profileContextLimitation, 'Bulk mail exposure profile context limitation', 300, true);
1070
+ strings(row.limitations, 'Bulk mail exposure row limitations', 6, 600);
1071
+ }
1072
+ for (const state of MAIL_STATES) {
1073
+ if (integer(counts[state], `Bulk mail exposure ${state} count`, 0, rows.length) !== actual.get(state))
1074
+ fail('Bulk mail exposure counts');
1075
+ }
1076
+ const declaredUnevaluated = integer(report.profileContextUnevaluatedCount, 'Bulk mail exposure unevaluated count', 0, rows.length);
1077
+ if (declaredUnevaluated < unevaluated)
1078
+ fail('Bulk mail exposure unevaluated count');
1079
+ strings(report.limitations, 'Bulk mail exposure limitations', 12, 600);
1080
+ validateIntegrity(root.integrity, 'Bulk mail exposure integrity', root.version, BULK_MAIL_EXPOSURE_EXPORT_VERSION);
1081
+ }
1082
+ function validateBulkView(value, label) {
1083
+ const view = exact(value, ['primaryFilter', 'mutationFilter', 'signalFilters', 'sourceFilter', 'lifecycleFilter', 'ageFilter', 'mailFilter', 'registrarFilter', 'caseDispositionFilter', 'reviewStateFilter', 'groupBy', 'sortKey', 'sortDirection'], label);
1084
+ text(view.primaryFilter, label, 60);
1085
+ text(view.mutationFilter, label, 60, true);
1086
+ strings(view.signalFilters, label, 5, 40);
1087
+ text(view.sourceFilter, label, 60, true);
1088
+ text(view.lifecycleFilter, label, 60, true);
1089
+ text(view.ageFilter, label, 60, true);
1090
+ text(view.mailFilter, label, 60, true);
1091
+ text(view.registrarFilter, label, 200, true);
1092
+ text(view.caseDispositionFilter, label, 60, true);
1093
+ enumeration(view.reviewStateFilter, ['', 'unreviewed', 'reviewing', 'reviewed', 'deferred'], label);
1094
+ text(view.groupBy, label, 60, true);
1095
+ enumeration(view.sortKey, ['domain', 'availability', 'risk', 'opportunity', 'activity', 'registrar', 'mutation'], label);
1096
+ if (view.sortDirection !== 1 && view.sortDirection !== -1)
1097
+ fail(label);
1098
+ }
1099
+ function validateBulkReviewManifest(value) {
1100
+ const root = exact(value, ['schema', 'version', 'generatedAt', 'observedAt', 'lookupProfile', 'selection', 'view', 'rows', 'limitations', 'integrity'], 'Bulk review manifest');
1101
+ iso(root.generatedAt, 'Bulk review manifest generatedAt');
1102
+ iso(root.observedAt, 'Bulk review manifest observedAt');
1103
+ enumeration(root.lookupProfile, ['deep', 'fast'], 'Bulk review manifest lookup profile');
1104
+ const selection = exact(root.selection, ['count', 'domains'], 'Bulk review manifest selection');
1105
+ const domains = strings(selection.domains, 'Bulk review manifest selected domains', 2_000, 253);
1106
+ domains.forEach((item) => domain(item, 'Bulk review manifest selected domain'));
1107
+ if (integer(selection.count, 'Bulk review manifest selection count', 0, 2_000) !== domains.length)
1108
+ fail('Bulk review manifest selection');
1109
+ validateBulkView(root.view, 'Bulk review manifest view');
1110
+ const rows = array(root.rows, 'Bulk review manifest rows', 2_000);
1111
+ for (const [index, candidate] of rows.entries()) {
1112
+ const row = exact(candidate, ['domain', 'reviewState', 'resultState', 'scanDepth', 'sourceCoverage', 'profileContext'], `Bulk review manifest row ${index + 1}`);
1113
+ domain(row.domain, 'Bulk review manifest row domain');
1114
+ enumeration(row.reviewState, ['unreviewed', 'reviewing', 'reviewed', 'deferred'], 'Bulk review manifest review state');
1115
+ enumeration(row.resultState, ['complete', 'error'], 'Bulk review manifest result state');
1116
+ enumeration(row.scanDepth, ['deep', 'fast'], 'Bulk review manifest scan depth');
1117
+ array(row.sourceCoverage, 'Bulk review manifest source coverage', 12).forEach((item, sourceIndex) => validateSourceCoverage(item, `Bulk review manifest source ${sourceIndex + 1}`));
1118
+ validateProfileContext(row.profileContext, 'Bulk review manifest profile context');
1119
+ if (domains[index] !== row.domain)
1120
+ fail('Bulk review manifest selection');
1121
+ }
1122
+ if (rows.length !== domains.length)
1123
+ fail('Bulk review manifest selection');
1124
+ strings(root.limitations, 'Bulk review manifest limitations', 8, 600);
1125
+ validateIntegrity(root.integrity, 'Bulk review manifest integrity', root.version, BULK_REVIEW_MANIFEST_VERSION);
1126
+ }
1127
+ function validateInvestigationManifest(value) {
1128
+ const root = exact(value, ['schema', 'version', 'generatedAt', 'application', 'workflow', 'configuration', 'artifacts', 'steps', 'summary', 'limitations', 'integrity'], 'Investigation manifest');
1129
+ iso(root.generatedAt, 'Investigation manifest generatedAt');
1130
+ const application = exact(root.application, ['name', 'version'], 'Investigation manifest application');
1131
+ if (application.name !== 'WHOISleuth CLI' || typeof application.version !== 'string'
1132
+ || !/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(application.version))
1133
+ fail('Investigation manifest application');
1134
+ text(root.workflow, 'Investigation manifest workflow', 160);
1135
+ const configuration = exact(root.configuration, ['digestSha256'], 'Investigation manifest configuration');
1136
+ if (configuration.digestSha256 !== null)
1137
+ digest(configuration.digestSha256, 'Investigation manifest configuration');
1138
+ const artifacts = array(root.artifacts, 'Investigation manifest artifacts', MAX_INVESTIGATION_MANIFEST_ARTIFACTS, 1);
1139
+ const steps = array(root.steps, 'Investigation manifest steps', MAX_INVESTIGATION_MANIFEST_ARTIFACTS, 1);
1140
+ let totalBytes = 0;
1141
+ for (const [index, candidate] of artifacts.entries()) {
1142
+ const item = exact(candidate, ['sequence', 'id', 'schema', 'version', 'byteLength', 'contentDigestSha256', 'canonicalDigestSha256'], `Investigation manifest artifact ${index + 1}`);
1143
+ if (integer(item.sequence, 'Investigation manifest artifact sequence', 1, artifacts.length) !== index + 1
1144
+ || item.id !== `artifact-${index + 1}`)
1145
+ fail('Investigation manifest artifact order');
1146
+ if (item.schema !== null)
1147
+ text(item.schema, 'Investigation manifest artifact schema', 160);
1148
+ if (item.version !== null)
1149
+ integer(item.version, 'Investigation manifest artifact version', 1, 1_000);
1150
+ totalBytes += integer(item.byteLength, 'Investigation manifest artifact bytes', 1, 15 * 1024 * 1024);
1151
+ digest(item.contentDigestSha256, 'Investigation manifest content digest');
1152
+ digest(item.canonicalDigestSha256, 'Investigation manifest canonical digest');
1153
+ const step = exact(steps[index], ['sequence', 'artifactId', 'contentDigestSha256'], `Investigation manifest step ${index + 1}`);
1154
+ if (step.sequence !== item.sequence || step.artifactId !== item.id || step.contentDigestSha256 !== item.contentDigestSha256)
1155
+ fail('Investigation manifest step linkage');
1156
+ }
1157
+ const summary = exact(root.summary, ['artifactCount', 'totalBytes'], 'Investigation manifest summary');
1158
+ if (integer(summary.artifactCount, 'Investigation manifest artifact count', 1, MAX_INVESTIGATION_MANIFEST_ARTIFACTS) !== artifacts.length
1159
+ || integer(summary.totalBytes, 'Investigation manifest total bytes', 1, MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES) !== totalBytes)
1160
+ fail('Investigation manifest summary');
1161
+ strings(root.limitations, 'Investigation manifest limitations', 8, 600);
1162
+ validateIntegrity(root.integrity, 'Investigation manifest integrity', root.version, INVESTIGATION_MANIFEST_VERSION);
1163
+ }
1164
+ const CASE_RESPONSE_PREFLIGHT_IDS = [
1165
+ 'required_incident_fields',
1166
+ 'evidence_pins',
1167
+ 'analyst_decision',
1168
+ 'recipient_route',
1169
+ 'profile_recipient',
1170
+ 'case_disposition',
1171
+ 'evidence_freshness',
1172
+ 'contradictory_evidence',
1173
+ 'action_tracking',
1174
+ ];
1175
+ const PUBLIC_CASE_ACTION_STATES = ['planned', 'ready_for_review', 'submitted', 'acknowledged', 'resolved', 'closed'];
1176
+ function validatePublicActionSummary(value, label) {
1177
+ const summary = exact(value, ['total', 'active', 'submitted', 'acknowledged', 'resolved', 'closed', 'overdue', 'followUpDue', 'withOutcome', 'latestOutcomes'], label);
1178
+ const total = integer(summary.total, label, 0, MAX_CASE_ACTIONS);
1179
+ for (const key of ['active', 'submitted', 'acknowledged', 'resolved', 'closed', 'overdue', 'followUpDue', 'withOutcome'])
1180
+ integer(summary[key], label, 0, total);
1181
+ if (Number(summary.resolved) + Number(summary.closed) + Number(summary.active) !== total)
1182
+ fail(label);
1183
+ const outcomes = array(summary.latestOutcomes, label, 5);
1184
+ for (const candidate of outcomes) {
1185
+ const outcome = exact(candidate, ['actionId', 'recipient', 'state', 'outcome', 'updatedAt'], label);
1186
+ text(outcome.actionId, label, 64);
1187
+ text(outcome.recipient, label, 320);
1188
+ enumeration(outcome.state, PUBLIC_CASE_ACTION_STATES, label);
1189
+ text(outcome.outcome, label, 2_000);
1190
+ iso(outcome.updatedAt, label);
1191
+ }
1192
+ }
1193
+ function expectedObservationAge(observedAt, generatedAt) {
1194
+ const ageSeconds = Math.floor((Date.parse(generatedAt) - Date.parse(observedAt)) / 1_000);
1195
+ if (ageSeconds < -300)
1196
+ return { ageSeconds, band: 'future_or_clock_skew', refreshRecommended: true };
1197
+ if (ageSeconds < 86_400)
1198
+ return { ageSeconds: Math.max(0, ageSeconds), band: 'under_24_hours', refreshRecommended: false };
1199
+ if (ageSeconds <= 604_800)
1200
+ return { ageSeconds, band: 'one_to_seven_days', refreshRecommended: false };
1201
+ return { ageSeconds, band: 'over_seven_days', refreshRecommended: true };
1202
+ }
1203
+ function validateCaseResponsePacketV6(value) {
1204
+ const root = exact(value, ['schema', 'schemaVersion', 'generatedAt', 'reviewRequired', 'submissionPerformed', 'profile', 'case', 'incident', 'contacts', 'preflight', 'escalationHistory', 'provenance', 'integrity'], 'Case-response packet');
1205
+ if (root.schemaVersion !== PUBLIC_CASE_RESPONSE_PACKET_VERSION)
1206
+ fail('Case-response packet v6');
1207
+ iso(root.generatedAt, 'Case-response packet generatedAt');
1208
+ if (root.reviewRequired !== true || root.submissionPerformed !== false)
1209
+ fail('Case-response packet review state');
1210
+ const profile = exact(root.profile, ['id', 'label', 'audience', 'subject', 'checklist', 'evidenceOrder', 'includedEvidence', 'excludedEvidence', 'redactions', 'attachments', 'followUpFields'], 'Case-response profile');
1211
+ const profileId = enumeration(profile.id, RESPONSE_PACKET_PROFILE_IDS, 'Case-response profile id');
1212
+ text(profile.label, 'Case-response profile label', 200);
1213
+ text(profile.audience, 'Case-response profile audience', 300);
1214
+ text(profile.subject, 'Case-response profile subject', 500);
1215
+ for (const key of ['checklist', 'evidenceOrder', 'includedEvidence', 'excludedEvidence', 'redactions', 'attachments', 'followUpFields'])
1216
+ strings(profile[key], `Case-response profile ${key}`, 24, 500);
1217
+ const caseRecord = exact(root.case, ['id', 'domain', 'status', 'disposition', 'updatedAt'], 'Case-response case');
1218
+ text(caseRecord.id, 'Case-response case id', 128);
1219
+ domain(caseRecord.domain, 'Case-response case domain');
1220
+ text(caseRecord.status, 'Case-response case status', 80);
1221
+ text(caseRecord.disposition, 'Case-response case disposition', 80);
1222
+ iso(caseRecord.updatedAt, 'Case-response case updatedAt');
1223
+ const incident = exact(root.incident, ['category', 'affectedParty', 'abusiveUrls', 'observedHarm', 'observedAt'], 'Case-response incident');
1224
+ text(incident.category, 'Case-response category', 80);
1225
+ text(incident.affectedParty, 'Case-response affected party', 200);
1226
+ const urls = strings(incident.abusiveUrls, 'Case-response abusive URLs', MAX_ABUSIVE_URLS, 2_048);
1227
+ if (!urls.length || urls.some((item) => { try {
1228
+ return !['http:', 'https:'].includes(new URL(item).protocol);
1229
+ }
1230
+ catch {
1231
+ return true;
1232
+ } }))
1233
+ fail('Case-response abusive URLs');
1234
+ text(incident.observedHarm, 'Case-response observed harm', 2_000);
1235
+ iso(incident.observedAt, 'Case-response observedAt');
1236
+ const selectedProfile = RESPONSE_PACKET_PROFILES.find((candidate) => candidate.id === profileId);
1237
+ if (!selectedProfile
1238
+ || profile.label !== selectedProfile.label
1239
+ || profile.audience !== selectedProfile.audience
1240
+ || profile.subject !== `${selectedProfile.subjectPrefix}: ${caseRecord.domain} (${incident.category})`
1241
+ || !sameValues(profile.checklist, selectedProfile.checklist)
1242
+ || !sameValues(profile.evidenceOrder, selectedProfile.evidenceOrder)
1243
+ || !sameValues(profile.includedEvidence, selectedProfile.includedEvidence)
1244
+ || !sameValues(profile.excludedEvidence, selectedProfile.excludedEvidence)
1245
+ || !sameValues(profile.redactions, selectedProfile.redactions)
1246
+ || !sameValues(profile.attachments, selectedProfile.attachments)
1247
+ || !sameValues(profile.followUpFields, selectedProfile.followUpFields))
1248
+ fail('Case-response profile');
1249
+ const canonicalUrls = urls.map((item) => {
1250
+ try {
1251
+ const parsed = new URL(item);
1252
+ return ['http:', 'https:'].includes(parsed.protocol) && !parsed.username && !parsed.password
1253
+ ? parsed.toString()
1254
+ : null;
1255
+ }
1256
+ catch {
1257
+ return null;
1258
+ }
1259
+ });
1260
+ if (canonicalUrls.some((item, index) => item === null || item !== urls[index])
1261
+ || new Set(urls).size !== urls.length)
1262
+ fail('Case-response abusive URLs');
1263
+ const contacts = array(root.contacts, 'Case-response contacts', MAX_RESPONSE_CONTACTS);
1264
+ const contactKeys = new Set();
1265
+ for (const candidate of contacts) {
1266
+ const contact = exact(candidate, ['kind', 'contact', 'source', 'limitations'], 'Case-response contact');
1267
+ const kind = enumeration(contact.kind, RESPONSE_CONTACT_KINDS, 'Case-response contact kind');
1268
+ const contactValue = text(contact.contact, 'Case-response contact value', MAX_RESPONSE_RECIPIENT_LENGTH);
1269
+ const contactKey = `${kind}\u0000${contactValue.toLowerCase()}`;
1270
+ if (contactKeys.has(contactKey))
1271
+ fail('Case-response contacts');
1272
+ contactKeys.add(contactKey);
1273
+ text(contact.source, 'Case-response contact source', MAX_RESPONSE_LABEL_LENGTH);
1274
+ strings(contact.limitations, 'Case-response contact limitations', MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH);
1275
+ }
1276
+ const preflight = exact(root.preflight, ['version', 'status', 'canExport', 'counts', 'checks', 'actionSummary'], 'Case-response preflight');
1277
+ if (preflight.version !== 1)
1278
+ fail('Case-response preflight');
1279
+ const checks = array(preflight.checks, 'Case-response preflight checks', CASE_RESPONSE_PREFLIGHT_IDS.length, CASE_RESPONSE_PREFLIGHT_IDS.length);
1280
+ const actualCounts = { block: 0, caution: 0, pass: 0 };
1281
+ for (const [index, candidate] of checks.entries()) {
1282
+ const check = exact(candidate, ['id', 'label', 'state', 'detail'], 'Case-response preflight check');
1283
+ const id = text(check.id, 'Case-response preflight id', 80);
1284
+ if (id !== CASE_RESPONSE_PREFLIGHT_IDS[index])
1285
+ fail('Case-response preflight check order');
1286
+ text(check.label, 'Case-response preflight label', 200);
1287
+ const state = enumeration(check.state, ['block', 'caution', 'pass'], 'Case-response preflight state');
1288
+ actualCounts[state] += 1;
1289
+ text(check.detail, 'Case-response preflight detail', 1_000);
1290
+ }
1291
+ const counts = exact(preflight.counts, ['block', 'caution', 'pass'], 'Case-response preflight counts');
1292
+ for (const state of ['block', 'caution', 'pass']) {
1293
+ if (integer(counts[state], 'Case-response preflight count', 0, checks.length) !== actualCounts[state])
1294
+ fail('Case-response preflight counts');
1295
+ }
1296
+ const expectedStatus = actualCounts.block ? 'needs_input' : actualCounts.caution ? 'review_cautions' : 'ready_for_review';
1297
+ if (preflight.status !== expectedStatus || preflight.canExport !== (actualCounts.block === 0))
1298
+ fail('Case-response preflight status');
1299
+ validatePublicActionSummary(preflight.actionSummary, 'Case-response action summary');
1300
+ const history = array(root.escalationHistory, 'Case-response escalation history', MAX_RESPONSE_ACTION_HISTORY);
1301
+ for (const candidate of history) {
1302
+ const action = exact(candidate, ['type', 'recipient', 'contactSource', 'state', 'reference', 'outcome', 'createdAt', 'updatedAt'], 'Case-response escalation action');
1303
+ enumeration(action.type, CASE_ACTION_TYPES, 'Case-response action type');
1304
+ text(action.recipient, 'Case-response action recipient', 320, true);
1305
+ text(action.contactSource, 'Case-response action source', 120, true);
1306
+ enumeration(action.state, PUBLIC_CASE_ACTION_STATES, 'Case-response action state');
1307
+ optionalText(action.reference, 'Case-response action reference', 500);
1308
+ optionalText(action.outcome, 'Case-response action outcome', 2_000);
1309
+ iso(action.createdAt, 'Case-response action createdAt');
1310
+ iso(action.updatedAt, 'Case-response action updatedAt');
1311
+ }
1312
+ const provenance = exact(root.provenance, ['latestEvidenceCapturedAt', 'evidencePinCount', 'decisionCount', 'assertionCount', 'observationAge', 'limitations'], 'Case-response provenance');
1313
+ iso(provenance.latestEvidenceCapturedAt, 'Case-response evidence time', true);
1314
+ integer(provenance.evidencePinCount, 'Case-response evidence pin count', 0, MAX_CASE_EVIDENCE_PINS);
1315
+ integer(provenance.decisionCount, 'Case-response decision count', 0, MAX_CASE_DECISIONS);
1316
+ integer(provenance.assertionCount, 'Case-response assertion count', 0, MAX_CASE_ASSERTIONS);
1317
+ const age = exact(provenance.observationAge, ['ageSeconds', 'band', 'refreshRecommended'], 'Case-response observation age');
1318
+ integer(age.ageSeconds, 'Case-response observation age seconds', Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER);
1319
+ enumeration(age.band, ['future_or_clock_skew', 'one_to_seven_days', 'over_seven_days', 'under_24_hours'], 'Case-response observation age band');
1320
+ boolean(age.refreshRecommended, 'Case-response refresh recommendation');
1321
+ const expectedAge = expectedObservationAge(incident.observedAt, root.generatedAt);
1322
+ if (age.ageSeconds !== expectedAge.ageSeconds || age.band !== expectedAge.band
1323
+ || age.refreshRecommended !== expectedAge.refreshRecommended)
1324
+ fail('Case-response observation age');
1325
+ strings(provenance.limitations, 'Case-response limitations', 8, 600);
1326
+ const integrity = exact(root.integrity, ['algorithm', 'canonicalization', 'scope', 'digestSha256'], 'Case-response integrity');
1327
+ if (integrity.algorithm !== 'SHA-256'
1328
+ || integrity.canonicalization !== 'sorted-json-v2'
1329
+ || integrity.scope !== 'packet excluding integrity')
1330
+ fail('Case-response integrity');
1331
+ if (typeof integrity.digestSha256 !== 'string' || !HEX_DIGEST_RE.test(integrity.digestSha256))
1332
+ fail('Case-response integrity');
1333
+ }
1334
+ function validateCurrentActionSummary(value, label) {
1335
+ const summary = exact(value, [
1336
+ 'total', 'active', 'drafting', 'readyForReview', 'reviewed', 'authorised', 'submitted',
1337
+ 'acknowledged', 'terminal', 'overdue', 'followUpDue', 'withProviderOutcome', 'latestOutcomes',
1338
+ ], label);
1339
+ const total = integer(summary.total, label, 0, MAX_CASE_ACTIONS);
1340
+ const drafting = integer(summary.drafting, label, 0, total);
1341
+ const ready = integer(summary.readyForReview, label, 0, total);
1342
+ const reviewed = integer(summary.reviewed, label, 0, total);
1343
+ const authorised = integer(summary.authorised, label, 0, total);
1344
+ const submitted = integer(summary.submitted, label, 0, total);
1345
+ const acknowledged = integer(summary.acknowledged, label, 0, total);
1346
+ const terminal = integer(summary.terminal, label, 0, total);
1347
+ const active = integer(summary.active, label, 0, total);
1348
+ if (drafting + ready + reviewed + authorised + submitted + acknowledged + terminal !== total || active + terminal !== total)
1349
+ fail(label);
1350
+ for (const key of ['overdue', 'followUpDue', 'withProviderOutcome'])
1351
+ integer(summary[key], label, 0, total);
1352
+ const outcomes = array(summary.latestOutcomes, label, 5);
1353
+ for (const candidate of outcomes) {
1354
+ const outcome = exact(candidate, ['actionId', 'recipient', 'state', 'providerOutcome', 'outcomeDetail', 'occurredAt'], label);
1355
+ text(outcome.actionId, label, 64);
1356
+ text(outcome.recipient, label, 320);
1357
+ enumeration(outcome.state, CASE_ACTION_STATES, label);
1358
+ enumeration(outcome.providerOutcome, CASE_PROVIDER_OUTCOMES, label);
1359
+ optionalText(outcome.outcomeDetail, label, 2_000);
1360
+ iso(outcome.occurredAt, label);
1361
+ }
1362
+ return total;
1363
+ }
1364
+ function validateCaseResponsePacketV7(value) {
1365
+ const root = exact(value, [
1366
+ 'schema', 'schemaVersion', 'generatedAt', 'reviewRequired', 'submissionPerformed', 'profile', 'case',
1367
+ 'incident', 'contacts', 'selectedEvidence', 'contradictions', 'readiness', 'artefactReferences',
1368
+ 'authorisation', 'preflight', 'escalationHistory', 'escalationHistoryOmitted',
1369
+ 'escalationHistoryLimitations', 'responseLifecycle', 'provenance', 'integrity',
1370
+ ], 'Case-response packet v7');
1371
+ if (root.schemaVersion !== CASE_RESPONSE_PACKET_VERSION)
1372
+ fail('Case-response packet v7');
1373
+ iso(root.generatedAt, 'Case-response packet generatedAt');
1374
+ if (root.reviewRequired !== true || root.submissionPerformed !== false)
1375
+ fail('Case-response packet review state');
1376
+ const profile = exact(root.profile, ['id', 'label', 'audience', 'subject', 'checklist', 'evidenceOrder', 'includedEvidence', 'excludedEvidence', 'redactions', 'attachments', 'followUpFields'], 'Case-response profile');
1377
+ const profileId = enumeration(profile.id, RESPONSE_PACKET_PROFILE_IDS, 'Case-response profile id');
1378
+ text(profile.label, 'Case-response profile label', 200);
1379
+ text(profile.audience, 'Case-response profile audience', 300);
1380
+ text(profile.subject, 'Case-response profile subject', 500);
1381
+ for (const key of ['checklist', 'evidenceOrder', 'includedEvidence', 'excludedEvidence', 'redactions', 'attachments', 'followUpFields'])
1382
+ strings(profile[key], `Case-response profile ${key}`, 24, 500);
1383
+ const selectedProfile = RESPONSE_PACKET_PROFILES.find((candidate) => candidate.id === profileId);
1384
+ if (!selectedProfile
1385
+ || profile.label !== selectedProfile.label
1386
+ || profile.audience !== selectedProfile.audience
1387
+ || !sameValues(profile.checklist, selectedProfile.checklist)
1388
+ || !sameValues(profile.evidenceOrder, selectedProfile.evidenceOrder)
1389
+ || !sameValues(profile.includedEvidence, selectedProfile.includedEvidence)
1390
+ || !sameValues(profile.excludedEvidence, selectedProfile.excludedEvidence)
1391
+ || !sameValues(profile.redactions, selectedProfile.redactions)
1392
+ || !sameValues(profile.attachments, selectedProfile.attachments)
1393
+ || !sameValues(profile.followUpFields, selectedProfile.followUpFields))
1394
+ fail('Case-response profile');
1395
+ const caseRecord = exact(root.case, ['id', 'domain', 'status', 'disposition', 'updatedAt'], 'Case-response case');
1396
+ text(caseRecord.id, 'Case-response case id', 128);
1397
+ domain(caseRecord.domain, 'Case-response case domain');
1398
+ text(caseRecord.status, 'Case-response case status', 80);
1399
+ text(caseRecord.disposition, 'Case-response case disposition', 80);
1400
+ iso(caseRecord.updatedAt, 'Case-response case updatedAt');
1401
+ const incident = exact(root.incident, ['category', 'affectedParty', 'abusiveUrls', 'observedHarm', 'observedAt'], 'Case-response incident');
1402
+ text(incident.category, 'Case-response category', 80);
1403
+ text(incident.affectedParty, 'Case-response affected party', 200);
1404
+ const urls = strings(incident.abusiveUrls, 'Case-response abusive URLs', MAX_ABUSIVE_URLS, 2_048);
1405
+ if (!urls.length || urls.some((item) => {
1406
+ try {
1407
+ const parsed = new URL(item);
1408
+ return !['http:', 'https:'].includes(parsed.protocol) || Boolean(parsed.username || parsed.password) || parsed.toString() !== item;
1409
+ }
1410
+ catch {
1411
+ return true;
1412
+ }
1413
+ }) || new Set(urls).size !== urls.length)
1414
+ fail('Case-response abusive URLs');
1415
+ text(incident.observedHarm, 'Case-response observed harm', 2_000);
1416
+ iso(incident.observedAt, 'Case-response observedAt');
1417
+ if (profile.subject !== `${selectedProfile?.subjectPrefix}: ${caseRecord.domain} (${incident.category})`)
1418
+ fail('Case-response profile subject');
1419
+ const contacts = array(root.contacts, 'Case-response contacts', MAX_RESPONSE_CONTACTS);
1420
+ const contactKeys = new Set();
1421
+ for (const candidate of contacts) {
1422
+ const contact = exact(candidate, ['kind', 'contact', 'source', 'observedAt', 'freshness', 'limitations'], 'Case-response contact');
1423
+ const kind = enumeration(contact.kind, RESPONSE_CONTACT_KINDS, 'Case-response contact kind');
1424
+ const contactValue = text(contact.contact, 'Case-response contact value', MAX_RESPONSE_RECIPIENT_LENGTH);
1425
+ const key = `${kind}\u0000${contactValue.toLowerCase()}`;
1426
+ if (contactKeys.has(key))
1427
+ fail('Case-response contacts');
1428
+ contactKeys.add(key);
1429
+ text(contact.source, 'Case-response contact source', 120);
1430
+ iso(contact.observedAt, 'Case-response contact observedAt', true);
1431
+ const freshness = enumeration(contact.freshness, ['current', 'stale', 'unknown'], 'Case-response contact freshness');
1432
+ const routeAge = contact.observedAt === null
1433
+ ? null
1434
+ : Date.parse(root.generatedAt) - Date.parse(contact.observedAt);
1435
+ const expectedFreshness = routeAge === null
1436
+ ? 'unknown'
1437
+ : routeAge < -300_000 || routeAge > RESPONSE_ROUTE_STALE_AFTER_DAYS * 86_400_000
1438
+ ? 'stale'
1439
+ : 'current';
1440
+ if (freshness !== expectedFreshness)
1441
+ fail('Case-response contact freshness');
1442
+ strings(contact.limitations, 'Case-response contact limitations', MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH);
1443
+ }
1444
+ const evidence = array(root.selectedEvidence, 'Case-response selected evidence', MAX_RESPONSE_SELECTED_EVIDENCE);
1445
+ const evidenceIds = new Set();
1446
+ for (const candidate of evidence) {
1447
+ const item = exact(candidate, ['id', 'label', 'source', 'observedAt', 'completeness', 'limitations'], 'Case-response selected evidence');
1448
+ const id = text(item.id, 'Case-response evidence id', 64);
1449
+ if (evidenceIds.has(id))
1450
+ fail('Case-response selected evidence');
1451
+ evidenceIds.add(id);
1452
+ text(item.label, 'Case-response evidence label', 80);
1453
+ text(item.source, 'Case-response evidence source', 120);
1454
+ iso(item.observedAt, 'Case-response evidence observedAt');
1455
+ enumeration(item.completeness, ['complete', 'partial', 'inconclusive', 'unknown'], 'Case-response evidence completeness');
1456
+ strings(item.limitations, 'Case-response evidence limitations', 8, 240);
1457
+ }
1458
+ const contradictions = array(root.contradictions, 'Case-response contradictions', MAX_RESPONSE_CONTRADICTIONS);
1459
+ const contradictionIds = new Set();
1460
+ for (const candidate of contradictions) {
1461
+ const item = exact(candidate, ['id', 'statement', 'state', 'limitations'], 'Case-response contradiction');
1462
+ const id = text(item.id, 'Case-response contradiction id', 64);
1463
+ if (contradictionIds.has(id))
1464
+ fail('Case-response contradictions');
1465
+ contradictionIds.add(id);
1466
+ text(item.statement, 'Case-response contradiction statement', 2_000);
1467
+ enumeration(item.state, ['open', 'resolved'], 'Case-response contradiction state');
1468
+ strings(item.limitations, 'Case-response contradiction limitations', 8, 240);
1469
+ }
1470
+ const readiness = exact(root.readiness, ['profileId', 'rows', 'counts', 'limitations'], 'Case-response readiness');
1471
+ if (readiness.profileId !== profileId)
1472
+ fail('Case-response readiness profile');
1473
+ const readinessRows = array(readiness.rows, 'Case-response readiness rows', RESPONSE_READINESS_ROW_IDS.length, RESPONSE_READINESS_ROW_IDS.length);
1474
+ const actualReadiness = Object.fromEntries(RESPONSE_READINESS_STATES.map((state) => [state, 0]));
1475
+ const requiredReadiness = new Set([
1476
+ 'observed_behaviour', 'exact_url', 'observation_time', 'capture_provenance', 'authority_review',
1477
+ 'selected_evidence', 'contradictions', 'source_limitations',
1478
+ ...(profileId === 'internal_soc' ? [] : ['recipient_route']),
1479
+ ...(['registrar', 'registry', 'network_hosting'].includes(profileId) ? ['infrastructure_responsibility'] : []),
1480
+ ]);
1481
+ const requiredContact = selectedProfile?.requiredContactKind
1482
+ ? contacts.find((candidate) => candidate.kind === selectedProfile.requiredContactKind)
1483
+ : contacts[0];
1484
+ const expectedReadinessStates = {
1485
+ observed_behaviour: 'complete',
1486
+ exact_url: 'complete',
1487
+ observation_time: expectedObservationAge(incident.observedAt, root.generatedAt).refreshRecommended ? 'stale' : 'complete',
1488
+ capture_provenance: evidence.length ? 'complete' : 'unavailable',
1489
+ recipient_route: !requiredContact
1490
+ ? 'not_provided'
1491
+ : requiredContact.freshness === 'stale'
1492
+ ? 'stale'
1493
+ : requiredContact.freshness === 'current' && requiredContact.source ? 'complete' : 'partial',
1494
+ selected_evidence: !evidence.length
1495
+ ? 'not_provided'
1496
+ : evidence.every((candidate) => candidate.completeness === 'complete') ? 'complete' : 'partial',
1497
+ ...(contradictions.length ? {
1498
+ contradictions: contradictions.some((candidate) => candidate.state === 'open') ? 'partial' : 'complete',
1499
+ } : {}),
1500
+ };
1501
+ for (const [index, candidate] of readinessRows.entries()) {
1502
+ const row = exact(candidate, ['id', 'label', 'state', 'detail', 'requiredForAuthorisation', 'limitations'], 'Case-response readiness row');
1503
+ if (row.id !== RESPONSE_READINESS_ROW_IDS[index])
1504
+ fail('Case-response readiness row order');
1505
+ text(row.label, 'Case-response readiness label', 200);
1506
+ const state = enumeration(row.state, RESPONSE_READINESS_STATES, 'Case-response readiness state');
1507
+ actualReadiness[state] = (actualReadiness[state] ?? 0) + 1;
1508
+ text(row.detail, 'Case-response readiness detail', 500);
1509
+ boolean(row.requiredForAuthorisation, 'Case-response readiness requirement');
1510
+ if (row.requiredForAuthorisation !== requiredReadiness.has(row.id)
1511
+ || (expectedReadinessStates[row.id] !== undefined
1512
+ && row.state !== expectedReadinessStates[row.id]))
1513
+ fail('Case-response readiness projection');
1514
+ strings(row.limitations, 'Case-response readiness limitations', 8, 240);
1515
+ }
1516
+ const readinessCounts = exact(readiness.counts, [...RESPONSE_READINESS_STATES], 'Case-response readiness counts');
1517
+ for (const state of RESPONSE_READINESS_STATES)
1518
+ if (integer(readinessCounts[state], 'Case-response readiness count', 0, readinessRows.length) !== actualReadiness[state])
1519
+ fail('Case-response readiness counts');
1520
+ strings(readiness.limitations, 'Case-response readiness limitations', 8, 600);
1521
+ const artefacts = array(root.artefactReferences, 'Case-response artefact references', MAX_RESPONSE_ARTEFACT_REFERENCES);
1522
+ const artefactIds = new Set();
1523
+ for (const candidate of artefacts) {
1524
+ const item = exact(candidate, ['id', 'label', 'mediaType', 'capturedAt', 'source', 'digestSha256', 'byteLength', 'limitations'], 'Case-response artefact reference');
1525
+ const id = text(item.id, 'Case-response artefact id', 64);
1526
+ if (artefactIds.has(id))
1527
+ fail('Case-response artefact references');
1528
+ artefactIds.add(id);
1529
+ text(item.label, 'Case-response artefact label', 120);
1530
+ text(item.mediaType, 'Case-response artefact media type', 120);
1531
+ iso(item.capturedAt, 'Case-response artefact capturedAt');
1532
+ text(item.source, 'Case-response artefact source', 120);
1533
+ if (typeof item.digestSha256 !== 'string' || !HEX_DIGEST_RE.test(item.digestSha256))
1534
+ fail('Case-response artefact digest');
1535
+ if (item.byteLength !== null)
1536
+ integer(item.byteLength, 'Case-response artefact byte length', 0, 100 * 1024 * 1024);
1537
+ strings(item.limitations, 'Case-response artefact limitations', 8, 240);
1538
+ }
1539
+ const authorisation = exact(root.authorisation, ['status', 'reviewedInputDigestSha256', 'suppliedReviewDigestSha256', 'digestMatches', 'confirmedAt', 'confirmations', 'missingConfirmations', 'limitations'], 'Case-response authorisation');
1540
+ const authorisationStatus = enumeration(authorisation.status, ['draft', 'authorised'], 'Case-response authorisation status');
1541
+ if (typeof authorisation.reviewedInputDigestSha256 !== 'string' || !HEX_DIGEST_RE.test(authorisation.reviewedInputDigestSha256))
1542
+ fail('Case-response review digest');
1543
+ if (authorisation.suppliedReviewDigestSha256 !== null && (typeof authorisation.suppliedReviewDigestSha256 !== 'string' || !HEX_DIGEST_RE.test(authorisation.suppliedReviewDigestSha256)))
1544
+ fail('Case-response supplied review digest');
1545
+ boolean(authorisation.digestMatches, 'Case-response authorisation digest match');
1546
+ iso(authorisation.confirmedAt, 'Case-response authorisation confirmedAt', true);
1547
+ if (authorisation.confirmedAt !== null
1548
+ && Date.parse(authorisation.confirmedAt) > Date.parse(root.generatedAt) + MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS) {
1549
+ fail('Case-response authorisation confirmedAt');
1550
+ }
1551
+ const confirmations = exact(authorisation.confirmations, [...RESPONSE_AUTHORISATION_CONFIRMATION_IDS], 'Case-response confirmations');
1552
+ for (const id of RESPONSE_AUTHORISATION_CONFIRMATION_IDS)
1553
+ boolean(confirmations[id], `Case-response confirmation ${id}`);
1554
+ const missing = strings(authorisation.missingConfirmations, 'Case-response missing confirmations', RESPONSE_AUTHORISATION_CONFIRMATION_IDS.length, 80);
1555
+ if (missing.some((id) => !RESPONSE_AUTHORISATION_CONFIRMATION_IDS.includes(id)))
1556
+ fail('Case-response missing confirmations');
1557
+ strings(authorisation.limitations, 'Case-response authorisation limitations', 8, 600);
1558
+ const expectedMissing = RESPONSE_AUTHORISATION_CONFIRMATION_IDS.filter((id) => confirmations[id] !== true);
1559
+ const expectedDigestMatches = authorisation.suppliedReviewDigestSha256 === authorisation.reviewedInputDigestSha256;
1560
+ const missingRequiredReadiness = readinessRows.some((candidate) => {
1561
+ const row = candidate;
1562
+ return row.requiredForAuthorisation === true && (row.state === 'not_provided' || row.state === 'unavailable');
1563
+ });
1564
+ const authorityReady = readinessRows.some((candidate) => {
1565
+ const row = candidate;
1566
+ return row.id === 'authority_review' && row.state === 'complete';
1567
+ });
1568
+ if (authorisation.digestMatches !== expectedDigestMatches
1569
+ || !sameValues(missing, expectedMissing)
1570
+ || (authorisationStatus === 'authorised' && (authorisation.digestMatches !== true || authorisation.confirmedAt === null || missing.length || missingRequiredReadiness || !authorityReady))
1571
+ || (authorisationStatus === 'draft' && authorisation.confirmedAt !== null))
1572
+ fail('Case-response authorisation');
1573
+ const preflight = exact(root.preflight, ['version', 'status', 'canExport', 'counts', 'checks', 'actionSummary'], 'Case-response preflight');
1574
+ if (preflight.version !== 2)
1575
+ fail('Case-response preflight');
1576
+ const checks = array(preflight.checks, 'Case-response preflight checks', CASE_RESPONSE_PREFLIGHT_IDS.length, CASE_RESPONSE_PREFLIGHT_IDS.length);
1577
+ const actualCounts = { block: 0, caution: 0, pass: 0 };
1578
+ for (const [index, candidate] of checks.entries()) {
1579
+ const check = exact(candidate, ['id', 'label', 'state', 'detail'], 'Case-response preflight check');
1580
+ if (check.id !== CASE_RESPONSE_PREFLIGHT_IDS[index])
1581
+ fail('Case-response preflight check order');
1582
+ text(check.label, 'Case-response preflight label', 200);
1583
+ const state = enumeration(check.state, ['block', 'caution', 'pass'], 'Case-response preflight state');
1584
+ actualCounts[state] += 1;
1585
+ text(check.detail, 'Case-response preflight detail', 1_000);
1586
+ }
1587
+ const counts = exact(preflight.counts, ['block', 'caution', 'pass'], 'Case-response preflight counts');
1588
+ for (const state of ['block', 'caution', 'pass'])
1589
+ if (integer(counts[state], 'Case-response preflight count', 0, checks.length) !== actualCounts[state])
1590
+ fail('Case-response preflight counts');
1591
+ const expectedStatus = actualCounts.block ? 'needs_input' : actualCounts.caution ? 'review_cautions' : 'ready_for_review';
1592
+ if (preflight.status !== expectedStatus || preflight.canExport !== (actualCounts.block === 0))
1593
+ fail('Case-response preflight status');
1594
+ const actionTotal = validateCurrentActionSummary(preflight.actionSummary, 'Case-response action summary');
1595
+ const history = array(root.escalationHistory, 'Case-response escalation history', MAX_RESPONSE_ACTION_HISTORY);
1596
+ const escalationHistoryOmitted = integer(root.escalationHistoryOmitted, 'Case-response omitted actions', 0, MAX_CASE_ACTIONS);
1597
+ const escalationHistoryLimitations = strings(root.escalationHistoryLimitations, 'Case-response history limitations', 4, 600);
1598
+ if (history.length !== Math.min(actionTotal, MAX_RESPONSE_ACTION_HISTORY)
1599
+ || escalationHistoryOmitted !== actionTotal - history.length
1600
+ || (escalationHistoryOmitted > 0) !== (escalationHistoryLimitations.length > 0))
1601
+ fail('Case-response action-history bounds');
1602
+ const actionIds = new Set();
1603
+ const providerEvents = [];
1604
+ for (const candidate of history) {
1605
+ const action = exact(candidate, ['actionId', 'type', 'recipient', 'contactSource', 'state', 'reference', 'providerOutcome', 'outcomeDetail', 'originActionId', 'historyOmitted', 'historyLimitations', 'transitions', 'createdAt', 'updatedAt'], 'Case-response escalation action');
1606
+ const actionId = text(action.actionId, 'Case-response action id', 64);
1607
+ if (actionIds.has(actionId))
1608
+ fail('Case-response action identity');
1609
+ actionIds.add(actionId);
1610
+ enumeration(action.type, CASE_ACTION_TYPES, 'Case-response action type');
1611
+ text(action.recipient, 'Case-response action recipient', 320);
1612
+ text(action.contactSource, 'Case-response action source', 120);
1613
+ enumeration(action.state, CASE_ACTION_STATES, 'Case-response action state');
1614
+ optionalText(action.reference, 'Case-response action reference', 500);
1615
+ if (action.providerOutcome !== null)
1616
+ enumeration(action.providerOutcome, CASE_PROVIDER_OUTCOMES, 'Case-response provider outcome');
1617
+ optionalText(action.outcomeDetail, 'Case-response action outcome detail', 2_000);
1618
+ optionalText(action.originActionId, 'Case-response action origin', 64);
1619
+ integer(action.historyOmitted, 'Case-response action omitted history', 0, 1_000_000);
1620
+ strings(action.historyLimitations, 'Case-response action history limitations', 8, 240);
1621
+ const transitions = array(action.transitions, 'Case-response action transitions', MAX_CASE_ACTION_EVENTS_PER_ACTION, 1);
1622
+ const eventIds = new Set();
1623
+ let projectedState = null;
1624
+ let latestReference = null;
1625
+ let latestProviderOutcome = null;
1626
+ let latestProviderOutcomeDetail = null;
1627
+ let latestOutcomeDetail = null;
1628
+ let previousTime = Number.NEGATIVE_INFINITY;
1629
+ let previousEventId = '';
1630
+ for (const eventCandidate of transitions) {
1631
+ const event = exact(eventCandidate, ['id', 'previousState', 'nextState', 'occurredAt', 'sourceClass', 'provenance', 'reference', 'evidencePinId', 'limitations', 'providerOutcome', 'outcomeDetail', 'originActionId', 'applied'], 'Case-response action transition');
1632
+ const eventId = text(event.id, 'Case-response action event id', 64);
1633
+ if (eventIds.has(eventId))
1634
+ fail('Case-response action event identity');
1635
+ eventIds.add(eventId);
1636
+ const previousState = event.previousState === null ? null : enumeration(event.previousState, CASE_ACTION_STATES, 'Case-response previous state');
1637
+ const nextState = enumeration(event.nextState, CASE_ACTION_STATES, 'Case-response next state');
1638
+ iso(event.occurredAt, 'Case-response action event time');
1639
+ const eventTime = Date.parse(event.occurredAt);
1640
+ if (eventTime < previousTime || (eventTime === previousTime && eventId < previousEventId))
1641
+ fail('Case-response action event order');
1642
+ previousTime = eventTime;
1643
+ previousEventId = eventId;
1644
+ const sourceClass = enumeration(event.sourceClass, CASE_ACTION_EVENT_SOURCE_CLASSES, 'Case-response action event source class');
1645
+ text(event.provenance, 'Case-response action event provenance', 80);
1646
+ optionalText(event.reference, 'Case-response action event reference', 500);
1647
+ optionalText(event.evidencePinId, 'Case-response action event evidence pin', 64);
1648
+ strings(event.limitations, 'Case-response action event limitations', 8, 240);
1649
+ const providerOutcome = event.providerOutcome === null ? null : enumeration(event.providerOutcome, CASE_PROVIDER_OUTCOMES, 'Case-response action event provider outcome');
1650
+ if (providerOutcome !== null && !['submitted', 'acknowledged', 'terminal'].includes(nextState) && sourceClass !== 'migration')
1651
+ fail('Case-response action event provider outcome');
1652
+ const migrationSnapshot = sourceClass === 'migration' && previousState === null;
1653
+ if (providerOutcome !== null && previousState === 'authorised' && nextState === 'submitted' && !migrationSnapshot)
1654
+ fail('Case-response submission event outcome');
1655
+ if (providerOutcome === 'no_response' && sourceClass === 'provider')
1656
+ fail('Case-response no-response event source');
1657
+ if (['ready_for_review', 'reviewed', 'authorised', 'submitted'].includes(nextState)
1658
+ && sourceClass !== 'analyst' && !migrationSnapshot)
1659
+ fail('Case-response explicit analyst transition');
1660
+ if (nextState === 'terminal' && previousState !== null
1661
+ && ['drafting', 'ready_for_review', 'reviewed', 'authorised'].includes(previousState)
1662
+ && (sourceClass !== 'analyst' || providerOutcome !== 'withdrawn'))
1663
+ fail('Case-response early terminal transition');
1664
+ if (providerOutcome === 'withdrawn'
1665
+ && (nextState !== 'terminal' || (sourceClass !== 'analyst' && !migrationSnapshot)))
1666
+ fail('Case-response withdrawn transition');
1667
+ optionalText(event.outcomeDetail, 'Case-response action event outcome detail', 2_000);
1668
+ optionalText(event.originActionId, 'Case-response action event origin', 64);
1669
+ if (event.originActionId === actionId)
1670
+ fail('Case-response action event origin');
1671
+ boolean(event.applied, 'Case-response action event projection');
1672
+ const applied = event.applied;
1673
+ if (providerOutcome !== null) {
1674
+ providerEvents.push({
1675
+ actionId,
1676
+ eventId,
1677
+ outcome: providerOutcome,
1678
+ occurredAt: event.occurredAt,
1679
+ reference: event.reference,
1680
+ applied,
1681
+ });
1682
+ }
1683
+ if (projectedState === null && Number(action.historyOmitted) > 0 && previousState !== null)
1684
+ projectedState = previousState;
1685
+ const expectedApplied = previousState === projectedState
1686
+ && isLegalCaseActionTransition(previousState, nextState, sourceClass);
1687
+ if (applied !== expectedApplied)
1688
+ fail('Case-response action event projection');
1689
+ if (applied) {
1690
+ projectedState = nextState;
1691
+ if (event.reference !== null)
1692
+ latestReference = event.reference;
1693
+ if (event.outcomeDetail !== null)
1694
+ latestOutcomeDetail = event.outcomeDetail;
1695
+ if (providerOutcome !== null) {
1696
+ latestProviderOutcome = providerOutcome;
1697
+ latestProviderOutcomeDetail = event.outcomeDetail;
1698
+ }
1699
+ }
1700
+ }
1701
+ if ((projectedState ?? 'drafting') !== action.state
1702
+ || latestReference !== action.reference
1703
+ || latestProviderOutcome !== action.providerOutcome
1704
+ || (latestProviderOutcomeDetail ?? latestOutcomeDetail) !== action.outcomeDetail)
1705
+ fail('Case-response action projection');
1706
+ if (action.originActionId === actionId)
1707
+ fail('Case-response action origin');
1708
+ iso(action.createdAt, 'Case-response action createdAt');
1709
+ iso(action.updatedAt, 'Case-response action updatedAt');
1710
+ }
1711
+ const lifecycle = exact(root.responseLifecycle, ['providerOutcomeState', 'latestProviderOutcome', 'observedChangeState', 'latestObservedEffect', 'latestObservedChangeAt', 'closure', 'limitations'], 'Case-response lifecycle');
1712
+ const providerOutcomeState = enumeration(lifecycle.providerOutcomeState, ['available', 'missing', 'ambiguous'], 'Case-response lifecycle provider state');
1713
+ let actualLatestProvider = null;
1714
+ if (lifecycle.latestProviderOutcome !== null) {
1715
+ const outcome = exact(lifecycle.latestProviderOutcome, ['actionId', 'eventId', 'outcome', 'occurredAt', 'reference'], 'Case-response lifecycle provider outcome');
1716
+ const actionId = text(outcome.actionId, 'Case-response lifecycle action id', 64);
1717
+ const eventId = text(outcome.eventId, 'Case-response lifecycle event id', 64);
1718
+ const providerOutcome = enumeration(outcome.outcome, CASE_PROVIDER_OUTCOMES, 'Case-response lifecycle provider outcome');
1719
+ iso(outcome.occurredAt, 'Case-response lifecycle provider time');
1720
+ const occurredAt = outcome.occurredAt;
1721
+ optionalText(outcome.reference, 'Case-response lifecycle reference', 500);
1722
+ actualLatestProvider = {
1723
+ actionId,
1724
+ eventId,
1725
+ outcome: providerOutcome,
1726
+ occurredAt,
1727
+ reference: outcome.reference,
1728
+ };
1729
+ }
1730
+ providerEvents.sort((left, right) => Date.parse(right.occurredAt) - Date.parse(left.occurredAt)
1731
+ || (right.eventId < left.eventId ? -1 : right.eventId > left.eventId ? 1 : 0));
1732
+ const latestProviderTime = providerEvents[0]?.occurredAt ?? null;
1733
+ const latestProviderEvents = latestProviderTime === null
1734
+ ? []
1735
+ : providerEvents.filter((event) => event.occurredAt === latestProviderTime);
1736
+ const expectedLatestProvider = escalationHistoryOmitted === 0
1737
+ && latestProviderEvents.length === 1 && latestProviderEvents[0].applied
1738
+ ? latestProviderEvents[0]
1739
+ : null;
1740
+ const expectedProviderState = escalationHistoryOmitted > 0
1741
+ ? 'ambiguous'
1742
+ : providerEvents.length === 0 ? 'missing' : expectedLatestProvider ? 'available' : 'ambiguous';
1743
+ if (providerOutcomeState !== expectedProviderState
1744
+ || (actualLatestProvider === null) !== (expectedLatestProvider === null)
1745
+ || (actualLatestProvider && expectedLatestProvider && (actualLatestProvider.actionId !== expectedLatestProvider.actionId
1746
+ || actualLatestProvider.eventId !== expectedLatestProvider.eventId
1747
+ || actualLatestProvider.outcome !== expectedLatestProvider.outcome
1748
+ || actualLatestProvider.occurredAt !== expectedLatestProvider.occurredAt
1749
+ || actualLatestProvider.reference !== expectedLatestProvider.reference)))
1750
+ fail('Case-response lifecycle provider state');
1751
+ const observedChangeState = enumeration(lifecycle.observedChangeState, ['available', 'missing', 'ambiguous'], 'Case-response lifecycle observed-change state');
1752
+ if (lifecycle.latestObservedEffect !== null) {
1753
+ const effect = exact(lifecycle.latestObservedEffect, ['reviewId', 'state', 'observedAt', 'sourceClass', 'source'], 'Case-response lifecycle observed effect');
1754
+ text(effect.reviewId, 'Case-response effect review id', 64);
1755
+ enumeration(effect.state, CASE_OBSERVED_EFFECT_STATES, 'Case-response effect state');
1756
+ iso(effect.observedAt, 'Case-response effect observedAt');
1757
+ enumeration(effect.sourceClass, CASE_OBSERVED_EFFECT_SOURCE_CLASSES, 'Case-response effect source class');
1758
+ text(effect.source, 'Case-response effect source', 80);
1759
+ }
1760
+ iso(lifecycle.latestObservedChangeAt, 'Case-response observed change time', true);
1761
+ if ((observedChangeState === 'available') !== (lifecycle.latestObservedChangeAt !== null))
1762
+ fail('Case-response lifecycle observed-change state');
1763
+ if (lifecycle.closure !== null) {
1764
+ const closure = exact(lifecycle.closure, ['id', 'reason', 'createdAt', 'limitations'], 'Case-response closure');
1765
+ text(closure.id, 'Case-response closure id', 64);
1766
+ enumeration(closure.reason, CASE_CLOSURE_REASONS, 'Case-response closure reason');
1767
+ iso(closure.createdAt, 'Case-response closure time');
1768
+ strings(closure.limitations, 'Case-response closure limitations', 8, 240);
1769
+ }
1770
+ strings(lifecycle.limitations, 'Case-response lifecycle limitations', 8, 600);
1771
+ const reviewedInputs = {
1772
+ contract: CASE_RESPONSE_REVIEW_INPUTS_SCHEMA,
1773
+ version: CASE_RESPONSE_REVIEW_INPUTS_VERSION,
1774
+ profile: {
1775
+ id: profile.id,
1776
+ label: profile.label,
1777
+ audience: profile.audience,
1778
+ subject: profile.subject,
1779
+ checklist: profile.checklist,
1780
+ includedEvidence: profile.includedEvidence,
1781
+ excludedEvidence: profile.excludedEvidence,
1782
+ redactions: profile.redactions,
1783
+ },
1784
+ case: root.case,
1785
+ incident: root.incident,
1786
+ contacts: root.contacts,
1787
+ selectedEvidence: root.selectedEvidence,
1788
+ contradictions: root.contradictions,
1789
+ readiness: root.readiness,
1790
+ artefactReferences: root.artefactReferences,
1791
+ escalationHistory: root.escalationHistory,
1792
+ escalationHistoryOmitted: root.escalationHistoryOmitted,
1793
+ escalationHistoryLimitations: root.escalationHistoryLimitations,
1794
+ responseLifecycle: root.responseLifecycle,
1795
+ };
1796
+ const expectedReviewDigest = createHash('sha256')
1797
+ .update(canonicalArtifactJsonV2(reviewedInputs))
1798
+ .digest('hex');
1799
+ if (authorisation.reviewedInputDigestSha256 !== expectedReviewDigest)
1800
+ fail('Case-response reviewed-input digest');
1801
+ const provenance = exact(root.provenance, ['latestEvidenceCapturedAt', 'evidencePinCount', 'decisionCount', 'assertionCount', 'observationAge', 'limitations'], 'Case-response provenance');
1802
+ iso(provenance.latestEvidenceCapturedAt, 'Case-response evidence time', true);
1803
+ integer(provenance.evidencePinCount, 'Case-response evidence pin count', 0, MAX_CASE_EVIDENCE_PINS);
1804
+ integer(provenance.decisionCount, 'Case-response decision count', 0, MAX_CASE_DECISIONS);
1805
+ integer(provenance.assertionCount, 'Case-response assertion count', 0, MAX_CASE_ASSERTIONS);
1806
+ const age = exact(provenance.observationAge, ['ageSeconds', 'band', 'refreshRecommended'], 'Case-response observation age');
1807
+ integer(age.ageSeconds, 'Case-response observation age seconds', Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER);
1808
+ enumeration(age.band, ['future_or_clock_skew', 'one_to_seven_days', 'over_seven_days', 'under_24_hours'], 'Case-response observation age band');
1809
+ boolean(age.refreshRecommended, 'Case-response refresh recommendation');
1810
+ const expectedAge = expectedObservationAge(incident.observedAt, root.generatedAt);
1811
+ if (age.ageSeconds !== expectedAge.ageSeconds || age.band !== expectedAge.band || age.refreshRecommended !== expectedAge.refreshRecommended)
1812
+ fail('Case-response observation age');
1813
+ strings(provenance.limitations, 'Case-response limitations', 8, 600);
1814
+ const integrity = exact(root.integrity, ['algorithm', 'canonicalization', 'scope', 'digestSha256'], 'Case-response integrity');
1815
+ if (integrity.algorithm !== 'SHA-256' || integrity.canonicalization !== 'sorted-json-v2' || integrity.scope !== 'packet excluding integrity')
1816
+ fail('Case-response integrity');
1817
+ if (typeof integrity.digestSha256 !== 'string' || !HEX_DIGEST_RE.test(integrity.digestSha256))
1818
+ fail('Case-response integrity');
1819
+ }
1820
+ function validateCaseResponsePacket(value) {
1821
+ if (value.schemaVersion === PUBLIC_CASE_RESPONSE_PACKET_VERSION)
1822
+ return validateCaseResponsePacketV6(value);
1823
+ if (Number.isSafeInteger(value.schemaVersion) && value.schemaVersion < CASE_RESPONSE_PACKET_VERSION) {
1824
+ throw new TypeError(`Case-response packet version ${String(value.schemaVersion)} is not part of the public compatibility boundary; no data was changed.`);
1825
+ }
1826
+ if (Number.isSafeInteger(value.schemaVersion) && value.schemaVersion > CASE_RESPONSE_PACKET_VERSION) {
1827
+ throw new TypeError(`Case-response packet version ${String(value.schemaVersion)} is newer than the supported version ${CASE_RESPONSE_PACKET_VERSION}; no data was changed.`);
1828
+ }
1829
+ validateCaseResponsePacketV7(value);
1830
+ }
1831
+ function validateBriefFact(value, label) {
1832
+ const fact = exact(value, ['label', 'value', 'detail', 'provenance'], label);
1833
+ text(fact.label, label, 320);
1834
+ text(fact.value, label, 2_000);
1835
+ text(fact.detail, label, 1_000, true);
1836
+ const provenance = exact(fact.provenance, ['sources', 'observedAt', 'fieldFamilies', 'normalization', 'completeness', 'limitations', 'conflicts', 'decisionImpact'], label);
1837
+ strings(provenance.sources, label, 8, 320);
1838
+ text(provenance.observedAt, label, 64);
1839
+ strings(provenance.fieldFamilies, label, 8, 320);
1840
+ text(provenance.normalization, label, 320);
1841
+ text(provenance.completeness, label, 320);
1842
+ strings(provenance.limitations, label, 8, 320);
1843
+ strings(provenance.conflicts, label, 8, 320);
1844
+ text(provenance.decisionImpact, label, 320);
1845
+ }
1846
+ function validateDecisionEntry(value, label) {
1847
+ const entry = exact(value, ['id', 'state', 'importance', 'title', 'detail', 'sources', 'href'], label);
1848
+ text(entry.id, label, 80);
1849
+ enumeration(entry.state, ['conflict', 'uncertain'], label);
1850
+ enumeration(entry.importance, ['high', 'medium', 'low'], label);
1851
+ text(entry.title, label, 320);
1852
+ text(entry.detail, label, 1_000);
1853
+ strings(entry.sources, label, 12, 320);
1854
+ if (typeof entry.href !== 'string' || !/^#[^\u0000-\u001f\u007f]{1,160}$/u.test(entry.href))
1855
+ fail(label);
1856
+ }
1857
+ function validatePublicBrief(value) {
1858
+ const brief = exact(value, ['schema', 'schemaVersion', 'generatedAt', 'target', 'targetType', 'task', 'taskLabel', 'question', 'summary', 'observation', 'verifiedFacts', 'contradictions', 'unknowns', 'nextActions', 'relationships', 'limitations'], 'Investigation capsule brief');
1859
+ if (brief.schema !== LOOKUP_INVESTIGATION_BRIEF_SCHEMA
1860
+ || brief.schemaVersion !== PUBLIC_LOOKUP_INVESTIGATION_BRIEF_VERSION)
1861
+ fail('Investigation capsule brief');
1862
+ iso(brief.generatedAt, 'Investigation capsule brief generatedAt');
1863
+ text(brief.target, 'Investigation capsule brief target', 253);
1864
+ text(brief.targetType, 'Investigation capsule brief target type', 40);
1865
+ enumeration(brief.task, ['general', 'acquisition', 'brand', 'incident', 'owned'], 'Investigation capsule brief task');
1866
+ text(brief.taskLabel, 'Investigation capsule brief task label', 320);
1867
+ text(brief.question, 'Investigation capsule brief question', 320);
1868
+ text(brief.summary, 'Investigation capsule brief summary', 500);
1869
+ const observation = exact(brief.observation, ['observedAt', 'evidenceAgeDays', 'completeSources', 'limitedSources', 'freshnessPolicy'], 'Investigation capsule observation');
1870
+ iso(observation.observedAt, 'Investigation capsule observation time', true);
1871
+ if (observation.evidenceAgeDays !== null)
1872
+ integer(observation.evidenceAgeDays, 'Investigation capsule evidence age', 0, 1_000_000);
1873
+ integer(observation.completeSources, 'Investigation capsule complete sources', 0, 100);
1874
+ integer(observation.limitedSources, 'Investigation capsule limited sources', 0, 100);
1875
+ const policy = exact(observation.freshnessPolicy, ['version', 'id', 'task', 'thresholdsDays'], 'Investigation capsule freshness policy');
1876
+ if (policy.version !== 1)
1877
+ fail('Investigation capsule freshness policy');
1878
+ enumeration(policy.id, ['task-default', 'analyst-custom'], 'Investigation capsule freshness policy');
1879
+ if (policy.task !== brief.task)
1880
+ fail('Investigation capsule freshness policy');
1881
+ const thresholds = exact(policy.thresholdsDays, ['registration', 'network', 'web'], 'Investigation capsule freshness thresholds');
1882
+ for (const key of ['registration', 'network', 'web'])
1883
+ integer(thresholds[key], 'Investigation capsule freshness threshold', 0, 3650);
1884
+ array(brief.verifiedFacts, 'Investigation capsule verified facts', 12).forEach((item, index) => validateBriefFact(item, `Investigation capsule fact ${index + 1}`));
1885
+ array(brief.contradictions, 'Investigation capsule contradictions', 24).forEach((item, index) => validateDecisionEntry(item, `Investigation capsule contradiction ${index + 1}`));
1886
+ array(brief.unknowns, 'Investigation capsule unknowns', 24).forEach((item, index) => validateDecisionEntry(item, `Investigation capsule unknown ${index + 1}`));
1887
+ for (const [index, candidate] of array(brief.nextActions, 'Investigation capsule next actions', 6).entries()) {
1888
+ const action = exact(candidate, ['id', 'label', 'reason', 'expectedOutcome', 'href', 'priority'], `Investigation capsule next action ${index + 1}`);
1889
+ text(action.id, 'Investigation capsule next action id', 80);
1890
+ text(action.label, 'Investigation capsule next action label', 320);
1891
+ text(action.reason, 'Investigation capsule next action reason', 500);
1892
+ text(action.expectedOutcome, 'Investigation capsule next action outcome', 500);
1893
+ if (typeof action.href !== 'string' || !/^#[^\u0000-\u001f\u007f]{1,160}$/u.test(action.href))
1894
+ fail('Investigation capsule next action href');
1895
+ enumeration(action.priority, ['high', 'medium', 'low'], 'Investigation capsule next action priority');
1896
+ }
1897
+ const relationships = exact(brief.relationships, ['nodes', 'edges', 'truncated', 'kinds'], 'Investigation capsule relationship summary');
1898
+ integer(relationships.nodes, 'Investigation capsule relationship node count', 0, 72);
1899
+ integer(relationships.edges, 'Investigation capsule relationship edge count', 0, 120);
1900
+ boolean(relationships.truncated, 'Investigation capsule relationship truncation');
1901
+ strings(relationships.kinds, 'Investigation capsule relationship kinds', 12, 320);
1902
+ strings(brief.limitations, 'Investigation capsule brief limitations', 20, 320);
1903
+ }
1904
+ function validateProjectionCollection(value, label, totalMaximum, displayedMaximum, validateItem) {
1905
+ const collection = exact(value, ['total', 'displayed', 'omitted', 'items'], label);
1906
+ const total = integer(collection.total, `${label} total`, 0, totalMaximum);
1907
+ const displayed = integer(collection.displayed, `${label} displayed`, 0, displayedMaximum);
1908
+ const omitted = integer(collection.omitted, `${label} omitted`, 0, totalMaximum);
1909
+ const items = array(collection.items, `${label} items`, displayedMaximum);
1910
+ if (displayed > total || omitted !== total - displayed || items.length !== displayed)
1911
+ fail(label);
1912
+ items.forEach(validateItem);
1913
+ return { total, displayed, omitted, items };
1914
+ }
1915
+ function validateSortedUnique(values, label) {
1916
+ for (let index = 0; index < values.length; index += 1) {
1917
+ if (index > 0 && values[index - 1] >= values[index])
1918
+ fail(label);
1919
+ }
1920
+ }
1921
+ function validateDecisionIdentifier(value, label) {
1922
+ const identifier = text(value, label, 200);
1923
+ if (!/^[a-z0-9](?:[a-z0-9._:-]{0,199})$/u.test(identifier))
1924
+ fail(label);
1925
+ return identifier;
1926
+ }
1927
+ function validateProjectedStringCollection(value, label, totalMaximum, displayedMaximum, textMaximum) {
1928
+ const projection = validateProjectionCollection(value, label, totalMaximum, displayedMaximum, (item) => { text(item, `${label} item`, textMaximum); });
1929
+ validateSortedUnique(projection.items, label);
1930
+ return projection;
1931
+ }
1932
+ function validateDecisionFactSource(value, label) {
1933
+ const source = exact(value, [
1934
+ 'id',
1935
+ 'label',
1936
+ 'provenance',
1937
+ 'evidenceState',
1938
+ 'observedAt',
1939
+ 'references',
1940
+ 'limitations',
1941
+ ], label);
1942
+ const id = validateDecisionIdentifier(source.id, `${label} id`);
1943
+ text(source.label, `${label} label`, 160);
1944
+ enumeration(source.provenance, DECISION_FACT_PROVENANCE_STATES, `${label} provenance`);
1945
+ enumeration(source.evidenceState, DECISION_FACT_EVIDENCE_STATES, `${label} evidence state`);
1946
+ iso(source.observedAt, `${label} observation time`, true);
1947
+ validateProjectedStringCollection(source.references, `${label} references`, MAX_DECISION_FACT_REFERENCES, MAX_DECISION_FACT_PROJECTION_SOURCE_REFERENCES, 200);
1948
+ validateProjectedStringCollection(source.limitations, `${label} limitations`, MAX_DECISION_FACT_LIMITATIONS, MAX_DECISION_FACT_PROJECTION_SOURCE_LIMITATIONS, 280);
1949
+ return id;
1950
+ }
1951
+ function validateProjectedDecisionFact(value, label) {
1952
+ const fact = exact(value, [
1953
+ 'version',
1954
+ 'id',
1955
+ 'question',
1956
+ 'conclusion',
1957
+ 'importance',
1958
+ 'evidenceState',
1959
+ 'completeness',
1960
+ 'freshness',
1961
+ 'consistency',
1962
+ 'dependencies',
1963
+ 'sourceReferences',
1964
+ 'sources',
1965
+ 'contradictions',
1966
+ 'limitations',
1967
+ 'safeNextActions',
1968
+ ], label);
1969
+ if (fact.version !== DECISION_FACT_VERSION)
1970
+ fail(`${label} version`);
1971
+ const id = validateDecisionIdentifier(fact.id, `${label} id`);
1972
+ text(fact.question, `${label} question`, 320);
1973
+ text(fact.conclusion, `${label} conclusion`, 640);
1974
+ enumeration(fact.importance, ['high', 'medium', 'low'], `${label} importance`);
1975
+ const evidenceState = enumeration(fact.evidenceState, DECISION_FACT_EVIDENCE_STATES, `${label} evidence state`);
1976
+ if (fact.completeness !== decisionFactCompleteness(evidenceState))
1977
+ fail(`${label} completeness`);
1978
+ enumeration(fact.freshness, DECISION_FACT_FRESHNESS_STATES, `${label} freshness`);
1979
+ const consistency = enumeration(fact.consistency, DECISION_FACT_CONSISTENCY_STATES, `${label} consistency`);
1980
+ const dependencies = validateProjectedStringCollection(fact.dependencies, `${label} dependencies`, MAX_DECISION_FACT_CONTRIBUTORS, MAX_DECISION_FACT_PROJECTION_SOURCES, 200);
1981
+ dependencies.items.forEach((item) => validateDecisionIdentifier(item, `${label} dependency`));
1982
+ validateProjectedStringCollection(fact.sourceReferences, `${label} source references`, MAX_DECISION_FACT_REFERENCES, MAX_DECISION_FACT_PROJECTION_REFERENCES, 200);
1983
+ const sourceIds = [];
1984
+ const sources = validateProjectionCollection(fact.sources, `${label} sources`, MAX_DECISION_FACT_CONTRIBUTORS, MAX_DECISION_FACT_PROJECTION_SOURCES, (item, index) => { sourceIds.push(validateDecisionFactSource(item, `${label} source ${index + 1}`)); });
1985
+ validateSortedUnique(sourceIds, `${label} sources`);
1986
+ if (dependencies.total !== sources.total
1987
+ || dependencies.displayed !== sources.displayed
1988
+ || dependencies.omitted !== sources.omitted
1989
+ || !sameValues(dependencies.items, sourceIds))
1990
+ fail(`${label} dependencies`);
1991
+ validateProjectedStringCollection(fact.contradictions, `${label} contradictions`, MAX_DECISION_FACT_CONTRADICTIONS, MAX_DECISION_FACT_PROJECTION_CONTRADICTIONS, 640);
1992
+ validateProjectedStringCollection(fact.limitations, `${label} limitations`, MAX_DECISION_FACT_LIMITATIONS, MAX_DECISION_FACT_PROJECTION_LIMITATIONS, 280);
1993
+ const actionIds = [];
1994
+ validateProjectionCollection(fact.safeNextActions, `${label} safe next actions`, MAX_DECISION_FACT_NEXT_ACTIONS, MAX_DECISION_FACT_PROJECTION_NEXT_ACTIONS, (candidate, index) => {
1995
+ const action = exact(candidate, ['id', 'label', 'reason', 'expectedOutcome', 'href', 'importance'], `${label} safe next action ${index + 1}`);
1996
+ actionIds.push(validateDecisionIdentifier(action.id, `${label} safe next action id`));
1997
+ text(action.label, `${label} safe next action label`, 160);
1998
+ text(action.reason, `${label} safe next action reason`, 320);
1999
+ text(action.expectedOutcome, `${label} safe next action outcome`, 320);
2000
+ if (typeof action.href !== 'string' || !/^#[a-z0-9](?:[a-z0-9._:-]{0,159})$/u.test(action.href))
2001
+ fail(`${label} safe next action href`);
2002
+ enumeration(action.importance, ['high', 'medium', 'low'], `${label} safe next action importance`);
2003
+ });
2004
+ validateSortedUnique(actionIds, `${label} safe next actions`);
2005
+ return { id, consistency };
2006
+ }
2007
+ function validateDecisionFactProjection(value) {
2008
+ const projection = exact(value, [
2009
+ 'version',
2010
+ 'total',
2011
+ 'displayed',
2012
+ 'omitted',
2013
+ 'contradictory',
2014
+ 'unresolved',
2015
+ 'facts',
2016
+ ], 'Investigation capsule Decision Fact projection');
2017
+ if (projection.version !== DECISION_FACT_PROJECTION_VERSION)
2018
+ fail('Investigation capsule Decision Fact projection');
2019
+ const total = integer(projection.total, 'Investigation capsule Decision Fact total', 0, MAX_DECISION_FACTS);
2020
+ const displayed = integer(projection.displayed, 'Investigation capsule Decision Fact displayed', 0, MAX_DECISION_FACT_PROJECTION_FACTS);
2021
+ const omitted = integer(projection.omitted, 'Investigation capsule Decision Fact omitted', 0, MAX_DECISION_FACTS);
2022
+ const contradictory = integer(projection.contradictory, 'Investigation capsule Decision Fact contradictory', 0, total);
2023
+ const unresolved = integer(projection.unresolved, 'Investigation capsule Decision Fact unresolved', 0, total);
2024
+ const facts = array(projection.facts, 'Investigation capsule Decision Facts', MAX_DECISION_FACT_PROJECTION_FACTS);
2025
+ if (displayed > total || omitted !== total - displayed || facts.length !== displayed
2026
+ || contradictory + unresolved > total)
2027
+ fail('Investigation capsule Decision Fact projection');
2028
+ const factIds = [];
2029
+ let displayedContradictory = 0;
2030
+ let displayedUnresolved = 0;
2031
+ for (const [index, candidate] of facts.entries()) {
2032
+ const validated = validateProjectedDecisionFact(candidate, `Investigation capsule Decision Fact ${index + 1}`);
2033
+ factIds.push(validated.id);
2034
+ if (validated.consistency === 'contradictory')
2035
+ displayedContradictory += 1;
2036
+ if (validated.consistency === 'unknown')
2037
+ displayedUnresolved += 1;
2038
+ }
2039
+ validateSortedUnique(factIds, 'Investigation capsule Decision Facts');
2040
+ if (contradictory < displayedContradictory || unresolved < displayedUnresolved
2041
+ || (omitted === 0 && (contradictory !== displayedContradictory || unresolved !== displayedUnresolved))) {
2042
+ fail('Investigation capsule Decision Fact state counts');
2043
+ }
2044
+ if (new TextEncoder().encode(JSON.stringify(projection)).byteLength > MAX_DECISION_FACT_PROJECTION_BYTES) {
2045
+ fail('Investigation capsule Decision Fact projection byte limit');
2046
+ }
2047
+ }
2048
+ function validateCurrentBrief(value) {
2049
+ const brief = exact(value, ['schema', 'schemaVersion', 'generatedAt', 'target', 'targetType', 'task', 'taskLabel', 'question', 'summary', 'observation', 'decisionFacts', 'relationships', 'limitations'], 'Investigation capsule brief');
2050
+ if (brief.schema !== LOOKUP_INVESTIGATION_BRIEF_SCHEMA
2051
+ || brief.schemaVersion !== LOOKUP_INVESTIGATION_BRIEF_VERSION)
2052
+ fail('Investigation capsule brief');
2053
+ iso(brief.generatedAt, 'Investigation capsule brief generatedAt');
2054
+ text(brief.target, 'Investigation capsule brief target', 253);
2055
+ text(brief.targetType, 'Investigation capsule brief target type', 40);
2056
+ enumeration(brief.task, ['general', 'acquisition', 'brand', 'incident', 'owned'], 'Investigation capsule brief task');
2057
+ text(brief.taskLabel, 'Investigation capsule brief task label', 320);
2058
+ text(brief.question, 'Investigation capsule brief question', 320);
2059
+ text(brief.summary, 'Investigation capsule brief summary', 500);
2060
+ const observation = exact(brief.observation, ['observedAt', 'evidenceAgeDays', 'completeSources', 'limitedSources', 'freshnessPolicy'], 'Investigation capsule observation');
2061
+ iso(observation.observedAt, 'Investigation capsule observation time', true);
2062
+ if (observation.evidenceAgeDays !== null)
2063
+ integer(observation.evidenceAgeDays, 'Investigation capsule evidence age', 0, 1_000_000);
2064
+ integer(observation.completeSources, 'Investigation capsule complete sources', 0, 100);
2065
+ integer(observation.limitedSources, 'Investigation capsule limited sources', 0, 100);
2066
+ const policy = exact(observation.freshnessPolicy, ['version', 'id', 'task', 'thresholdsDays'], 'Investigation capsule freshness policy');
2067
+ if (policy.version !== 1)
2068
+ fail('Investigation capsule freshness policy');
2069
+ enumeration(policy.id, ['task-default', 'analyst-custom'], 'Investigation capsule freshness policy');
2070
+ if (policy.task !== brief.task)
2071
+ fail('Investigation capsule freshness policy');
2072
+ const thresholds = exact(policy.thresholdsDays, ['registration', 'network', 'web'], 'Investigation capsule freshness thresholds');
2073
+ for (const key of ['registration', 'network', 'web'])
2074
+ integer(thresholds[key], 'Investigation capsule freshness threshold', 0, 3650);
2075
+ validateDecisionFactProjection(brief.decisionFacts);
2076
+ const relationships = exact(brief.relationships, ['nodes', 'edges', 'truncated', 'kinds'], 'Investigation capsule relationship summary');
2077
+ integer(relationships.nodes, 'Investigation capsule relationship node count', 0, 72);
2078
+ integer(relationships.edges, 'Investigation capsule relationship edge count', 0, 120);
2079
+ boolean(relationships.truncated, 'Investigation capsule relationship truncation');
2080
+ strings(relationships.kinds, 'Investigation capsule relationship kinds', 12, 320);
2081
+ strings(brief.limitations, 'Investigation capsule brief limitations', 20, 320);
2082
+ if (new TextEncoder().encode(JSON.stringify(brief)).byteLength > MAX_LOOKUP_INVESTIGATION_BRIEF_BYTES) {
2083
+ fail('Investigation capsule brief byte limit');
2084
+ }
2085
+ }
2086
+ function validateGraph(value) {
2087
+ const graph = exact(value, ['version', 'targetId', 'nodes', 'edges', 'sources', 'truncated', 'limitations'], 'Investigation capsule graph');
2088
+ if (graph.version !== LOOKUP_ASSET_GRAPH_VERSION)
2089
+ fail('Investigation capsule graph');
2090
+ text(graph.targetId, 'Investigation capsule graph target', 160);
2091
+ const nodes = array(graph.nodes, 'Investigation capsule graph nodes', 72, 1);
2092
+ const nodeIds = new Set();
2093
+ for (const candidate of nodes) {
2094
+ const node = exact(candidate, ['id', 'label', 'kind', 'detail'], 'Investigation capsule graph node');
2095
+ const id = text(node.id, 'Investigation capsule graph node id', 160);
2096
+ if (nodeIds.has(id))
2097
+ fail('Investigation capsule graph node ids');
2098
+ nodeIds.add(id);
2099
+ text(node.label, 'Investigation capsule graph node label', 320);
2100
+ enumeration(node.kind, ['address', 'certificate', 'hostname', 'identity', 'issuer', 'key', 'network', 'observation', 'origin', 'prefix', 'registrar', 'target', 'tracker'], 'Investigation capsule graph node kind');
2101
+ text(node.detail, 'Investigation capsule graph node detail', 500, true);
2102
+ }
2103
+ if (!nodeIds.has(graph.targetId))
2104
+ fail('Investigation capsule graph target');
2105
+ const sourceIds = new Set();
2106
+ for (const candidate of array(graph.sources, 'Investigation capsule graph sources', 32)) {
2107
+ const source = exact(candidate, ['id', 'label', 'href', 'observedAt', 'completeness', 'limitations'], 'Investigation capsule graph source');
2108
+ const id = text(source.id, 'Investigation capsule graph source id', 160);
2109
+ if (sourceIds.has(id))
2110
+ fail('Investigation capsule graph source ids');
2111
+ sourceIds.add(id);
2112
+ text(source.label, 'Investigation capsule graph source label', 320);
2113
+ if (typeof source.href !== 'string' || !/^#[^\u0000-\u001f\u007f]{1,160}$/u.test(source.href))
2114
+ fail('Investigation capsule graph source href');
2115
+ iso(source.observedAt, 'Investigation capsule graph source observedAt', true);
2116
+ enumeration(source.completeness, ['complete', 'partial', 'unknown'], 'Investigation capsule graph source completeness');
2117
+ strings(source.limitations, 'Investigation capsule graph source limitations', 5, 320);
2118
+ }
2119
+ const edges = array(graph.edges, 'Investigation capsule graph edges', 120);
2120
+ const edgeIds = new Set();
2121
+ for (const candidate of edges) {
2122
+ const edge = exactOptional(candidate, ['id', 'sourceId', 'source', 'target', 'kind', 'label', 'sourceLabel', 'observedAt', 'completeness', 'limitations', 'lenses', 'href'], ['boundary'], 'Investigation capsule graph edge');
2123
+ const id = text(edge.id, 'Investigation capsule graph edge id', 160);
2124
+ if (edgeIds.has(id))
2125
+ fail('Investigation capsule graph edge ids');
2126
+ edgeIds.add(id);
2127
+ const sourceId = text(edge.sourceId, 'Investigation capsule graph edge source id', 160);
2128
+ if (!sourceIds.has(sourceId))
2129
+ fail('Investigation capsule graph edge source');
2130
+ if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target))
2131
+ fail('Investigation capsule graph edge endpoints');
2132
+ text(edge.kind, 'Investigation capsule graph edge kind', 160);
2133
+ text(edge.label, 'Investigation capsule graph edge label', 320);
2134
+ text(edge.sourceLabel, 'Investigation capsule graph edge source label', 320);
2135
+ iso(edge.observedAt, 'Investigation capsule graph edge observedAt', true);
2136
+ enumeration(edge.completeness, ['complete', 'partial', 'unknown'], 'Investigation capsule graph edge completeness');
2137
+ strings(edge.limitations, 'Investigation capsule graph edge limitations', 5, 320);
2138
+ strings(edge.lenses, 'Investigation capsule graph edge lenses', 4, 40).forEach((lens) => enumeration(lens, ['all', 'identity', 'delegation', 'certificate'], 'Investigation capsule graph edge lens'));
2139
+ if (typeof edge.href !== 'string' || !/^#[^\u0000-\u001f\u007f]{1,160}$/u.test(edge.href))
2140
+ fail('Investigation capsule graph edge href');
2141
+ if (edge.boundary !== undefined)
2142
+ enumeration(edge.boundary, ['external', 'reviewed_profile', 'same_origin', 'same_registrable_domain', 'unresolved'], 'Investigation capsule graph edge boundary');
2143
+ }
2144
+ boolean(graph.truncated, 'Investigation capsule graph truncation');
2145
+ strings(graph.limitations, 'Investigation capsule graph limitations', 20, 320);
2146
+ }
2147
+ function validateAnalystRecords(value) {
2148
+ if (value === null)
2149
+ return;
2150
+ const records = exact(value, ['caseId', 'status', 'disposition', 'decisions', 'assertions'], 'Investigation capsule analyst records');
2151
+ text(records.caseId, 'Investigation capsule case id', 128);
2152
+ text(records.status, 'Investigation capsule case status', 80);
2153
+ text(records.disposition, 'Investigation capsule case disposition', 80);
2154
+ for (const candidate of array(records.decisions, 'Investigation capsule decisions', MAX_CASE_DECISIONS)) {
2155
+ const decision = exact(candidate, ['id', 'summary', 'rationale', 'evidencePinIds', 'createdAt'], 'Investigation capsule decision');
2156
+ text(decision.id, 'Investigation capsule decision id', 64);
2157
+ text(decision.summary, 'Investigation capsule decision summary', 500);
2158
+ text(decision.rationale, 'Investigation capsule decision rationale', 2_000, true);
2159
+ strings(decision.evidencePinIds, 'Investigation capsule decision evidence pins', MAX_DECISION_PIN_REFERENCES, 64);
2160
+ iso(decision.createdAt, 'Investigation capsule decision createdAt');
2161
+ }
2162
+ for (const candidate of array(records.assertions, 'Investigation capsule assertions', MAX_CASE_ASSERTIONS)) {
2163
+ const assertion = exact(candidate, ['id', 'kind', 'statement', 'rationale', 'evidencePinIds', 'state', 'createdAt', 'updatedAt'], 'Investigation capsule assertion');
2164
+ text(assertion.id, 'Investigation capsule assertion id', 64);
2165
+ text(assertion.kind, 'Investigation capsule assertion kind', 80);
2166
+ text(assertion.statement, 'Investigation capsule assertion statement', 2_000);
2167
+ optionalText(assertion.rationale, 'Investigation capsule assertion rationale', 2_000);
2168
+ strings(assertion.evidencePinIds, 'Investigation capsule assertion evidence pins', MAX_DECISION_PIN_REFERENCES, 64);
2169
+ text(assertion.state, 'Investigation capsule assertion state', 80);
2170
+ iso(assertion.createdAt, 'Investigation capsule assertion createdAt');
2171
+ iso(assertion.updatedAt, 'Investigation capsule assertion updatedAt');
2172
+ }
2173
+ }
2174
+ export function validateInvestigationCapsuleStructure(value) {
2175
+ const root = exact(value, ['schema', 'schemaVersion', 'generatedAt', 'application', 'target', 'sourceContracts', 'investigationBrief', 'graphSnapshot', 'analystRecords', 'integrity', 'limitations'], 'Investigation capsule');
2176
+ if (root.schemaVersion !== PUBLIC_INVESTIGATION_CAPSULE_VERSION
2177
+ && root.schemaVersion !== INVESTIGATION_CAPSULE_VERSION)
2178
+ fail('Investigation capsule');
2179
+ iso(root.generatedAt, 'Investigation capsule generatedAt');
2180
+ const application = exact(root.application, ['name', 'version'], 'Investigation capsule application');
2181
+ if (application.name !== 'WHOISleuth' || typeof application.version !== 'string'
2182
+ || !/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(application.version))
2183
+ fail('Investigation capsule application');
2184
+ const target = exact(root.target, ['value', 'type'], 'Investigation capsule target');
2185
+ text(target.value, 'Investigation capsule target value', 253);
2186
+ text(target.type, 'Investigation capsule target type', 40);
2187
+ const contracts = array(root.sourceContracts, 'Investigation capsule source contracts', 4, 3);
2188
+ const byId = new Map();
2189
+ for (const candidate of contracts) {
2190
+ const contract = exact(candidate, ['id', 'schema', 'version', 'digest', 'embedded'], 'Investigation capsule source contract');
2191
+ const id = enumeration(contract.id, ['lookup-evidence', 'investigation-brief', 'asset-graph', 'analyst-records'], 'Investigation capsule source contract id');
2192
+ if (byId.has(id))
2193
+ fail('Investigation capsule source contracts');
2194
+ byId.set(id, contract);
2195
+ text(contract.schema, 'Investigation capsule source schema', 120);
2196
+ integer(contract.version, 'Investigation capsule source version', 0, 10_000);
2197
+ digest(contract.digest, 'Investigation capsule source digest');
2198
+ boolean(contract.embedded, 'Investigation capsule embedded source');
2199
+ }
2200
+ const expectedContractIds = root.analystRecords === null
2201
+ ? ['lookup-evidence', 'investigation-brief', 'asset-graph']
2202
+ : ['lookup-evidence', 'investigation-brief', 'asset-graph', 'analyst-records'];
2203
+ const expectedBriefVersion = root.schemaVersion === INVESTIGATION_CAPSULE_VERSION
2204
+ ? LOOKUP_INVESTIGATION_BRIEF_VERSION
2205
+ : PUBLIC_LOOKUP_INVESTIGATION_BRIEF_VERSION;
2206
+ if (!sameValues(contracts.map((candidate) => candidate.id), expectedContractIds))
2207
+ fail('Investigation capsule source contracts');
2208
+ if (!byId.has('lookup-evidence') || !byId.has('investigation-brief') || !byId.has('asset-graph')
2209
+ || byId.get('lookup-evidence')?.embedded !== false
2210
+ || byId.get('investigation-brief')?.schema !== LOOKUP_INVESTIGATION_BRIEF_SCHEMA
2211
+ || byId.get('investigation-brief')?.version !== expectedBriefVersion
2212
+ || byId.get('investigation-brief')?.embedded !== true
2213
+ || byId.get('asset-graph')?.schema !== LOOKUP_ASSET_GRAPH_SCHEMA
2214
+ || byId.get('asset-graph')?.version !== LOOKUP_ASSET_GRAPH_VERSION
2215
+ || byId.get('asset-graph')?.embedded !== true
2216
+ || (byId.has('analyst-records') && (byId.get('analyst-records')?.schema !== INVESTIGATION_CAPSULE_ANALYST_RECORDS_SCHEMA
2217
+ || byId.get('analyst-records')?.version !== INVESTIGATION_CAPSULE_ANALYST_RECORDS_VERSION
2218
+ || byId.get('analyst-records')?.embedded !== true)))
2219
+ fail('Investigation capsule source contracts');
2220
+ if (root.schemaVersion === INVESTIGATION_CAPSULE_VERSION)
2221
+ validateCurrentBrief(root.investigationBrief);
2222
+ else
2223
+ validatePublicBrief(root.investigationBrief);
2224
+ validateGraph(root.graphSnapshot);
2225
+ validateAnalystRecords(root.analystRecords);
2226
+ const brief = root.investigationBrief;
2227
+ const graph = root.graphSnapshot;
2228
+ if (target.value !== brief.target || target.type !== brief.targetType
2229
+ || graph.targetId === undefined
2230
+ || brief.relationships.nodes !== graph.nodes.length
2231
+ || brief.relationships.edges !== graph.edges.length)
2232
+ fail('Investigation capsule projection linkage');
2233
+ const integrity = exact(root.integrity, ['algorithm', 'canonicalization', 'scope', 'briefDigest', 'graphDigest', 'analystRecordsDigest', 'digestSha256'], 'Investigation capsule integrity');
2234
+ if (integrity.algorithm !== 'SHA-256')
2235
+ fail('Investigation capsule integrity');
2236
+ if (integrity.canonicalization !== 'sorted-json-v2' || integrity.scope !== 'capsule excluding integrity')
2237
+ fail('Investigation capsule integrity');
2238
+ digest(integrity.digestSha256, 'Investigation capsule digest');
2239
+ digest(integrity.briefDigest, 'Investigation capsule brief digest');
2240
+ digest(integrity.graphDigest, 'Investigation capsule graph digest');
2241
+ if (integrity.analystRecordsDigest !== null)
2242
+ digest(integrity.analystRecordsDigest, 'Investigation capsule analyst digest');
2243
+ if (byId.get('investigation-brief')?.digest !== integrity.briefDigest
2244
+ || byId.get('asset-graph')?.digest !== integrity.graphDigest
2245
+ || (root.analystRecords === null) !== (integrity.analystRecordsDigest === null)
2246
+ || (root.analystRecords === null) !== !byId.has('analyst-records')
2247
+ || (byId.get('analyst-records')?.digest ?? null) !== integrity.analystRecordsDigest)
2248
+ fail('Investigation capsule digest linkage');
2249
+ strings(root.limitations, 'Investigation capsule limitations', 8, 600);
2250
+ }
2251
+ const DNS_TYPES = ['A', 'AAAA', 'CAA', 'CDNSKEY', 'CDS', 'CNAME', 'CSYNC', 'HTTPS', 'MX', 'NS', 'SRV', 'SVCB', 'TLSA', 'TXT'];
2252
+ function validateReviewMatrix(value, label) {
2253
+ for (const [rowIndex, candidate] of array(value, label, 500).entries()) {
2254
+ const row = exact(candidate, ['owner', 'type', 'state', 'observations'], `${label} row ${rowIndex + 1}`);
2255
+ text(row.owner, `${label} owner`, 253);
2256
+ enumeration(row.type, DNS_TYPES, `${label} type`);
2257
+ const observations = array(row.observations, `${label} observations`, 16);
2258
+ let complete = 0;
2259
+ const signatures = new Set();
2260
+ for (const observationCandidate of observations) {
2261
+ const observation = exact(observationCandidate, ['label', 'source', 'state', 'values', 'ttlRange'], `${label} observation`);
2262
+ text(observation.label, `${label} observation label`, 120);
2263
+ text(observation.source, `${label} observation source`, 240);
2264
+ const state = enumeration(observation.state, ['observed', 'partial', 'unavailable'], `${label} observation state`);
2265
+ const values = strings(observation.values, `${label} observation values`, 500, 16_384);
2266
+ if (state === 'observed') {
2267
+ complete += 1;
2268
+ signatures.add(JSON.stringify(values));
2269
+ }
2270
+ if (observation.ttlRange !== null) {
2271
+ const range = exact(observation.ttlRange, ['minimum', 'maximum'], `${label} TTL range`);
2272
+ const minimum = integer(range.minimum, `${label} minimum TTL`, 0, 0x7fff_ffff);
2273
+ const maximum = integer(range.maximum, `${label} maximum TTL`, minimum, 0x7fff_ffff);
2274
+ if (maximum < minimum)
2275
+ fail(`${label} TTL range`);
2276
+ }
2277
+ }
2278
+ const expected = complete < 2 ? 'insufficient' : signatures.size === 1 ? 'aligned' : 'different';
2279
+ if (row.state !== expected)
2280
+ fail(`${label} state`);
2281
+ }
2282
+ }
2283
+ function validateDomainChangeReview(value, label) {
2284
+ const review = exact(value, ['schema', 'version', 'generatedAt', 'domain', 'state', 'authoritativeRecordMatrix', 'resolverDivergenceMatrix', 'dnssecAutomation', 'acmeDependencies', 'certificate', 'services', 'hsts', 'gate', 'limitations'], label);
2285
+ if (review.schema !== 'whoisleuth.domain-change.review' || review.version !== 1)
2286
+ fail(label);
2287
+ iso(review.generatedAt, `${label} generatedAt`);
2288
+ domain(review.domain, `${label} domain`);
2289
+ enumeration(review.state, ['ready', 'review'], `${label} state`);
2290
+ validateReviewMatrix(review.authoritativeRecordMatrix, `${label} authority matrix`);
2291
+ validateReviewMatrix(review.resolverDivergenceMatrix, `${label} resolver matrix`);
2292
+ const automation = exact(review.dnssecAutomation, ['state', 'cdsObserved', 'cdnskeyObserved', 'csyncObserved', 'conflictingTypes', 'detail'], `${label} DNSSEC automation`);
2293
+ enumeration(automation.state, ['not_observed', 'conflict', 'partial', 'review_ready'], `${label} DNSSEC automation`);
2294
+ boolean(automation.cdsObserved, `${label} CDS observed`);
2295
+ boolean(automation.cdnskeyObserved, `${label} CDNSKEY observed`);
2296
+ boolean(automation.csyncObserved, `${label} CSYNC observed`);
2297
+ strings(automation.conflictingTypes, `${label} conflicting types`, 3, 16).forEach((item) => enumeration(item, ['CDS', 'CDNSKEY', 'CSYNC'], `${label} conflicting type`));
2298
+ text(automation.detail, `${label} DNSSEC detail`, 600);
2299
+ for (const candidate of array(review.acmeDependencies, `${label} ACME dependencies`, 64)) {
2300
+ const dependency = exact(candidate, ['method', 'owner', 'target', 'provider', 'state'], `${label} ACME dependency`);
2301
+ enumeration(dependency.method, ['dns-01', 'http-01', 'tls-alpn-01'], `${label} ACME method`);
2302
+ text(dependency.owner, `${label} ACME owner`, 253);
2303
+ optionalText(dependency.target, `${label} ACME target`, 253);
2304
+ optionalText(dependency.provider, `${label} ACME provider`, 120);
2305
+ enumeration(dependency.state, ['confirmed', 'partial', 'unknown'], `${label} ACME state`);
2306
+ }
2307
+ const certificate = exact(review.certificate, ['state', 'continuity', 'findings'], `${label} certificate`);
2308
+ enumeration(certificate.state, ['not_supplied', 'observed', 'partial', 'unavailable'], `${label} certificate state`);
2309
+ enumeration(certificate.continuity, ['unknown', 'retained', 'changes'], `${label} certificate continuity`);
2310
+ strings(certificate.findings, `${label} certificate findings`, 8, 600);
2311
+ for (const candidate of array(review.services, `${label} services`, 1_000)) {
2312
+ const service = exact(candidate, ['owner', 'value', 'source', 'state', 'observedAt'], `${label} service`);
2313
+ text(service.owner, `${label} service owner`, 253);
2314
+ text(service.value, `${label} service value`, 16_384);
2315
+ text(service.source, `${label} service source`, 240);
2316
+ enumeration(service.state, ['observed', 'partial', 'unavailable'], `${label} service state`);
2317
+ iso(service.observedAt, `${label} service observedAt`);
2318
+ }
2319
+ if (review.hsts !== null) {
2320
+ const hsts = exact(review.hsts, ['state', 'observedAt', 'header', 'preloadState', 'source'], `${label} HSTS`);
2321
+ enumeration(hsts.state, ['observed', 'partial', 'unavailable'], `${label} HSTS state`);
2322
+ iso(hsts.observedAt, `${label} HSTS observedAt`);
2323
+ optionalText(hsts.header, `${label} HSTS header`, 1_024);
2324
+ enumeration(hsts.preloadState, ['listed', 'not_listed', 'unavailable'], `${label} HSTS preload`);
2325
+ text(hsts.source, `${label} HSTS source`, 240);
2326
+ }
2327
+ const gate = exact(review.gate, ['pass', 'reasons'], `${label} gate`);
2328
+ boolean(gate.pass, `${label} gate`);
2329
+ const reasons = strings(gate.reasons, `${label} gate reasons`, 2_000, 600);
2330
+ if (gate.pass !== (reasons.length === 0) || review.state !== (gate.pass ? 'ready' : 'review'))
2331
+ fail(`${label} gate`);
2332
+ strings(review.limitations, `${label} limitations`, 8, 600);
2333
+ return review;
2334
+ }
2335
+ function validatePlannedAssurance(value, label) {
2336
+ const assurance = exact(value, ['schema', 'version', 'generatedAt', 'result', 'limitations'], label);
2337
+ if (assurance.schema !== 'whoisleuth.domain-assurance' || assurance.version !== 2)
2338
+ fail(label);
2339
+ iso(assurance.generatedAt, `${label} generatedAt`);
2340
+ const result = exact(assurance.result, ['kind', 'domain', 'reference', 'window', 'milestones', 'rollbackCriteria', 'postChangeChecks', 'review'], `${label} result`);
2341
+ if (result.kind !== 'planned-change')
2342
+ fail(`${label} result`);
2343
+ domain(result.domain, `${label} domain`);
2344
+ text(result.reference, `${label} reference`, 120);
2345
+ const window = exact(result.window, ['startsAt', 'endsAt'], `${label} window`);
2346
+ iso(window.startsAt, `${label} startsAt`);
2347
+ iso(window.endsAt, `${label} endsAt`);
2348
+ if (Date.parse(window.endsAt) <= Date.parse(window.startsAt))
2349
+ fail(`${label} window`);
2350
+ const ids = new Set();
2351
+ for (const candidate of array(result.milestones, `${label} milestones`, 24, 1)) {
2352
+ const item = exact(candidate, ['id', 'label', 'expectedBy', 'evidenceSource', 'state', 'observedAt', 'evidenceReference'], `${label} milestone`);
2353
+ const id = text(item.id, `${label} milestone id`, 64);
2354
+ if (ids.has(id))
2355
+ fail(`${label} item ids`);
2356
+ ids.add(id);
2357
+ text(item.label, `${label} milestone label`, 180);
2358
+ iso(item.expectedBy, `${label} milestone expectedBy`);
2359
+ text(item.evidenceSource, `${label} milestone source`, 120);
2360
+ const state = enumeration(item.state, ['missed', 'not_checked', 'observed', 'planned'], `${label} milestone state`);
2361
+ iso(item.observedAt, `${label} milestone observedAt`, true);
2362
+ optionalText(item.evidenceReference, `${label} milestone reference`, 300);
2363
+ if (['observed', 'missed'].includes(state) !== (item.observedAt !== null && item.evidenceReference !== null))
2364
+ fail(`${label} milestone evidence`);
2365
+ }
2366
+ for (const candidate of array(result.rollbackCriteria, `${label} rollback criteria`, 16, 1)) {
2367
+ const item = exact(candidate, ['id', 'condition', 'owner', 'state'], `${label} rollback criterion`);
2368
+ const id = text(item.id, `${label} rollback id`, 64);
2369
+ if (ids.has(id))
2370
+ fail(`${label} item ids`);
2371
+ ids.add(id);
2372
+ text(item.condition, `${label} rollback condition`, 240);
2373
+ text(item.owner, `${label} rollback owner`, 120);
2374
+ enumeration(item.state, ['met', 'not_checked', 'not_met'], `${label} rollback state`);
2375
+ }
2376
+ for (const candidate of array(result.postChangeChecks, `${label} post-change checks`, 24, 1)) {
2377
+ const item = exact(candidate, ['id', 'label', 'expectedState', 'evidenceSource', 'state', 'evidenceReference'], `${label} post-change check`);
2378
+ const id = text(item.id, `${label} post-change id`, 64);
2379
+ if (ids.has(id))
2380
+ fail(`${label} item ids`);
2381
+ ids.add(id);
2382
+ text(item.label, `${label} post-change label`, 180);
2383
+ text(item.expectedState, `${label} expected state`, 240);
2384
+ text(item.evidenceSource, `${label} post-change source`, 120);
2385
+ const state = enumeration(item.state, ['matched', 'not_checked', 'unexpected'], `${label} post-change state`);
2386
+ optionalText(item.evidenceReference, `${label} post-change reference`, 300);
2387
+ if ((state === 'not_checked') !== (item.evidenceReference === null))
2388
+ fail(`${label} post-change evidence`);
2389
+ }
2390
+ const review = exact(result.review, ['state', 'reasons'], `${label} review`);
2391
+ enumeration(review.state, ['incomplete', 'needs_review', 'ready'], `${label} review state`);
2392
+ const reasons = strings(review.reasons, `${label} review reasons`, 20, 600);
2393
+ if ((review.state === 'ready') !== (reasons.length === 0))
2394
+ fail(`${label} review`);
2395
+ strings(assurance.limitations, `${label} limitations`, 8, 600);
2396
+ return assurance;
2397
+ }
2398
+ function expectedDomainChangeSummary(before, after) {
2399
+ const rows = (review) => new Map(review.authoritativeRecordMatrix.map((row) => [`${row.owner}\u0000${row.type}`, row]));
2400
+ const beforeRows = rows(before);
2401
+ const afterRows = rows(after);
2402
+ const keys = [...new Set([...beforeRows.keys(), ...afterRows.keys()])].sort();
2403
+ return keys.slice(0, 500).flatMap((key) => {
2404
+ const left = beforeRows.get(key);
2405
+ const right = afterRows.get(key);
2406
+ const values = (row) => [...new Set((row?.observations ?? [])
2407
+ .flatMap((observation) => observation.values))].sort();
2408
+ const beforeValues = values(left);
2409
+ const afterValues = values(right);
2410
+ if (sameValues(beforeValues, afterValues))
2411
+ return [];
2412
+ const [owner, type] = key.split('\u0000');
2413
+ return [{ owner: owner ?? '', type: type ?? '', beforeValues, afterValues }];
2414
+ });
2415
+ }
2416
+ function validateDomainChangePacket(value) {
2417
+ const root = exact(value, ['schema', 'version', 'generatedAt', 'domain', 'reference', 'state', 'gate', 'summary', 'evidence', 'limitations', 'integrity'], 'Domain change packet');
2418
+ iso(root.generatedAt, 'Domain change packet generatedAt');
2419
+ domain(root.domain, 'Domain change packet domain');
2420
+ text(root.reference, 'Domain change packet reference', 200);
2421
+ enumeration(root.state, ['ready', 'review'], 'Domain change packet state');
2422
+ const gate = exact(root.gate, ['pass', 'reasons'], 'Domain change packet gate');
2423
+ boolean(gate.pass, 'Domain change packet gate');
2424
+ const gateReasons = strings(gate.reasons, 'Domain change packet gate reasons', 100, 700);
2425
+ if (gate.pass !== (gateReasons.length === 0) || root.state !== (gate.pass ? 'ready' : 'review'))
2426
+ fail('Domain change packet gate');
2427
+ const summary = exact(root.summary, ['changedAuthoritativeRecordSets', 'preChangeState', 'postChangeState', 'assuranceState'], 'Domain change packet summary');
2428
+ const changed = array(summary.changedAuthoritativeRecordSets, 'Domain change packet changes', 500);
2429
+ for (const candidate of changed) {
2430
+ const item = exact(candidate, ['owner', 'type', 'beforeValues', 'afterValues'], 'Domain change packet changed record set');
2431
+ text(item.owner, 'Domain change packet changed owner', 253);
2432
+ enumeration(item.type, DNS_TYPES, 'Domain change packet changed type');
2433
+ strings(item.beforeValues, 'Domain change packet before values', 500, 16_384);
2434
+ strings(item.afterValues, 'Domain change packet after values', 500, 16_384);
2435
+ }
2436
+ enumeration(summary.preChangeState, ['ready', 'review'], 'Domain change packet pre-change state');
2437
+ enumeration(summary.postChangeState, ['ready', 'review'], 'Domain change packet post-change state');
2438
+ enumeration(summary.assuranceState, ['incomplete', 'needs_review', 'ready'], 'Domain change packet assurance state');
2439
+ const evidence = exact(root.evidence, ['preChange', 'postChange', 'assurance'], 'Domain change packet evidence');
2440
+ const pre = validateDomainChangeReview(evidence.preChange, 'Domain change packet pre-change review');
2441
+ const post = validateDomainChangeReview(evidence.postChange, 'Domain change packet post-change review');
2442
+ const assurance = validatePlannedAssurance(evidence.assurance, 'Domain change packet assurance');
2443
+ const assuranceResult = assurance.result;
2444
+ if (pre.generatedAt !== root.generatedAt || post.generatedAt !== root.generatedAt || assurance.generatedAt !== root.generatedAt
2445
+ || pre.domain !== root.domain || post.domain !== root.domain || assuranceResult.domain !== root.domain
2446
+ || pre.state !== summary.preChangeState || post.state !== summary.postChangeState
2447
+ || assuranceResult.review.state !== summary.assuranceState)
2448
+ fail('Domain change packet evidence linkage');
2449
+ const expectedReasons = [
2450
+ ...pre.gate.reasons,
2451
+ ].map((reason) => `Pre-change evidence: ${reason}`);
2452
+ expectedReasons.push(...post.gate.reasons.map((reason) => `Post-change evidence: ${reason}`), ...(assuranceResult.review.reasons.map((reason) => `Change plan: ${reason}`)));
2453
+ const boundedReasons = expectedReasons.slice(0, 100);
2454
+ if (!sameValues(gateReasons, boundedReasons)
2455
+ || gate.pass !== (boundedReasons.length === 0)
2456
+ || root.state !== (boundedReasons.length === 0 ? 'ready' : 'review'))
2457
+ fail('Domain change packet gate');
2458
+ const expectedChanges = expectedDomainChangeSummary(pre, post);
2459
+ if (changed.length !== expectedChanges.length || changed.some((candidate, index) => {
2460
+ const item = candidate;
2461
+ const expected = expectedChanges[index];
2462
+ return item.owner !== expected.owner || item.type !== expected.type
2463
+ || !sameValues(item.beforeValues, expected.beforeValues)
2464
+ || !sameValues(item.afterValues, expected.afterValues);
2465
+ }))
2466
+ fail('Domain change packet summary');
2467
+ strings(root.limitations, 'Domain change packet limitations', 8, 600);
2468
+ validateIntegrity(root.integrity, 'Domain change packet integrity', root.version, DOMAIN_CHANGE_PACKET_VERSION);
2469
+ }
2470
+ export function validateSignedDigestArtifactStructure(schema, value) {
2471
+ if (value.schema !== schema)
2472
+ fail('Signed review artefact');
2473
+ if (schema === ACQUISITION_DECISION_PACKET_SCHEMA)
2474
+ validateAcquisition(value);
2475
+ else if (schema === LOOKUP_CLAIM_PASSPORT_SCHEMA)
2476
+ validateLookupClaimPassport(value);
2477
+ else if (schema === BULK_DOMAIN_COMPARISON_SCHEMA)
2478
+ validateDomainComparison(value);
2479
+ else if (schema === BULK_MAIL_EXPOSURE_SCHEMA)
2480
+ validateMailExposure(value);
2481
+ else if (schema === BULK_REVIEW_MANIFEST_SCHEMA)
2482
+ validateBulkReviewManifest(value);
2483
+ else if (schema === DOMAIN_CONTROL_MANIFEST_SCHEMA) {
2484
+ try {
2485
+ normalizeDomainControlManifestDocument(value);
2486
+ }
2487
+ catch {
2488
+ fail('Domain control manifest');
2489
+ }
2490
+ }
2491
+ else if (schema === DOMAIN_CHANGE_PACKET_SCHEMA)
2492
+ validateDomainChangePacket(value);
2493
+ else if (schema === INVESTIGATION_MANIFEST_SCHEMA)
2494
+ validateInvestigationManifest(value);
2495
+ else
2496
+ fail('Signed review artefact');
2497
+ }
2498
+ export function validateOfflineArtifactStructure(schema, value) {
2499
+ if (schema === CASE_RESPONSE_PACKET_SCHEMA)
2500
+ validateCaseResponsePacket(value);
2501
+ else if (schema === INVESTIGATION_CAPSULE_SCHEMA)
2502
+ validateInvestigationCapsuleStructure(value);
2503
+ else if (schema === LOOKUP_EVIDENCE_SCHEMA)
2504
+ validateLookupEvidenceArtifactStructure(value);
2505
+ else
2506
+ validateSignedDigestArtifactStructure(schema, value);
2507
+ }