@slicedearth/whoisleuth-cli 1.43.0 → 1.47.4

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 (153) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +67 -10
  4. package/bin/whoisleuth.mjs +22 -9
  5. package/cli/arguments.mjs +256 -9
  6. package/cli/artifact-structure.mjs +1833 -0
  7. package/cli/artifact-verify.mjs +242 -49
  8. package/cli/bounded-json.mjs +1 -0
  9. package/cli/bulk-checkpoint.mjs +67 -31
  10. package/cli/bulk-command-runner.mjs +16 -6
  11. package/cli/bulk.mjs +18 -7
  12. package/cli/case-pack.mjs +819 -34
  13. package/cli/command-reference.mjs +48 -24
  14. package/cli/completion.mjs +50 -11
  15. package/cli/config-profile.mjs +15 -12
  16. package/cli/ct-event-intake.mjs +32 -2
  17. package/cli/discovery-observation-snapshot.mjs +8 -14
  18. package/cli/discovery-scan-command-runner.mjs +18 -6
  19. package/cli/discovery-snapshot.mjs +8 -15
  20. package/cli/discovery-workflow.mjs +2 -3
  21. package/cli/doctor.mjs +2 -2
  22. package/cli/domain-control-monitor.mjs +21 -15
  23. package/cli/domain-control-observations.mjs +25 -2
  24. package/cli/errors.mjs +7 -1
  25. package/cli/evidence-command-runner.mjs +17 -11
  26. package/cli/evidence-signing.mjs +73 -45
  27. package/cli/export-evidence.mjs +11 -4
  28. package/cli/formatters/html.mjs +3 -3
  29. package/cli/formatters/json.mjs +12 -4
  30. package/cli/formatters/terminal.mjs +4 -1
  31. package/cli/http.mjs +2 -1
  32. package/cli/input.mjs +57 -0
  33. package/cli/interchange-report.mjs +226 -0
  34. package/cli/investigation-manifest.mjs +16 -17
  35. package/cli/investigation-run.mjs +71 -23
  36. package/cli/local-mmdb-review.mjs +45 -14
  37. package/cli/lookup-command-runner.mjs +2 -2
  38. package/cli/lookup-diff.mjs +9 -0
  39. package/cli/lookup-reconcile.mjs +1 -1
  40. package/cli/mail-review.mjs +5 -5
  41. package/cli/manual.mjs +1 -1
  42. package/cli/network-command-runner.mjs +78 -0
  43. package/cli/offline-evidence-review.mjs +28 -8
  44. package/cli/page-compare.mjs +8 -5
  45. package/cli/registry-cohort.mjs +385 -61
  46. package/cli/registry-fixture-scaffold.mjs +4 -3
  47. package/cli/registry-support.mjs +2 -1
  48. package/cli/retained-artifact-diff.mjs +376 -0
  49. package/cli/risk-calibration.mjs +7 -5
  50. package/cli/runner.mjs +150 -177
  51. package/cli/saved-lookup.mjs +26 -3
  52. package/cli/sharing-review.mjs +12 -6
  53. package/docs/cli-reference.md +467 -74
  54. package/docs/cli.md +25 -5
  55. package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
  56. package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
  57. package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
  58. package/frontend/src/lib/analysis/brand-profile-model.js +33 -7
  59. package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
  60. package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
  61. package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
  62. package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
  63. package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
  64. package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
  65. package/frontend/src/lib/analysis/bulk-sort.js +4 -0
  66. package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
  67. package/frontend/src/lib/analysis/case-evidence-model.js +9 -1
  68. package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
  69. package/frontend/src/lib/analysis/case-migration-model.js +83 -31
  70. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  71. package/frontend/src/lib/analysis/case-record-core.js +1 -1
  72. package/frontend/src/lib/analysis/case-record-model.js +2 -0
  73. package/frontend/src/lib/analysis/case-record-operations.js +43 -13
  74. package/frontend/src/lib/analysis/case-report.js +39 -6
  75. package/frontend/src/lib/analysis/case-response-model.js +66 -7
  76. package/frontend/src/lib/analysis/case-response-packet.js +15 -5
  77. package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
  78. package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
  79. package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
  80. package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
  81. package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
  82. package/frontend/src/lib/analysis/domain-control-records.js +88 -0
  83. package/frontend/src/lib/analysis/external-findings-import.js +70 -6
  84. package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
  85. package/frontend/src/lib/analysis/investigation-guide.js +52 -10
  86. package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
  87. package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
  88. package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
  89. package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
  90. package/frontend/src/lib/analysis/lookup-decision-support.js +63 -8
  91. package/frontend/src/lib/analysis/lookup-presentation.js +70 -14
  92. package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
  93. package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
  94. package/frontend/src/lib/analysis/utils.js +27 -4
  95. package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
  96. package/frontend/src/lib/analysis/visualization-models.js +63 -94
  97. package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
  98. package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
  99. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
  100. package/frontend/src/lib/analysis/workspace-archive.js +105 -19
  101. package/lib/application-version.mjs +3 -0
  102. package/lib/availability.mjs +14 -12
  103. package/lib/bounded-file.mjs +82 -0
  104. package/lib/bounded-json-response.mjs +170 -0
  105. package/lib/bounded-json.mjs +249 -0
  106. package/lib/browser-library-profile.mjs +167 -38
  107. package/lib/certificate-extension-profile.mjs +2 -1
  108. package/lib/cryptographic-assurance.mjs +155 -0
  109. package/lib/ct-search.mjs +87 -47
  110. package/lib/dns-intelligence.mjs +3 -2
  111. package/lib/dns-resolver-selection.mjs +6 -4
  112. package/lib/dnssec-chain-validation.mjs +1053 -0
  113. package/lib/dnssec-evidence-validation.mjs +10 -3
  114. package/lib/domain-change-packet.mjs +7 -5
  115. package/lib/domain-control-manifest.mjs +17 -129
  116. package/lib/domain-posture.mjs +7 -4
  117. package/lib/evidence-export.mjs +751 -78
  118. package/lib/evidence-report-markdown.mjs +3 -0
  119. package/lib/evidence-report.mjs +5 -0
  120. package/lib/favicon.mjs +8 -5
  121. package/lib/http-evidence-bounds.mjs +3 -0
  122. package/lib/http-intelligence.mjs +2 -5
  123. package/lib/interchange-fidelity-registry.mjs +122 -0
  124. package/lib/local-geoip-evidence.mjs +2 -1
  125. package/lib/lookup-cache.mjs +5 -1
  126. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  127. package/lib/lookup-response-contract.mjs +691 -3
  128. package/lib/lookup.mjs +2 -2
  129. package/lib/observation.mjs +7 -6
  130. package/lib/outbound-identity.mjs +2 -3
  131. package/lib/perceptual-hash.mjs +12 -4
  132. package/lib/portable-generator.mjs +21 -0
  133. package/lib/provider-policy-admission.mjs +9 -4
  134. package/lib/rdap-normalization.mjs +18 -5
  135. package/lib/rdap-registrar.mjs +5 -1
  136. package/lib/rdap-transport.mjs +6 -6
  137. package/lib/registry-capabilities.mjs +3 -0
  138. package/lib/registry-capability-catalogue.mjs +12 -2
  139. package/lib/registry-comparison.mjs +28 -11
  140. package/lib/risk-calibration-summary.mjs +338 -0
  141. package/lib/rpki-evidence.mjs +9 -8
  142. package/lib/safe-fetch.mjs +96 -42
  143. package/lib/security-txt.mjs +26 -3
  144. package/lib/service-binding-dns.mjs +6 -1
  145. package/lib/smtp-transport-review.mjs +784 -0
  146. package/lib/sslbl-certificates.generated.mjs +136 -134
  147. package/lib/threat-intelligence-runtime.mjs +4 -13
  148. package/lib/threat-intelligence-types.mjs +18 -1
  149. package/lib/tls-intelligence.mjs +11 -5
  150. package/lib/tlsa-evidence.mjs +37 -7
  151. package/lib/website-technology.mjs +2 -6
  152. package/package.json +1 -1
  153. package/third-party-notices.txt +95 -5817
@@ -0,0 +1,1833 @@
1
+ import { ACQUISITION_DECISIONS, ACQUISITION_MANUAL_CHECKS, ACQUISITION_DECISION_PACKET_SCHEMA, ACQUISITION_DECISION_PACKET_VERSION, } from "../frontend/src/lib/analysis/acquisition-decision-packet.js";
2
+ import { BULK_DOMAIN_COMPARISON_EXPORT_VERSION, BULK_DOMAIN_COMPARISON_SCHEMA, } from "../frontend/src/lib/analysis/bulk-domain-comparison.js";
3
+ import { BULK_MAIL_EXPOSURE_EXPORT_VERSION, BULK_MAIL_EXPOSURE_SCHEMA, MAX_BULK_MAIL_EXPOSURE_ROWS, } from "../frontend/src/lib/analysis/bulk-mail-exposure.js";
4
+ import { BULK_REVIEW_MANIFEST_SCHEMA, BULK_REVIEW_MANIFEST_VERSION, } from "../frontend/src/lib/analysis/bulk-review-export.js";
5
+ import { CASE_RESPONSE_PACKET_SCHEMA, CASE_RESPONSE_PACKET_VERSION, LEGACY_CASE_RESPONSE_PACKET_VERSION, MAX_ABUSIVE_URLS, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_CONTACTS, RESPONSE_PACKET_PROFILES, RESPONSE_CONTACT_KINDS, RESPONSE_PACKET_PROFILE_IDS, } from "../frontend/src/lib/analysis/case-response-packet.js";
6
+ import { CASE_ACTION_STATES, CASE_ACTION_TYPES, 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 "../frontend/src/lib/analysis/case-response-model.js";
7
+ import { INVESTIGATION_CAPSULE_SCHEMA, INVESTIGATION_CAPSULE_VERSION, LEGACY_INVESTIGATION_CAPSULE_VERSION, } from "../frontend/src/lib/analysis/investigation-capsule.js";
8
+ import { LOOKUP_INVESTIGATION_BRIEF_SCHEMA } from "../frontend/src/lib/analysis/lookup-investigation-brief.js";
9
+ 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 "../frontend/src/lib/analysis/lookup-claim-passport.js";
10
+ import { LOOKUP_CLAIM_IDS, LOOKUP_CLAIM_READINESS_VERSION, LOOKUP_CLAIM_REQUIREMENT_IDS, } from "../frontend/src/lib/analysis/lookup-claim-readiness.js";
11
+ import { normalizeDomainControlPassportDocument } from "../frontend/src/lib/analysis/domain-control-manifest-core.js";
12
+ import { DOMAIN_CHANGE_PACKET_SCHEMA, DOMAIN_CHANGE_PACKET_VERSION } from "../lib/domain-change-packet.mjs";
13
+ import { DOMAIN_CONTROL_MANIFEST_SCHEMA } from "../lib/domain-control-manifest.mjs";
14
+ import { INVESTIGATION_MANIFEST_SCHEMA, INVESTIGATION_MANIFEST_VERSION, MAX_INVESTIGATION_MANIFEST_ARTIFACTS, MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES, } from "./investigation-manifest.mjs";
15
+ import { assertLookupEvidencePrivacySafeTree, assertLookupEvidencePortableTree, LEGACY_LOOKUP_EVIDENCE_SCHEMA_VERSION, LOOKUP_EVIDENCE_SCHEMA, LOOKUP_EVIDENCE_SCHEMA_VERSION, projectLookupEvidenceAvailability, projectLookupEvidenceQuery, SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS, } from "../lib/evidence-export.mjs";
16
+ import { compareRegistrySources } from "../lib/registry-comparison.mjs";
17
+ import { buildRegistryInsights } from "../lib/registry-insights.mjs";
18
+ import { WHOISLEUTH_SOURCE_REPOSITORY_URL } from "../lib/project-metadata.mjs";
19
+ import { isDeepStrictEqual } from 'node:util';
20
+ const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
21
+ const DIGEST_RE = /^sha256:[a-f0-9]{64}$/u;
22
+ const HEX_DIGEST_RE = /^[a-f0-9]{64}$/u;
23
+ 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;
24
+ 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;
25
+ function fail(label) {
26
+ throw new TypeError(`${label} has an unsupported or malformed structure.`);
27
+ }
28
+ function record(value, label) {
29
+ if (!value || typeof value !== 'object' || Array.isArray(value))
30
+ fail(label);
31
+ return value;
32
+ }
33
+ function exact(value, keys, label) {
34
+ const source = record(value, label);
35
+ const actual = Object.keys(source);
36
+ if (actual.length !== keys.length || actual.some((key) => !keys.includes(key)))
37
+ fail(label);
38
+ return source;
39
+ }
40
+ function exactOptional(value, required, optional, label) {
41
+ const source = record(value, label);
42
+ const actual = Object.keys(source);
43
+ if (required.some((key) => !actual.includes(key))
44
+ || actual.some((key) => !required.includes(key) && !optional.includes(key)))
45
+ fail(label);
46
+ return source;
47
+ }
48
+ function text(value, label, maximum = 2_000, allowEmpty = false) {
49
+ if (typeof value !== 'string' || value.length > maximum || CONTROL_RE.test(value) || (!allowEmpty && !value))
50
+ fail(label);
51
+ return value;
52
+ }
53
+ function optionalText(value, label, maximum = 2_000) {
54
+ if (value !== null)
55
+ text(value, label, maximum);
56
+ }
57
+ function iso(value, label, nullable = false) {
58
+ if (nullable && value === null)
59
+ return;
60
+ const candidate = text(value, label, 64);
61
+ const parsed = Date.parse(candidate);
62
+ if (!Number.isFinite(parsed) || new Date(parsed).toISOString() !== candidate)
63
+ fail(label);
64
+ }
65
+ function integer(value, label, minimum = 0, maximum = Number.MAX_SAFE_INTEGER) {
66
+ if (!Number.isSafeInteger(value) || Number(value) < minimum || Number(value) > maximum)
67
+ fail(label);
68
+ return Number(value);
69
+ }
70
+ function boolean(value, label) {
71
+ if (typeof value !== 'boolean')
72
+ fail(label);
73
+ }
74
+ function enumeration(value, allowed, label) {
75
+ if (typeof value !== 'string' || !allowed.includes(value))
76
+ fail(label);
77
+ return value;
78
+ }
79
+ function array(value, label, maximum, minimum = 0) {
80
+ if (!Array.isArray(value) || value.length < minimum || value.length > maximum)
81
+ fail(label);
82
+ return value;
83
+ }
84
+ function strings(value, label, maximum, textMaximum = 2_000) {
85
+ const values = array(value, label, maximum);
86
+ for (const item of values)
87
+ text(item, label, textMaximum);
88
+ return values;
89
+ }
90
+ function digest(value, label) {
91
+ if (typeof value !== 'string' || !DIGEST_RE.test(value))
92
+ fail(label);
93
+ }
94
+ function sameValues(left, right) {
95
+ return left.length === right.length && left.every((value, index) => value === right[index]);
96
+ }
97
+ function domain(value, label) {
98
+ if (typeof value !== 'string' || !DOMAIN_RE.test(value))
99
+ fail(label);
100
+ }
101
+ function absoluteUrl(value, label, protocols) {
102
+ const candidate = text(value, label, 2_048);
103
+ try {
104
+ const parsed = new URL(candidate);
105
+ if (!protocols.includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash)
106
+ fail(label);
107
+ return parsed;
108
+ }
109
+ catch {
110
+ return fail(label);
111
+ }
112
+ }
113
+ function nullableRecord(value, label) {
114
+ if (value !== null)
115
+ record(value, label);
116
+ }
117
+ const LOOKUP_RDAP_ATTEMPT_OUTCOMES = [
118
+ 'success', 'not_found', 'no_results', 'unsupported', 'rate_limited',
119
+ 'server_error', 'client_error', 'invalid_json', 'invalid_response',
120
+ 'timeout', 'network_error', 'unknown',
121
+ ];
122
+ const LOOKUP_TIMING_SOURCES = [
123
+ 'rdap', 'whois', 'domain_evidence', 'reverse_dns', 'registrar_rdap',
124
+ 'network_context', 'security_txt', 'external_intelligence',
125
+ 'malware_host_intelligence', 'malware_ioc_intelligence',
126
+ ];
127
+ const LOOKUP_AVAILABILITY_ANALYSIS_KEYS = [
128
+ 'applicable', 'type', 'domain', 'state', 'confidence', 'detail', 'source',
129
+ 'rdapServer', 'nameservers', 'statuses', 'registrar', 'registrant', 'abuse',
130
+ 'createdDate', 'expiryDate', 'createdDateIso', 'expiryDateIso', 'domainAgeDays',
131
+ 'expiresInDays', 'privacyProtected', 'dnssec', 'activityStatus',
132
+ 'websiteProbeStatus', 'websiteProbeDetail', 'http', 'deepScanComplete',
133
+ 'faviconHash', 'faviconPHash', 'pageTitle', 'hasPasswordField',
134
+ 'phishingLanguageMatch', 'hasExternalFormAction', 'externalAssetHosts',
135
+ 'pageIdentity', 'credentialSurfaceProfile', 'structuredDataIdentity',
136
+ 'technologyProfile', 'pageRoleProfile', 'clientBehaviorProfile',
137
+ 'securityPosture', 'dns', 'tls', 'hasMx', 'hasNullMx', 'mxHosts', 'hasSpf',
138
+ 'hasDmarc', 'bulkComparison', 'limitations',
139
+ ];
140
+ const LOOKUP_IDN_ANALYSIS_KEYS = [
141
+ 'version', 'mappingVersion', 'asciiDomain', 'unicodeDomain', 'hasIdn',
142
+ 'scripts', 'labels', 'mixedScript', 'skeleton', 'referenceMatches', 'findings',
143
+ 'truncated', 'limitations',
144
+ ];
145
+ function validateLookupEvidenceRdapAttempt(value, label) {
146
+ const attempt = exact(value, ['endpoint', 'transportSecurity', 'status', 'outcome', 'detail', 'selected'], label);
147
+ let endpoint = null;
148
+ if (attempt.endpoint !== null)
149
+ endpoint = absoluteUrl(attempt.endpoint, `${label} endpoint`, ['http:', 'https:']);
150
+ if (attempt.transportSecurity !== null)
151
+ enumeration(attempt.transportSecurity, ['http', 'https'], `${label} transport`);
152
+ if (endpoint && attempt.transportSecurity !== endpoint.protocol.slice(0, -1))
153
+ fail(`${label} transport`);
154
+ if (!endpoint && attempt.transportSecurity !== null)
155
+ fail(`${label} transport`);
156
+ if (attempt.status !== null)
157
+ integer(attempt.status, `${label} status`, 100, 599);
158
+ enumeration(attempt.outcome, LOOKUP_RDAP_ATTEMPT_OUTCOMES, `${label} outcome`);
159
+ optionalText(attempt.detail, `${label} detail`, 240);
160
+ boolean(attempt.selected, `${label} selection`);
161
+ }
162
+ function validateLookupEvidenceWhoisHop(value, label) {
163
+ const hop = exact(value, ['server', 'address', 'queriedAt', 'queryProfile', 'responseEncoding', 'status', 'detail'], label);
164
+ optionalText(hop.server, `${label} server`, 253);
165
+ optionalText(hop.address, `${label} address`, 64);
166
+ iso(hop.queriedAt, `${label} queriedAt`, true);
167
+ if (hop.queryProfile !== null)
168
+ enumeration(hop.queryProfile, [
169
+ 'plain-domain', 'denic-domain-ace', 'jprs-domain-english',
170
+ 'registry-domain-unicode', 'not-issued',
171
+ ], `${label} query profile`);
172
+ if (hop.responseEncoding !== null)
173
+ enumeration(hop.responseEncoding, ['utf-8'], `${label} response encoding`);
174
+ enumeration(hop.status, ['success', 'error', 'not_issued', 'unknown'], `${label} status`);
175
+ optionalText(hop.detail, `${label} detail`, 240);
176
+ }
177
+ function validateLookupEvidenceTiming(value) {
178
+ if (value === null)
179
+ return;
180
+ const timing = exact(value, ['version', 'totalMs', 'sources'], 'Lookup evidence timing');
181
+ if (timing.version !== 1)
182
+ fail('Lookup evidence timing');
183
+ if (timing.totalMs !== null)
184
+ integer(timing.totalMs, 'Lookup evidence total timing', 0, 120_000);
185
+ const seen = new Set();
186
+ array(timing.sources, 'Lookup evidence timing sources', LOOKUP_TIMING_SOURCES.length)
187
+ .forEach((item, index) => {
188
+ const entry = exact(item, ['source', 'outcome', 'durationMs', 'completedAfterMs'], `Lookup evidence timing source ${index + 1}`);
189
+ const source = enumeration(entry.source, LOOKUP_TIMING_SOURCES, `Lookup evidence timing source ${index + 1}`);
190
+ if (seen.has(source))
191
+ fail('Lookup evidence timing source');
192
+ seen.add(source);
193
+ enumeration(entry.outcome, ['fulfilled', 'rejected'], `Lookup evidence timing outcome ${index + 1}`);
194
+ if (entry.durationMs !== null)
195
+ integer(entry.durationMs, `Lookup evidence timing duration ${index + 1}`, 0, 120_000);
196
+ if (entry.completedAfterMs !== null)
197
+ integer(entry.completedAfterMs, `Lookup evidence timing completion ${index + 1}`, 0, 120_000);
198
+ });
199
+ }
200
+ function validateLookupEvidenceRegistryAccess(value) {
201
+ if (value === null)
202
+ return;
203
+ const access = exactOptional(value, [
204
+ 'suffix', 'coverageState', 'whoisAccessProfile', 'rdapAccessProfile',
205
+ 'limitation', 'authority',
206
+ ], ['officialLookupUrl'], 'Lookup evidence registry access');
207
+ optionalText(access.suffix, 'Lookup evidence registry suffix', 253);
208
+ optionalText(access.coverageState, 'Lookup evidence registry coverage', 40);
209
+ optionalText(access.whoisAccessProfile, 'Lookup evidence WHOIS access profile', 80);
210
+ optionalText(access.rdapAccessProfile, 'Lookup evidence RDAP access profile', 80);
211
+ if (access.officialLookupUrl !== undefined && access.officialLookupUrl !== null)
212
+ absoluteUrl(access.officialLookupUrl, 'Lookup evidence official lookup URL', ['http:', 'https:']);
213
+ optionalText(access.limitation, 'Lookup evidence registry limitation', 500);
214
+ if (access.authority !== null)
215
+ enumeration(access.authority, ['context_only'], 'Lookup evidence registry authority');
216
+ }
217
+ function validateLookupEvidenceRegistrarDiagnostic(value) {
218
+ if (value === null)
219
+ return;
220
+ const registrar = exact(value, ['status', 'endpoint', 'transportSecurity', 'httpStatus', 'fetchedAt', 'attempt'], 'Lookup evidence registrar diagnostic');
221
+ enumeration(registrar.status, ['success', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled'], 'Lookup evidence registrar diagnostic status');
222
+ let endpoint = null;
223
+ if (registrar.endpoint !== null)
224
+ endpoint = absoluteUrl(registrar.endpoint, 'Lookup evidence registrar diagnostic endpoint', ['http:', 'https:']);
225
+ if (registrar.transportSecurity !== null)
226
+ enumeration(registrar.transportSecurity, ['http', 'https'], 'Lookup evidence registrar diagnostic transport');
227
+ if (endpoint && registrar.transportSecurity !== endpoint.protocol.slice(0, -1))
228
+ fail('Lookup evidence registrar diagnostic transport');
229
+ if (!endpoint && registrar.transportSecurity !== null)
230
+ fail('Lookup evidence registrar diagnostic transport');
231
+ if (registrar.httpStatus !== null)
232
+ integer(registrar.httpStatus, 'Lookup evidence registrar diagnostic HTTP status', 100, 599);
233
+ iso(registrar.fetchedAt, 'Lookup evidence registrar diagnostic fetchedAt', true);
234
+ if (registrar.attempt !== null)
235
+ validateLookupEvidenceRdapAttempt(registrar.attempt, 'Lookup evidence registrar diagnostic attempt');
236
+ }
237
+ function validateLookupEvidenceCurrentDiagnostics(value) {
238
+ const diagnostics = exactOptional(value, ['rdap', 'whois'], [
239
+ 'version', 'timing', 'registryAccess', 'availability', 'reverseDns',
240
+ 'network', 'securityTxt', 'sslbl',
241
+ ], 'Lookup evidence diagnostics');
242
+ if (diagnostics.version !== undefined)
243
+ integer(diagnostics.version, 'Lookup evidence diagnostics version', 1, 10);
244
+ if (diagnostics.timing !== undefined)
245
+ validateLookupEvidenceTiming(diagnostics.timing);
246
+ if (diagnostics.registryAccess !== undefined)
247
+ validateLookupEvidenceRegistryAccess(diagnostics.registryAccess);
248
+ const rdap = exactOptional(diagnostics.rdap, ['status', 'errorCode', 'endpoint', 'transportSecurity', 'httpStatus', 'fetchedAt', 'attempts'], ['registrar'], 'Lookup evidence RDAP diagnostics');
249
+ const rdapStatus = enumeration(rdap.status, ['success', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled'], 'Lookup evidence RDAP diagnostic status');
250
+ optionalText(rdap.errorCode, 'Lookup evidence RDAP diagnostic error', 80);
251
+ let rdapEndpoint = null;
252
+ if (rdap.endpoint !== null)
253
+ rdapEndpoint = absoluteUrl(rdap.endpoint, 'Lookup evidence RDAP diagnostic endpoint', ['http:', 'https:']);
254
+ if (rdap.transportSecurity !== null)
255
+ enumeration(rdap.transportSecurity, ['http', 'https'], 'Lookup evidence RDAP diagnostic transport');
256
+ if (rdapEndpoint && rdap.transportSecurity !== rdapEndpoint.protocol.slice(0, -1))
257
+ fail('Lookup evidence RDAP diagnostic transport');
258
+ if (!rdapEndpoint && rdap.transportSecurity !== null)
259
+ fail('Lookup evidence RDAP diagnostic transport');
260
+ if (rdap.httpStatus !== null)
261
+ integer(rdap.httpStatus, 'Lookup evidence RDAP diagnostic HTTP status', 100, 599);
262
+ iso(rdap.fetchedAt, 'Lookup evidence RDAP diagnostic fetchedAt', true);
263
+ array(rdap.attempts, 'Lookup evidence RDAP diagnostic attempts', 16)
264
+ .forEach((item, index) => validateLookupEvidenceRdapAttempt(item, `Lookup evidence RDAP diagnostic attempt ${index + 1}`));
265
+ if (rdap.registrar !== undefined)
266
+ validateLookupEvidenceRegistrarDiagnostic(rdap.registrar);
267
+ const whois = exact(diagnostics.whois, ['status', 'errorCode', 'queriedAt', 'authoritativeHop', 'failedHop', 'conflictingHop'], 'Lookup evidence WHOIS diagnostics');
268
+ const whoisStatus = enumeration(whois.status, ['complete', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled'], 'Lookup evidence WHOIS diagnostic status');
269
+ optionalText(whois.errorCode, 'Lookup evidence WHOIS diagnostic error', 80);
270
+ iso(whois.queriedAt, 'Lookup evidence WHOIS diagnostic queriedAt', true);
271
+ for (const key of ['authoritativeHop', 'failedHop', 'conflictingHop'])
272
+ optionalText(whois[key], `Lookup evidence WHOIS diagnostic ${key}`, 253);
273
+ if (diagnostics.availability !== undefined) {
274
+ const availability = exact(diagnostics.availability, ['status', 'errorCode', 'resultState'], 'Lookup evidence availability diagnostics');
275
+ enumeration(availability.status, ['complete', 'error', 'disabled', 'not_applicable'], 'Lookup evidence availability diagnostic status');
276
+ optionalText(availability.errorCode, 'Lookup evidence availability diagnostic error', 80);
277
+ optionalText(availability.resultState, 'Lookup evidence availability result', 40);
278
+ }
279
+ let reverseDnsState = null;
280
+ if (diagnostics.reverseDns !== undefined) {
281
+ const reverseDns = exact(diagnostics.reverseDns, ['status', 'observedAt', 'complete', 'truncated'], 'Lookup evidence reverse DNS diagnostics');
282
+ const status = enumeration(reverseDns.status, ['success', 'partial', 'not_found', 'unsupported', 'skipped', 'error'], 'Lookup evidence reverse DNS diagnostic status');
283
+ iso(reverseDns.observedAt, 'Lookup evidence reverse DNS diagnostic observedAt', true);
284
+ boolean(reverseDns.complete, 'Lookup evidence reverse DNS diagnostic completeness');
285
+ boolean(reverseDns.truncated, 'Lookup evidence reverse DNS diagnostic truncation');
286
+ const expectedComplete = reverseDns.truncated !== true && (status === 'success' || status === 'not_found');
287
+ if (reverseDns.complete !== expectedComplete)
288
+ fail('Lookup evidence reverse DNS diagnostic completeness');
289
+ reverseDnsState = { status, complete: Boolean(reverseDns.complete), truncated: Boolean(reverseDns.truncated) };
290
+ }
291
+ let networkState = null;
292
+ if (diagnostics.network !== undefined) {
293
+ const network = exact(diagnostics.network, ['status', 'address', 'family', 'addressSource', 'endpoint', 'transportSecurity', 'httpStatus', 'fetchedAt', 'attempts'], 'Lookup evidence network diagnostics');
294
+ networkState = enumeration(network.status, ['success', 'partial', 'not_found', 'unsupported', 'error'], 'Lookup evidence network diagnostic status');
295
+ optionalText(network.address, 'Lookup evidence network diagnostic address', 64);
296
+ if (network.family !== null && network.family !== 4 && network.family !== 6)
297
+ fail('Lookup evidence network diagnostic family');
298
+ if (network.addressSource !== null)
299
+ enumeration(network.addressSource, ['tls_connection', 'dns_a', 'dns_aaaa'], 'Lookup evidence network diagnostic address source');
300
+ let endpoint = null;
301
+ if (network.endpoint !== null)
302
+ endpoint = absoluteUrl(network.endpoint, 'Lookup evidence network diagnostic endpoint', ['http:', 'https:']);
303
+ if (network.transportSecurity !== null)
304
+ enumeration(network.transportSecurity, ['http', 'https'], 'Lookup evidence network diagnostic transport');
305
+ if (endpoint && network.transportSecurity !== endpoint.protocol.slice(0, -1))
306
+ fail('Lookup evidence network diagnostic transport');
307
+ if (!endpoint && network.transportSecurity !== null)
308
+ fail('Lookup evidence network diagnostic transport');
309
+ if (network.httpStatus !== null)
310
+ integer(network.httpStatus, 'Lookup evidence network diagnostic HTTP status', 100, 599);
311
+ iso(network.fetchedAt, 'Lookup evidence network diagnostic fetchedAt', true);
312
+ array(network.attempts, 'Lookup evidence network diagnostic attempts', 3)
313
+ .forEach((item, index) => validateLookupEvidenceRdapAttempt(item, `Lookup evidence network diagnostic attempt ${index + 1}`));
314
+ }
315
+ if (diagnostics.securityTxt !== undefined) {
316
+ const securityTxt = exact(diagnostics.securityTxt, ['status', 'state', 'endpoint', 'httpStatus', 'observedAt', 'complete', 'truncated'], 'Lookup evidence security.txt diagnostics');
317
+ optionalText(securityTxt.status, 'Lookup evidence security.txt diagnostic status', 40);
318
+ if (securityTxt.state !== null)
319
+ enumeration(securityTxt.state, ['present', 'stale', 'partial', 'absent', 'malformed', 'unsupported', 'unavailable'], 'Lookup evidence security.txt diagnostic state');
320
+ if (securityTxt.endpoint !== null)
321
+ absoluteUrl(securityTxt.endpoint, 'Lookup evidence security.txt diagnostic endpoint', ['https:']);
322
+ if (securityTxt.httpStatus !== null)
323
+ integer(securityTxt.httpStatus, 'Lookup evidence security.txt diagnostic HTTP status', 100, 599);
324
+ iso(securityTxt.observedAt, 'Lookup evidence security.txt diagnostic observedAt', true);
325
+ boolean(securityTxt.complete, 'Lookup evidence security.txt diagnostic completeness');
326
+ boolean(securityTxt.truncated, 'Lookup evidence security.txt diagnostic truncation');
327
+ }
328
+ if (diagnostics.sslbl !== undefined) {
329
+ const sslbl = exact(diagnostics.sslbl, ['status', 'verdict', 'observedAt', 'complete', 'snapshotUpdatedAt'], 'Lookup evidence SSLBL diagnostics');
330
+ if (sslbl.status !== null)
331
+ enumeration(sslbl.status, ['success', 'stale', 'unavailable'], 'Lookup evidence SSLBL diagnostic status');
332
+ if (sslbl.verdict !== null)
333
+ enumeration(sslbl.verdict, ['listed', 'not_listed', 'inconclusive'], 'Lookup evidence SSLBL diagnostic verdict');
334
+ iso(sslbl.observedAt, 'Lookup evidence SSLBL diagnostic observedAt', true);
335
+ boolean(sslbl.complete, 'Lookup evidence SSLBL diagnostic completeness');
336
+ iso(sslbl.snapshotUpdatedAt, 'Lookup evidence SSLBL diagnostic snapshot timestamp', true);
337
+ }
338
+ return { rdap: rdapStatus, whois: whoisStatus, reverseDns: reverseDnsState, network: networkState };
339
+ }
340
+ function validateLookupEvidenceTree(value) {
341
+ try {
342
+ assertLookupEvidencePortableTree(value);
343
+ }
344
+ catch {
345
+ fail('Lookup evidence portable bounds');
346
+ }
347
+ }
348
+ function validateLookupEvidenceRdap(value, version) {
349
+ const source = record(value, 'Lookup evidence RDAP source');
350
+ if (source.status === 'error') {
351
+ const error = exact(source, ['status', 'error', 'attempts'], 'Lookup evidence RDAP source');
352
+ text(error.error, 'Lookup evidence RDAP error', 10_000);
353
+ array(error.attempts, 'Lookup evidence RDAP attempts', 16).forEach((item, index) => {
354
+ if (version === LEGACY_LOOKUP_EVIDENCE_SCHEMA_VERSION)
355
+ record(item, `Lookup evidence legacy RDAP attempt ${index + 1}`);
356
+ else
357
+ validateLookupEvidenceRdapAttempt(item, `Lookup evidence RDAP attempt ${index + 1}`);
358
+ });
359
+ return 'error';
360
+ }
361
+ const complete = exact(source, ['status', 'endpoint', 'transportSecurity', 'httpStatus', 'fetchedAt', 'attempts', 'parsed', 'raw'], 'Lookup evidence RDAP source');
362
+ const status = enumeration(complete.status, version === LEGACY_LOOKUP_EVIDENCE_SCHEMA_VERSION
363
+ ? ['success', 'not_found']
364
+ : ['success', 'partial', 'not_found', 'unsupported', 'skipped', 'disabled'], 'Lookup evidence RDAP status');
365
+ let endpoint = null;
366
+ if (complete.endpoint !== null)
367
+ endpoint = absoluteUrl(complete.endpoint, 'Lookup evidence RDAP endpoint', ['http:', 'https:']);
368
+ if (complete.transportSecurity !== null)
369
+ enumeration(complete.transportSecurity, ['http', 'https'], 'Lookup evidence RDAP transport');
370
+ if (version !== LEGACY_LOOKUP_EVIDENCE_SCHEMA_VERSION) {
371
+ if (endpoint && complete.transportSecurity !== endpoint.protocol.slice(0, -1))
372
+ fail('Lookup evidence RDAP transport');
373
+ if (!endpoint && complete.transportSecurity !== null)
374
+ fail('Lookup evidence RDAP transport');
375
+ }
376
+ if (complete.httpStatus !== null)
377
+ integer(complete.httpStatus, 'Lookup evidence RDAP HTTP status', 100, 599);
378
+ iso(complete.fetchedAt, 'Lookup evidence RDAP fetchedAt', true);
379
+ array(complete.attempts, 'Lookup evidence RDAP attempts', 16).forEach((item, index) => {
380
+ if (version === LEGACY_LOOKUP_EVIDENCE_SCHEMA_VERSION)
381
+ record(item, `Lookup evidence legacy RDAP attempt ${index + 1}`);
382
+ else
383
+ validateLookupEvidenceRdapAttempt(item, `Lookup evidence RDAP attempt ${index + 1}`);
384
+ });
385
+ nullableRecord(complete.parsed, 'Lookup evidence RDAP parsed data');
386
+ nullableRecord(complete.raw, 'Lookup evidence RDAP raw data');
387
+ if (version !== LEGACY_LOOKUP_EVIDENCE_SCHEMA_VERSION
388
+ && status === 'success' && complete.parsed === null)
389
+ fail('Lookup evidence RDAP publication');
390
+ if (version !== LEGACY_LOOKUP_EVIDENCE_SCHEMA_VERSION
391
+ && !['success', 'partial'].includes(status)
392
+ && (complete.parsed !== null || complete.raw !== null))
393
+ fail('Lookup evidence RDAP unavailable publication');
394
+ return status;
395
+ }
396
+ function validateLookupEvidenceWhois(value, version) {
397
+ const source = record(value, 'Lookup evidence WHOIS source');
398
+ if (source.status === 'error') {
399
+ const error = exact(source, ['status', 'error'], 'Lookup evidence WHOIS source');
400
+ text(error.error, 'Lookup evidence WHOIS error', 10_000);
401
+ return 'error';
402
+ }
403
+ const complete = exact(source, ['status', 'queriedAt', 'authoritativeHop', 'failedHop', 'conflictingHop', 'parsed', 'chain'], 'Lookup evidence WHOIS source');
404
+ const status = enumeration(complete.status, version === LEGACY_LOOKUP_EVIDENCE_SCHEMA_VERSION
405
+ ? ['complete', 'partial', 'unknown']
406
+ : ['complete', 'partial', 'not_found', 'unsupported', 'skipped', 'disabled'], 'Lookup evidence WHOIS status');
407
+ iso(complete.queriedAt, 'Lookup evidence WHOIS queriedAt', true);
408
+ optionalText(complete.authoritativeHop, 'Lookup evidence WHOIS authoritative hop', 253);
409
+ optionalText(complete.failedHop, 'Lookup evidence WHOIS failed hop', 253);
410
+ optionalText(complete.conflictingHop, 'Lookup evidence WHOIS conflicting hop', 253);
411
+ nullableRecord(complete.parsed, 'Lookup evidence WHOIS parsed data');
412
+ array(complete.chain, 'Lookup evidence WHOIS chain', 16).forEach((item, index) => {
413
+ if (version === LEGACY_LOOKUP_EVIDENCE_SCHEMA_VERSION)
414
+ record(item, `Lookup evidence legacy WHOIS chain item ${index + 1}`);
415
+ else
416
+ validateLookupEvidenceWhoisHop(item, `Lookup evidence WHOIS chain item ${index + 1}`);
417
+ });
418
+ if (status === 'complete' && complete.parsed === null)
419
+ fail('Lookup evidence WHOIS publication');
420
+ if (version !== LEGACY_LOOKUP_EVIDENCE_SCHEMA_VERSION
421
+ && !['complete', 'partial'].includes(status)
422
+ && (complete.parsed !== null || complete.chain.length !== 0))
423
+ fail('Lookup evidence WHOIS unavailable publication');
424
+ return status;
425
+ }
426
+ function validateLookupEvidenceReverseDns(value) {
427
+ if (value === null)
428
+ return null;
429
+ const source = exact(value, ['version', 'status', 'observedAt', 'scanMode', 'source', 'durationMs', 'complete', 'truncated', 'limitations', 'diagnostics', 'records'], 'Lookup evidence reverse DNS source');
430
+ if (source.version !== 1 || source.source !== 'reverse_dns')
431
+ fail('Lookup evidence reverse DNS source');
432
+ const status = enumeration(source.status, ['success', 'partial', 'not_found', 'unsupported', 'skipped', 'error'], 'Lookup evidence reverse DNS status');
433
+ iso(source.observedAt, 'Lookup evidence reverse DNS observedAt', true);
434
+ if (source.scanMode !== null)
435
+ enumeration(source.scanMode, ['deep'], 'Lookup evidence reverse DNS mode');
436
+ if (source.durationMs !== null)
437
+ integer(source.durationMs, 'Lookup evidence reverse DNS duration', 0, 120_000);
438
+ boolean(source.complete, 'Lookup evidence reverse DNS completeness');
439
+ boolean(source.truncated, 'Lookup evidence reverse DNS truncation');
440
+ strings(source.limitations, 'Lookup evidence reverse DNS limitations', 10, 300);
441
+ if (source.diagnostics !== null) {
442
+ const diagnostics = exact(source.diagnostics, ['ptr'], 'Lookup evidence reverse DNS diagnostics');
443
+ const ptr = exact(diagnostics.ptr, ['status', 'error', 'truncated', 'discarded'], 'Lookup evidence reverse DNS PTR diagnostics');
444
+ optionalText(ptr.status, 'Lookup evidence reverse DNS PTR status', 40);
445
+ optionalText(ptr.error, 'Lookup evidence reverse DNS PTR error', 180);
446
+ boolean(ptr.truncated, 'Lookup evidence reverse DNS PTR truncation');
447
+ if (ptr.discarded !== null)
448
+ integer(ptr.discarded, 'Lookup evidence reverse DNS PTR discarded count', 0, 1_000);
449
+ }
450
+ const records = exact(source.records, ['ptr'], 'Lookup evidence reverse DNS records');
451
+ const ptr = strings(records.ptr, 'Lookup evidence reverse DNS PTR records', 8, 253);
452
+ if (!['success', 'partial'].includes(status) && ptr.length)
453
+ fail('Lookup evidence reverse DNS unavailable records');
454
+ const expectedComplete = source.truncated !== true && (status === 'success' || status === 'not_found');
455
+ if (source.complete !== expectedComplete)
456
+ fail('Lookup evidence reverse DNS completeness');
457
+ return { status, complete: Boolean(source.complete), truncated: Boolean(source.truncated) };
458
+ }
459
+ function validateLookupEvidenceNetwork(value) {
460
+ if (value === null)
461
+ return null;
462
+ const source = exact(value, ['contextVersion', 'version', 'status', 'observedAt', 'scanMode', 'source', 'durationMs', 'complete', 'truncated', 'limitations', 'diagnostics', 'detail', 'endpoint', 'rdap', 'network'], 'Lookup evidence network source');
463
+ if (source.contextVersion !== 1 || (source.version !== 1 && source.version !== null))
464
+ fail('Lookup evidence network source');
465
+ const status = enumeration(source.status, ['success', 'partial', 'not_found', 'unsupported', 'error'], 'Lookup evidence network status');
466
+ iso(source.observedAt, 'Lookup evidence network observedAt', true);
467
+ if (source.scanMode !== null)
468
+ enumeration(source.scanMode, ['deep'], 'Lookup evidence network mode');
469
+ if (source.source !== null)
470
+ enumeration(source.source, ['ip_rdap'], 'Lookup evidence network kind');
471
+ if (source.durationMs !== null)
472
+ integer(source.durationMs, 'Lookup evidence network duration', 0, 120_000);
473
+ boolean(source.complete, 'Lookup evidence network completeness');
474
+ boolean(source.truncated, 'Lookup evidence network truncation');
475
+ strings(source.limitations, 'Lookup evidence network limitations', 10, 300);
476
+ if (source.diagnostics !== null) {
477
+ const diagnostics = exact(source.diagnostics, ['requestCount', 'addressSource', 'httpStatus', 'cidrCount'], 'Lookup evidence network diagnostics');
478
+ if (diagnostics.requestCount !== null)
479
+ integer(diagnostics.requestCount, 'Lookup evidence network request count', 0, 1);
480
+ if (diagnostics.addressSource !== null)
481
+ enumeration(diagnostics.addressSource, ['tls_connection', 'dns_a', 'dns_aaaa'], 'Lookup evidence network address source');
482
+ if (diagnostics.httpStatus !== null)
483
+ integer(diagnostics.httpStatus, 'Lookup evidence network HTTP status', 100, 599);
484
+ if (diagnostics.cidrCount !== null)
485
+ integer(diagnostics.cidrCount, 'Lookup evidence network CIDR count', 0, 16);
486
+ }
487
+ optionalText(source.detail, 'Lookup evidence network detail', 300);
488
+ if (source.endpoint !== null) {
489
+ const endpoint = exact(source.endpoint, ['address', 'family', 'selectedFrom'], 'Lookup evidence network endpoint');
490
+ optionalText(endpoint.address, 'Lookup evidence network address', 64);
491
+ if (endpoint.family !== null)
492
+ integer(endpoint.family, 'Lookup evidence network address family', 4, 6);
493
+ if (endpoint.family !== null && endpoint.family !== 4 && endpoint.family !== 6)
494
+ fail('Lookup evidence network address family');
495
+ if (endpoint.selectedFrom !== null)
496
+ enumeration(endpoint.selectedFrom, ['tls_connection', 'dns_a', 'dns_aaaa'], 'Lookup evidence network selected source');
497
+ }
498
+ if (source.rdap !== null) {
499
+ const rdap = exact(source.rdap, ['endpoint', 'transportSecurity', 'httpStatus', 'fetchedAt', 'attempts'], 'Lookup evidence network RDAP');
500
+ if (rdap.endpoint !== null)
501
+ absoluteUrl(rdap.endpoint, 'Lookup evidence network RDAP endpoint', ['http:', 'https:']);
502
+ if (rdap.transportSecurity !== null)
503
+ enumeration(rdap.transportSecurity, ['http', 'https'], 'Lookup evidence network RDAP transport');
504
+ if (rdap.httpStatus !== null)
505
+ integer(rdap.httpStatus, 'Lookup evidence network RDAP HTTP status', 100, 599);
506
+ iso(rdap.fetchedAt, 'Lookup evidence network RDAP fetchedAt', true);
507
+ array(rdap.attempts, 'Lookup evidence network RDAP attempts', 3).forEach((item, index) => {
508
+ const attempt = exact(item, ['endpoint', 'transportSecurity', 'status', 'outcome', 'detail', 'selected'], `Lookup evidence network RDAP attempt ${index + 1}`);
509
+ if (attempt.endpoint !== null)
510
+ absoluteUrl(attempt.endpoint, 'Lookup evidence network RDAP attempt endpoint', ['http:', 'https:']);
511
+ if (attempt.transportSecurity !== null)
512
+ enumeration(attempt.transportSecurity, ['http', 'https'], 'Lookup evidence network RDAP attempt transport');
513
+ if (attempt.status !== null)
514
+ integer(attempt.status, 'Lookup evidence network RDAP attempt status', 100, 599);
515
+ optionalText(attempt.outcome, 'Lookup evidence network RDAP attempt outcome', 40);
516
+ optionalText(attempt.detail, 'Lookup evidence network RDAP attempt detail', 240);
517
+ boolean(attempt.selected, 'Lookup evidence network RDAP attempt selection');
518
+ });
519
+ }
520
+ if (source.network !== null) {
521
+ const network = exact(source.network, ['handle', 'name', 'holder', 'cidrs', 'startAddress', 'endAddress', 'country', 'networkType', 'databaseUpdatedAt'], 'Lookup evidence network registration');
522
+ for (const key of ['handle', 'name', 'holder'])
523
+ optionalText(network[key], `Lookup evidence network ${key}`, 300);
524
+ strings(network.cidrs, 'Lookup evidence network CIDRs', 16, 96);
525
+ optionalText(network.startAddress, 'Lookup evidence network start address', 64);
526
+ optionalText(network.endAddress, 'Lookup evidence network end address', 64);
527
+ if (network.country !== null && !/^[A-Z]{2}$/u.test(String(network.country)))
528
+ fail('Lookup evidence network country');
529
+ optionalText(network.networkType, 'Lookup evidence network type', 160);
530
+ iso(network.databaseUpdatedAt, 'Lookup evidence network database timestamp', true);
531
+ }
532
+ if (!['success', 'partial'].includes(status)
533
+ && (source.endpoint !== null || source.rdap !== null || source.network !== null)) {
534
+ fail('Lookup evidence unavailable network publication');
535
+ }
536
+ const expectedComplete = source.truncated !== true && (status === 'success' || status === 'not_found');
537
+ if (source.complete !== expectedComplete)
538
+ fail('Lookup evidence network completeness');
539
+ return { status, complete: Boolean(source.complete), truncated: Boolean(source.truncated) };
540
+ }
541
+ function validateLookupEvidenceSecurityTxt(value) {
542
+ if (value === null)
543
+ return;
544
+ 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');
545
+ if (source.securityTxtVersion !== 1 || (source.version !== 1 && source.version !== null) || source.source !== 'security_txt')
546
+ fail('Lookup evidence security.txt source');
547
+ enumeration(source.state, ['present', 'stale', 'partial', 'absent', 'malformed', 'unsupported', 'unavailable'], 'Lookup evidence security.txt state');
548
+ optionalText(source.status, 'Lookup evidence security.txt status', 40);
549
+ iso(source.observedAt, 'Lookup evidence security.txt observedAt', true);
550
+ if (source.scanMode !== null)
551
+ enumeration(source.scanMode, ['deep'], 'Lookup evidence security.txt mode');
552
+ if (source.durationMs !== null)
553
+ integer(source.durationMs, 'Lookup evidence security.txt duration', 0, 120_000);
554
+ boolean(source.complete, 'Lookup evidence security.txt completeness');
555
+ boolean(source.truncated, 'Lookup evidence security.txt truncation');
556
+ strings(source.limitations, 'Lookup evidence security.txt limitations', 10, 300);
557
+ optionalText(source.detail, 'Lookup evidence security.txt detail', 300);
558
+ optionalText(source.requestedUrl, 'Lookup evidence security.txt requested URL', 2_048);
559
+ optionalText(source.finalUrl, 'Lookup evidence security.txt final URL', 2_048);
560
+ if (source.httpStatus !== null)
561
+ integer(source.httpStatus, 'Lookup evidence security.txt HTTP status', 100, 599);
562
+ if (source.redirectCount !== null)
563
+ integer(source.redirectCount, 'Lookup evidence security.txt redirects', 0, 3);
564
+ iso(source.expiresAt, 'Lookup evidence security.txt expiry', true);
565
+ boolean(source.signed, 'Lookup evidence security.txt signed state');
566
+ if (source.canonicalMatches !== null)
567
+ boolean(source.canonicalMatches, 'Lookup evidence security.txt canonical state');
568
+ strings(source.contacts, 'Lookup evidence security.txt contacts', 10, 2_048);
569
+ strings(source.policies, 'Lookup evidence security.txt policies', 10, 2_048);
570
+ strings(source.encryption, 'Lookup evidence security.txt encryption', 10, 2_048);
571
+ strings(source.canonical, 'Lookup evidence security.txt canonical URLs', 10, 2_048);
572
+ strings(source.preferredLanguages, 'Lookup evidence security.txt languages', 10, 40);
573
+ const expectedStatus = source.state === 'present'
574
+ ? 'success'
575
+ : source.state === 'absent'
576
+ ? 'not_found'
577
+ : source.state === 'unsupported'
578
+ ? 'unsupported'
579
+ : source.state === 'unavailable' || source.state === 'malformed'
580
+ ? 'error'
581
+ : 'partial';
582
+ if (source.status !== expectedStatus)
583
+ fail('Lookup evidence security.txt state');
584
+ if (source.complete !== (source.state === 'present' || source.state === 'absent'))
585
+ fail('Lookup evidence security.txt completeness');
586
+ }
587
+ function validateLookupEvidenceSslbl(value) {
588
+ if (value === null)
589
+ return;
590
+ const source = exact(value, ['sslblVersion', 'source', 'status', 'verdict', 'complete', 'observedAt', 'fingerprintSha1', 'referenceUrl', 'snapshot', 'detail', 'limitations'], 'Lookup evidence SSLBL source');
591
+ if (source.sslblVersion !== 1 || source.source !== 'sslbl')
592
+ fail('Lookup evidence SSLBL source');
593
+ enumeration(source.status, ['success', 'stale', 'unavailable'], 'Lookup evidence SSLBL status');
594
+ enumeration(source.verdict, ['listed', 'not_listed', 'inconclusive'], 'Lookup evidence SSLBL verdict');
595
+ boolean(source.complete, 'Lookup evidence SSLBL completeness');
596
+ iso(source.observedAt, 'Lookup evidence SSLBL observedAt', true);
597
+ if (source.fingerprintSha1 !== null && !/^[a-f0-9]{40}$/u.test(String(source.fingerprintSha1)))
598
+ fail('Lookup evidence SSLBL fingerprint');
599
+ optionalText(source.referenceUrl, 'Lookup evidence SSLBL reference', 2_048);
600
+ if (source.snapshot === null)
601
+ fail('Lookup evidence SSLBL snapshot');
602
+ const snapshot = exact(source.snapshot, ['sourceUpdatedAt', 'generatedAt', 'ageSeconds', 'entryCount', 'digestSha256'], 'Lookup evidence SSLBL snapshot');
603
+ iso(snapshot.sourceUpdatedAt, 'Lookup evidence SSLBL source timestamp', true);
604
+ iso(snapshot.generatedAt, 'Lookup evidence SSLBL generated timestamp', true);
605
+ if (snapshot.ageSeconds !== null)
606
+ integer(snapshot.ageSeconds, 'Lookup evidence SSLBL age', 0, 31_536_000);
607
+ if (snapshot.entryCount !== null)
608
+ integer(snapshot.entryCount, 'Lookup evidence SSLBL entry count', 0, 50_000);
609
+ if (snapshot.digestSha256 !== null && !HEX_DIGEST_RE.test(String(snapshot.digestSha256)))
610
+ fail('Lookup evidence SSLBL digest');
611
+ optionalText(source.detail, 'Lookup evidence SSLBL detail', 300);
612
+ strings(source.limitations, 'Lookup evidence SSLBL limitations', 10, 300);
613
+ const validState = (source.verdict === 'not_listed' && source.status === 'success' && source.complete === true)
614
+ || (source.verdict === 'listed'
615
+ && (source.status === 'success' || source.status === 'stale')
616
+ && source.complete === (source.status === 'success'))
617
+ || (source.verdict === 'inconclusive'
618
+ && (source.status === 'stale' || source.status === 'unavailable')
619
+ && source.complete === false);
620
+ if (!validState)
621
+ fail('Lookup evidence SSLBL state');
622
+ if (source.referenceUrl !== null) {
623
+ if (source.verdict !== 'listed' || source.fingerprintSha1 === null)
624
+ fail('Lookup evidence SSLBL reference');
625
+ const reference = absoluteUrl(source.referenceUrl, 'Lookup evidence SSLBL reference', ['https:']);
626
+ if (reference.hostname !== 'sslbl.abuse.ch'
627
+ || reference.port
628
+ || reference.pathname !== `/ssl-certificates/sha1/${String(source.fingerprintSha1)}/`)
629
+ fail('Lookup evidence SSLBL reference');
630
+ }
631
+ }
632
+ const LOOKUP_REGISTRAR_COMPARISON_STATUSES = [
633
+ 'equivalent', 'conflict', 'registry_only', 'registrar_only',
634
+ 'registry_redacted', 'registrar_redacted', 'registry_unavailable',
635
+ 'registrar_unavailable', 'registry_incomplete', 'registrar_incomplete',
636
+ ];
637
+ function lookupPublicationCondition(status) {
638
+ if (status === 'partial')
639
+ return 'incomplete';
640
+ return ['error', 'unsupported', 'not_found', 'skipped', 'disabled'].includes(status)
641
+ ? 'unavailable'
642
+ : 'complete';
643
+ }
644
+ function validateLookupEvidenceRegistrarComparison(value) {
645
+ if (value === null)
646
+ return;
647
+ const comparison = exact(value, ['fields', 'counts', 'sourceHealth'], 'Lookup evidence registrar comparison');
648
+ const counts = exact(comparison.counts, LOOKUP_REGISTRAR_COMPARISON_STATUSES, 'Lookup evidence registrar comparison counts');
649
+ const observedCounts = new Map();
650
+ const labels = new Set();
651
+ array(comparison.fields, 'Lookup evidence registrar comparison fields', 9).forEach((item, index) => {
652
+ const field = exact(item, [
653
+ 'label', 'status', 'registryState', 'registrarState', 'registryDisplay', 'registrarDisplay',
654
+ ], `Lookup evidence registrar comparison field ${index + 1}`);
655
+ const label = enumeration(field.label, [
656
+ 'Domain', 'Registrar', 'Registrar IANA ID', 'Created', 'Expires',
657
+ 'Last updated', 'DNSSEC', 'Statuses', 'Name servers',
658
+ ], `Lookup evidence registrar comparison field ${index + 1} label`);
659
+ if (labels.has(label))
660
+ fail('Lookup evidence registrar comparison fields');
661
+ labels.add(label);
662
+ const status = enumeration(field.status, LOOKUP_REGISTRAR_COMPARISON_STATUSES, `Lookup evidence registrar comparison field ${index + 1} status`);
663
+ enumeration(field.registryState, ['absent', 'redacted', 'value', 'incomplete', 'unavailable'], `Lookup evidence registrar comparison field ${index + 1} registry state`);
664
+ enumeration(field.registrarState, ['absent', 'redacted', 'value', 'incomplete', 'unavailable'], `Lookup evidence registrar comparison field ${index + 1} registrar state`);
665
+ text(field.registryDisplay, `Lookup evidence registrar comparison field ${index + 1} registry display`, 2_000);
666
+ text(field.registrarDisplay, `Lookup evidence registrar comparison field ${index + 1} registrar display`, 2_000);
667
+ observedCounts.set(status, (observedCounts.get(status) ?? 0) + 1);
668
+ });
669
+ for (const status of LOOKUP_REGISTRAR_COMPARISON_STATUSES) {
670
+ const count = integer(counts[status], `Lookup evidence registrar comparison ${status} count`, 0, 9);
671
+ if (count !== (observedCounts.get(status) ?? 0))
672
+ fail('Lookup evidence registrar comparison counts');
673
+ }
674
+ const sourceHealth = exact(comparison.sourceHealth, ['registry', 'registrar'], 'Lookup evidence registrar comparison source health');
675
+ for (const source of ['registry', 'registrar']) {
676
+ const health = exact(sourceHealth[source], ['status', 'condition'], `Lookup evidence registrar comparison ${source} health`);
677
+ const status = enumeration(health.status, [
678
+ 'success', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled',
679
+ ], `Lookup evidence registrar comparison ${source} status`);
680
+ if (health.condition !== lookupPublicationCondition(status))
681
+ fail(`Lookup evidence registrar comparison ${source} condition`);
682
+ }
683
+ }
684
+ export function validateLookupEvidenceArtifactStructure(value) {
685
+ validateLookupEvidenceTree(value);
686
+ const root = exact(value, ['schema', 'schemaVersion', 'generatedAt', 'application', 'query', 'diagnostics', 'sources', 'analysis'], 'Lookup evidence');
687
+ if (root.schema !== LOOKUP_EVIDENCE_SCHEMA
688
+ || !SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS.includes(Number(root.schemaVersion)))
689
+ fail('Lookup evidence');
690
+ const version = integer(root.schemaVersion, 'Lookup evidence version', LEGACY_LOOKUP_EVIDENCE_SCHEMA_VERSION, LOOKUP_EVIDENCE_SCHEMA_VERSION);
691
+ iso(root.generatedAt, 'Lookup evidence generatedAt');
692
+ const application = exact(root.application, ['name', 'version', 'projectUrl'], 'Lookup evidence application');
693
+ if (application.name !== 'WHOISleuth' || application.projectUrl !== WHOISLEUTH_SOURCE_REPOSITORY_URL)
694
+ fail('Lookup evidence application');
695
+ if (application.version !== null && (typeof application.version !== 'string' || !SEMANTIC_VERSION_RE.test(application.version)))
696
+ fail('Lookup evidence application version');
697
+ const query = exact(root.query, ['submitted', 'type', 'inputHostname', 'registrableDomain', 'isSubdomain'], 'Lookup evidence query');
698
+ text(query.submitted, 'Lookup evidence submitted query', 1_024);
699
+ const queryType = enumeration(query.type, ['domain', 'ipv4', 'ipv6', 'asn'], 'Lookup evidence query type');
700
+ optionalText(query.inputHostname, 'Lookup evidence input hostname', 253);
701
+ optionalText(query.registrableDomain, 'Lookup evidence registrable domain', 253);
702
+ boolean(query.isSubdomain, 'Lookup evidence subdomain state');
703
+ if (queryType === 'domain') {
704
+ if (query.inputHostname !== null)
705
+ domain(query.inputHostname, 'Lookup evidence input hostname');
706
+ domain(query.registrableDomain, 'Lookup evidence registrable domain');
707
+ }
708
+ else if (query.inputHostname !== null || query.registrableDomain !== null || query.isSubdomain !== false)
709
+ fail('Lookup evidence query');
710
+ if (version !== LEGACY_LOOKUP_EVIDENCE_SCHEMA_VERSION
711
+ && !isDeepStrictEqual(query, projectLookupEvidenceQuery(query)))
712
+ fail('Lookup evidence query');
713
+ let diagnostics;
714
+ let rdapDiagnosticStatus;
715
+ let whoisDiagnosticStatus;
716
+ let currentDiagnosticStates = null;
717
+ if (version === LEGACY_LOOKUP_EVIDENCE_SCHEMA_VERSION) {
718
+ diagnostics = exactOptional(root.diagnostics, ['rdap', 'whois'], ['version', 'availability', 'timing', 'registryAccess', 'reverseDns', 'network', 'securityTxt', 'sslbl'], 'Lookup evidence diagnostics');
719
+ if (diagnostics.version !== undefined)
720
+ integer(diagnostics.version, 'Lookup evidence diagnostics version', 1, 10);
721
+ const rdapDiagnostics = record(diagnostics.rdap, 'Lookup evidence rdap diagnostics');
722
+ const whoisDiagnostics = record(diagnostics.whois, 'Lookup evidence whois diagnostics');
723
+ rdapDiagnosticStatus = enumeration(rdapDiagnostics.status, ['success', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled'], 'Lookup evidence RDAP diagnostic status');
724
+ whoisDiagnosticStatus = enumeration(whoisDiagnostics.status, ['complete', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled'], 'Lookup evidence WHOIS diagnostic status');
725
+ for (const id of ['availability', 'timing', 'registryAccess', 'reverseDns', 'network', 'securityTxt', 'sslbl']) {
726
+ if (diagnostics[id] !== undefined)
727
+ record(diagnostics[id], `Lookup evidence ${id} diagnostics`);
728
+ }
729
+ }
730
+ else {
731
+ diagnostics = record(root.diagnostics, 'Lookup evidence diagnostics');
732
+ currentDiagnosticStates = validateLookupEvidenceCurrentDiagnostics(diagnostics);
733
+ rdapDiagnosticStatus = currentDiagnosticStates.rdap;
734
+ whoisDiagnosticStatus = currentDiagnosticStates.whois;
735
+ }
736
+ const sources = exact(root.sources, ['rdap', 'whois', 'reverseDns', 'network', 'securityTxt', 'sslbl'], 'Lookup evidence sources');
737
+ const rdapSourceStatus = validateLookupEvidenceRdap(sources.rdap, version);
738
+ const whoisSourceStatus = validateLookupEvidenceWhois(sources.whois, version);
739
+ if (version !== LEGACY_LOOKUP_EVIDENCE_SCHEMA_VERSION) {
740
+ if (rdapSourceStatus !== rdapDiagnosticStatus)
741
+ fail('Lookup evidence RDAP source state');
742
+ if (whoisSourceStatus !== whoisDiagnosticStatus)
743
+ fail('Lookup evidence WHOIS source state');
744
+ }
745
+ else {
746
+ const rdapSource = record(sources.rdap, 'Lookup evidence legacy RDAP source');
747
+ const whoisSource = record(sources.whois, 'Lookup evidence legacy WHOIS source');
748
+ if (rdapDiagnosticStatus === 'success'
749
+ && (rdapSourceStatus !== 'success' || rdapSource.parsed === null))
750
+ fail('Lookup evidence legacy RDAP source state');
751
+ if (rdapDiagnosticStatus === 'not_found' && rdapSourceStatus !== 'not_found')
752
+ fail('Lookup evidence legacy RDAP source state');
753
+ if (rdapDiagnosticStatus === 'error' && rdapSourceStatus !== 'error')
754
+ fail('Lookup evidence legacy RDAP source state');
755
+ if (whoisDiagnosticStatus === 'complete'
756
+ && (whoisSourceStatus !== 'complete' || whoisSource.parsed === null))
757
+ fail('Lookup evidence legacy WHOIS source state');
758
+ if (whoisDiagnosticStatus === 'partial'
759
+ && (whoisSourceStatus !== 'partial' || whoisSource.parsed === null))
760
+ fail('Lookup evidence legacy WHOIS source state');
761
+ if (whoisDiagnosticStatus === 'error' && whoisSourceStatus !== 'error')
762
+ fail('Lookup evidence legacy WHOIS source state');
763
+ }
764
+ const reverseDnsState = validateLookupEvidenceReverseDns(sources.reverseDns);
765
+ const networkState = validateLookupEvidenceNetwork(sources.network);
766
+ if (currentDiagnosticStates) {
767
+ if (currentDiagnosticStates.reverseDns
768
+ && !isDeepStrictEqual(reverseDnsState, currentDiagnosticStates.reverseDns)) {
769
+ fail('Lookup evidence reverse DNS source state');
770
+ }
771
+ if (currentDiagnosticStates.network !== null
772
+ && (networkState?.status ?? null) !== currentDiagnosticStates.network) {
773
+ fail('Lookup evidence network source state');
774
+ }
775
+ }
776
+ validateLookupEvidenceSecurityTxt(sources.securityTxt);
777
+ validateLookupEvidenceSslbl(sources.sslbl);
778
+ const analysis = exact(root.analysis, ['availability', 'idn', 'registryInsights', 'registryComparison', 'registrarPublicationComparison'], 'Lookup evidence analysis');
779
+ if (analysis.availability !== null) {
780
+ exactOptional(analysis.availability, [], LOOKUP_AVAILABILITY_ANALYSIS_KEYS, 'Lookup evidence availability analysis');
781
+ if (version !== LEGACY_LOOKUP_EVIDENCE_SCHEMA_VERSION
782
+ && !isDeepStrictEqual(analysis.availability, projectLookupEvidenceAvailability(analysis.availability)))
783
+ fail('Lookup evidence availability analysis');
784
+ }
785
+ if (analysis.idn !== null) {
786
+ exactOptional(analysis.idn, [], LOOKUP_IDN_ANALYSIS_KEYS, 'Lookup evidence IDN analysis');
787
+ }
788
+ const registryInsights = record(analysis.registryInsights, 'Lookup evidence registry insights');
789
+ const registryComparison = record(analysis.registryComparison, 'Lookup evidence registry comparison');
790
+ validateLookupEvidenceRegistrarComparison(analysis.registrarPublicationComparison);
791
+ if (version !== LEGACY_LOOKUP_EVIDENCE_SCHEMA_VERSION) {
792
+ const rdap = record(sources.rdap, 'Lookup evidence RDAP source');
793
+ const whois = record(sources.whois, 'Lookup evidence WHOIS source');
794
+ const rdapParsed = ['success', 'partial'].includes(rdapDiagnosticStatus) ? rdap.parsed : null;
795
+ const whoisParsed = ['complete', 'partial'].includes(whoisDiagnosticStatus) ? whois.parsed : null;
796
+ const expectedComparison = compareRegistrySources(rdapParsed, whoisParsed, {
797
+ rdapStatus: rdapDiagnosticStatus,
798
+ whoisStatus: whoisDiagnosticStatus,
799
+ });
800
+ if (!isDeepStrictEqual(registryComparison, expectedComparison))
801
+ fail('Lookup evidence registry comparison derivation');
802
+ const expectedInsights = buildRegistryInsights({
803
+ rdapParsed,
804
+ rdapStatus: rdapDiagnosticStatus,
805
+ rdapFetchedAt: rdap.fetchedAt,
806
+ whoisParsed,
807
+ whoisStatus: whoisDiagnosticStatus,
808
+ whoisQueriedAt: whois.queriedAt,
809
+ });
810
+ if (!isDeepStrictEqual(registryInsights, expectedInsights))
811
+ fail('Lookup evidence registry insight derivation');
812
+ try {
813
+ assertLookupEvidencePrivacySafeTree(root);
814
+ }
815
+ catch {
816
+ fail('Lookup evidence privacy boundary');
817
+ }
818
+ }
819
+ }
820
+ function validateIntegrity(value, label, version, legacyVersion, currentVersion, legacyExplicit = false) {
821
+ const explicit = version === currentVersion || (version === legacyVersion && legacyExplicit);
822
+ if (version !== legacyVersion && version !== currentVersion)
823
+ fail(label);
824
+ const integrity = exact(value, explicit
825
+ ? ['algorithm', 'canonicalization', 'digestSha256']
826
+ : ['algorithm', 'digestSha256'], label);
827
+ if (integrity.algorithm !== 'SHA-256')
828
+ fail(label);
829
+ if (explicit && integrity.canonicalization !== (version === currentVersion ? 'sorted-json-v2' : 'sorted-json-v1'))
830
+ fail(label);
831
+ digest(integrity.digestSha256, label);
832
+ }
833
+ function validateAcquisitionItem(value, label) {
834
+ const item = exact(value, ['id', 'label', 'state', 'detail', 'provenance'], label);
835
+ const id = enumeration(item.id, ['availability', 'contacts', 'lifecycle', 'mail', 'nameservers', 'operations', 'policy_eligibility', 'policy_lifecycle', 'policy_transfer', 'tls', 'transfer', 'web'], label);
836
+ enumeration(item.state, ['authoritative', 'observed', 'review', 'unavailable'], label);
837
+ text(item.label, label, 200);
838
+ text(item.detail, label, 2_000);
839
+ text(item.provenance, label, 300);
840
+ return id;
841
+ }
842
+ function validateAcquisition(value) {
843
+ const root = exact(value, ['schema', 'version', 'generatedAt', 'target', 'synthetic', 'evidenceObservedAt', 'analystReview', 'evidenceReview', 'limitations', 'integrity'], 'Acquisition decision packet');
844
+ iso(root.generatedAt, 'Acquisition decision packet generatedAt');
845
+ domain(root.target, 'Acquisition decision packet target');
846
+ boolean(root.synthetic, 'Acquisition decision packet synthetic');
847
+ iso(root.evidenceObservedAt, 'Acquisition decision packet evidenceObservedAt', true);
848
+ const review = exact(root.analystReview, ['decision', 'rationale', 'reviewedChecks', 'outstandingChecks', 'state'], 'Acquisition analyst review');
849
+ const decision = enumeration(review.decision, ACQUISITION_DECISIONS, 'Acquisition analyst review decision');
850
+ text(review.rationale, 'Acquisition analyst review rationale', 2_000, true);
851
+ const reviewed = strings(review.reviewedChecks, 'Acquisition reviewed checks', ACQUISITION_MANUAL_CHECKS.length, 40);
852
+ const outstanding = strings(review.outstandingChecks, 'Acquisition outstanding checks', ACQUISITION_MANUAL_CHECKS.length, 40);
853
+ const expectedReviewed = ACQUISITION_MANUAL_CHECKS.filter((item) => reviewed.includes(item));
854
+ const expectedOutstanding = ACQUISITION_MANUAL_CHECKS.filter((item) => !reviewed.includes(item));
855
+ if (!sameValues(reviewed, expectedReviewed) || !sameValues(outstanding, expectedOutstanding))
856
+ fail('Acquisition analyst review checks');
857
+ const expectedState = decision === 'unresolved' || reviewed.length < ACQUISITION_MANUAL_CHECKS.length ? 'draft' : 'reviewed';
858
+ if (review.state !== expectedState)
859
+ fail('Acquisition analyst review state');
860
+ const evidence = exact(root.evidenceReview, ['version', 'label', 'state', 'items', 'transitionDependencies', 'policyChecks', 'nextSteps', 'limitations'], 'Acquisition evidence review');
861
+ if (evidence.version !== 2)
862
+ fail('Acquisition evidence review');
863
+ text(evidence.label, 'Acquisition evidence review label', 200);
864
+ enumeration(evidence.state, ['incomplete', 'registered', 'review_transition', 'sale_signal', 'unregistered_observation'], 'Acquisition evidence review state');
865
+ const expectedEvidenceIds = {
866
+ items: ['availability', 'lifecycle', 'transfer', 'operations', 'contacts'],
867
+ transitionDependencies: ['nameservers', 'web', 'mail', 'tls'],
868
+ policyChecks: ['policy_eligibility', 'policy_lifecycle', 'policy_transfer'],
869
+ };
870
+ for (const key of ['items', 'transitionDependencies', 'policyChecks']) {
871
+ const expected = expectedEvidenceIds[key];
872
+ const values = array(evidence[key], `Acquisition evidence review ${key}`, expected.length, expected.length);
873
+ const ids = values.map((item, index) => validateAcquisitionItem(item, `Acquisition evidence review ${key}[${index}]`));
874
+ if (!sameValues(ids, expected))
875
+ fail(`Acquisition evidence review ${key}`);
876
+ }
877
+ strings(evidence.nextSteps, 'Acquisition next steps', 6, 600);
878
+ strings(evidence.limitations, 'Acquisition evidence limitations', 12, 600);
879
+ strings(root.limitations, 'Acquisition limitations', 8, 600);
880
+ validateIntegrity(root.integrity, 'Acquisition integrity', root.version, 1, ACQUISITION_DECISION_PACKET_VERSION);
881
+ }
882
+ const CLAIM_PASSPORT_SOURCE_STATES = ['complete', 'not_found', 'partial', 'skipped', 'unavailable', 'unknown', 'unsupported'];
883
+ function validateClaimPassportTarget(type, value) {
884
+ const candidate = text(value, 'Claim passport target', 253);
885
+ if (type === 'domain') {
886
+ domain(candidate, 'Claim passport target');
887
+ return;
888
+ }
889
+ if (type === 'ipv4') {
890
+ const parts = candidate.split('.');
891
+ if (parts.length !== 4 || parts.some((part) => !/^\d{1,3}$/u.test(part) || Number(part) > 255 || String(Number(part)) !== part))
892
+ fail('Claim passport target');
893
+ return;
894
+ }
895
+ if (type === 'ipv6') {
896
+ if (!candidate.includes(':') || /[\[\]%/?#@]/u.test(candidate))
897
+ fail('Claim passport target');
898
+ try {
899
+ const hostname = new URL(`http://[${candidate}]/`).hostname;
900
+ const normalized = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
901
+ if (normalized !== candidate)
902
+ fail('Claim passport target');
903
+ }
904
+ catch {
905
+ fail('Claim passport target');
906
+ }
907
+ return;
908
+ }
909
+ if (!/^AS(?:[1-9]\d{0,9})$/u.test(candidate) || Number(candidate.slice(2)) > 4_294_967_295)
910
+ fail('Claim passport target');
911
+ }
912
+ function validateLookupClaimPassport(value) {
913
+ const root = exact(value, ['schema', 'version', 'generatedAt', 'application', 'target', 'observation', 'claim', 'models', 'limitations', 'integrity'], 'Lookup claim passport');
914
+ if (root.version !== LOOKUP_CLAIM_PASSPORT_VERSION)
915
+ fail('Lookup claim passport');
916
+ iso(root.generatedAt, 'Lookup claim passport generatedAt');
917
+ const application = exact(root.application, ['name', 'version'], 'Lookup claim passport application');
918
+ if (application.name !== 'WHOISleuth' || typeof application.version !== 'string'
919
+ || !/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(application.version))
920
+ fail('Lookup claim passport application');
921
+ const target = exact(root.target, ['type', 'value'], 'Lookup claim passport target');
922
+ const type = enumeration(target.type, LOOKUP_CLAIM_PASSPORT_TARGET_TYPES, 'Lookup claim passport target');
923
+ validateClaimPassportTarget(type, target.value);
924
+ const observation = exact(root.observation, ['observedAt', 'lookupDepth'], 'Lookup claim passport observation');
925
+ iso(observation.observedAt, 'Lookup claim passport observation', true);
926
+ enumeration(observation.lookupDepth, ['fast', 'deep'], 'Lookup claim passport observation');
927
+ const claim = exact(root.claim, ['id', 'label', 'state', 'conclusion', 'requiredEvidenceIds', 'missingEvidenceIds', 'requirements', 'limitations'], 'Lookup claim passport claim');
928
+ enumeration(claim.id, LOOKUP_CLAIM_IDS, 'Lookup claim passport claim');
929
+ text(claim.label, 'Lookup claim passport claim label', 160);
930
+ text(claim.conclusion, 'Lookup claim passport claim conclusion', 600);
931
+ const requirements = array(claim.requirements, 'Lookup claim passport requirements', MAX_LOOKUP_CLAIM_PASSPORT_REQUIREMENTS, 1);
932
+ const requirementIds = [];
933
+ const missingIds = [];
934
+ const seen = new Set();
935
+ let observedCount = 0;
936
+ let hasLimited = false;
937
+ for (const [index, candidate] of requirements.entries()) {
938
+ const item = exact(candidate, ['id', 'label', 'evidenceId', 'mode', 'state', 'observedAt', 'limitations'], `Lookup claim passport requirement ${index + 1}`);
939
+ const id = enumeration(item.id, LOOKUP_CLAIM_REQUIREMENT_IDS, 'Lookup claim passport requirement id');
940
+ if (seen.has(id))
941
+ fail('Lookup claim passport requirements');
942
+ seen.add(id);
943
+ requirementIds.push(id);
944
+ text(item.label, 'Lookup claim passport requirement label', 160);
945
+ if (item.evidenceId !== null && (typeof item.evidenceId !== 'string' || !/^[a-z][a-z0-9_-]{0,63}$/u.test(item.evidenceId)))
946
+ fail('Lookup claim passport evidence id');
947
+ enumeration(item.mode, ['network_collection', 'local_review'], 'Lookup claim passport requirement mode');
948
+ const state = enumeration(item.state, CLAIM_PASSPORT_SOURCE_STATES, 'Lookup claim passport requirement state');
949
+ if (state !== 'complete')
950
+ missingIds.push(id);
951
+ if (state !== 'skipped' && state !== 'unsupported' && state !== 'unknown')
952
+ observedCount += 1;
953
+ if (state === 'partial' || state === 'unavailable' || state === 'unknown')
954
+ hasLimited = true;
955
+ iso(item.observedAt, 'Lookup claim passport requirement observedAt', true);
956
+ if (item.evidenceId === null && item.observedAt !== null)
957
+ fail('Lookup claim passport requirement observedAt');
958
+ strings(item.limitations, 'Lookup claim passport requirement limitations', 8, 400);
959
+ }
960
+ const required = strings(claim.requiredEvidenceIds, 'Lookup claim passport required evidence', MAX_LOOKUP_CLAIM_PASSPORT_REQUIREMENTS, 64);
961
+ const missing = strings(claim.missingEvidenceIds, 'Lookup claim passport missing evidence', MAX_LOOKUP_CLAIM_PASSPORT_REQUIREMENTS, 64);
962
+ if (!sameValues(required, requirementIds) || !sameValues(missing, missingIds))
963
+ fail('Lookup claim passport evidence identifiers');
964
+ const expectedState = missingIds.length === 0 ? 'ready' : observedCount > 0 && hasLimited ? 'limited' : 'not_ready';
965
+ if (claim.state !== expectedState)
966
+ fail('Lookup claim passport claim state');
967
+ strings(claim.limitations, 'Lookup claim passport claim limitations', MAX_LOOKUP_CLAIM_PASSPORT_LIMITATIONS, 400);
968
+ const models = exact(root.models, ['claimReadiness', 'risk'], 'Lookup claim passport models');
969
+ if (models.claimReadiness !== LOOKUP_CLAIM_READINESS_VERSION)
970
+ fail('Lookup claim passport models');
971
+ if (models.risk !== null)
972
+ integer(models.risk, 'Lookup claim passport Risk model', 1, 1_000);
973
+ strings(root.limitations, 'Lookup claim passport limitations', MAX_LOOKUP_CLAIM_PASSPORT_LIMITATIONS, 600);
974
+ validateIntegrity(root.integrity, 'Lookup claim passport integrity', root.version, LOOKUP_CLAIM_PASSPORT_VERSION, LOOKUP_CLAIM_PASSPORT_VERSION);
975
+ }
976
+ const COMPARISON_STATES = ['conflicting', 'different', 'equal', 'missing', 'not_recorded', 'unavailable'];
977
+ const SOURCE_STATES = ['complete', 'error', 'not_found', 'not_recorded', 'partial', 'skipped', 'unavailable', 'unsupported'];
978
+ const DOMAIN_COMPARISON_ROW_IDS = [
979
+ 'registration', 'registrar', 'created', 'expires', 'nameservers', 'dns-a', 'dns-aaaa', 'dns-cname',
980
+ 'dns-caa', 'dnssec', 'ip-addresses', 'tls-source', 'certificate', 'tls-issuer', 'tls-spki', 'mail',
981
+ 'null-mx', 'spf', 'dmarc', 'website', 'page-title', 'favicon', 'tracking', 'password-field',
982
+ 'phishing-language', 'official-assets', 'technology', 'source-health',
983
+ ];
984
+ function validateDomainComparison(value) {
985
+ const root = exact(value, ['schema', 'version', 'generatedAt', 'comparison', 'integrity'], 'Domain comparison artefact');
986
+ iso(root.generatedAt, 'Domain comparison generatedAt');
987
+ const comparison = exact(root.comparison, ['version', 'leftDomain', 'rightDomain', 'observedAt', 'freshness', 'rows', 'counts', 'limitations'], 'Domain comparison');
988
+ if (comparison.version !== 3)
989
+ fail('Domain comparison');
990
+ domain(comparison.leftDomain, 'Domain comparison left domain');
991
+ domain(comparison.rightDomain, 'Domain comparison right domain');
992
+ iso(comparison.observedAt, 'Domain comparison observedAt', true);
993
+ const freshness = exact(comparison.freshness, ['state', 'ageDays'], 'Domain comparison freshness');
994
+ enumeration(freshness.state, ['current', 'stale', 'unknown'], 'Domain comparison freshness');
995
+ if (freshness.ageDays !== null)
996
+ integer(freshness.ageDays, 'Domain comparison age', 0, 1_000_000);
997
+ const rows = array(comparison.rows, 'Domain comparison rows', DOMAIN_COMPARISON_ROW_IDS.length, DOMAIN_COMPARISON_ROW_IDS.length);
998
+ const counts = exact(comparison.counts, [...COMPARISON_STATES], 'Domain comparison counts');
999
+ const actual = new Map(COMPARISON_STATES.map((state) => [state, 0]));
1000
+ for (const [index, candidate] of rows.entries()) {
1001
+ const row = exact(candidate, ['id', 'category', 'label', 'left', 'right', 'state', 'method', 'source', 'leftSourceState', 'rightSourceState', 'observedAt', 'leftEvidenceHref', 'rightEvidenceHref', 'limitations'], `Domain comparison row ${index + 1}`);
1002
+ const id = text(row.id, 'Domain comparison row id', 80);
1003
+ if (id !== DOMAIN_COMPARISON_ROW_IDS[index])
1004
+ fail('Domain comparison row order');
1005
+ enumeration(row.category, ['certificate', 'dns', 'identity', 'infrastructure', 'lifecycle', 'mail', 'registration', 'source', 'technology', 'web'], 'Domain comparison category');
1006
+ text(row.label, 'Domain comparison label', 120);
1007
+ text(row.left, 'Domain comparison left value', 2_000);
1008
+ text(row.right, 'Domain comparison right value', 2_000);
1009
+ const state = enumeration(row.state, COMPARISON_STATES, 'Domain comparison state');
1010
+ actual.set(state, (actual.get(state) ?? 0) + 1);
1011
+ text(row.method, 'Domain comparison method', 320);
1012
+ text(row.source, 'Domain comparison source', 80);
1013
+ enumeration(row.leftSourceState, SOURCE_STATES, 'Domain comparison left source state');
1014
+ enumeration(row.rightSourceState, SOURCE_STATES, 'Domain comparison right source state');
1015
+ iso(row.observedAt, 'Domain comparison row observedAt', true);
1016
+ if (row.observedAt !== comparison.observedAt)
1017
+ fail('Domain comparison row observedAt');
1018
+ if (typeof row.leftEvidenceHref !== 'string' || !/^(?:|#bulk-result-\d{1,4})$/u.test(row.leftEvidenceHref))
1019
+ fail('Domain comparison evidence link');
1020
+ if (typeof row.rightEvidenceHref !== 'string' || !/^(?:|#bulk-result-\d{1,4})$/u.test(row.rightEvidenceHref))
1021
+ fail('Domain comparison evidence link');
1022
+ strings(row.limitations, 'Domain comparison row limitations', 6, 320);
1023
+ }
1024
+ for (const state of COMPARISON_STATES) {
1025
+ if (integer(counts[state], `Domain comparison ${state} count`, 0, rows.length) !== actual.get(state))
1026
+ fail('Domain comparison counts');
1027
+ }
1028
+ strings(comparison.limitations, 'Domain comparison limitations', 12, 600);
1029
+ validateIntegrity(root.integrity, 'Domain comparison integrity', root.version, 3, BULK_DOMAIN_COMPARISON_EXPORT_VERSION);
1030
+ }
1031
+ const MAIL_STATES = ['authenticated_mail', 'evidence_incomplete', 'mail_auth_gap', 'mail_auth_incomplete', 'no_explicit_mx', 'null_mx'];
1032
+ function validateSourceCoverage(value, label) {
1033
+ const source = exact(value, ['source', 'state'], label);
1034
+ if (typeof source.source !== 'string' || !/^[a-z][a-z0-9_-]{0,39}$/u.test(source.source))
1035
+ fail(label);
1036
+ enumeration(source.state, SOURCE_STATES.filter((state) => state !== 'not_recorded'), label);
1037
+ }
1038
+ function validateProfileContext(value, label) {
1039
+ const context = exact(value, ['sourceState', 'activeProfileId', 'profileUpdatedAt', 'limitation'], label);
1040
+ enumeration(context.sourceState, ['mixed', 'ready', 'unavailable'], label);
1041
+ optionalText(context.activeProfileId, label, 128);
1042
+ iso(context.profileUpdatedAt, label, true);
1043
+ text(context.limitation, label, 300, true);
1044
+ }
1045
+ function validateMailExposure(value) {
1046
+ const root = exact(value, ['schema', 'version', 'report', 'integrity'], 'Bulk mail exposure artefact');
1047
+ const report = exact(root.report, ['version', 'generatedAt', 'observedAt', 'baseline', 'rows', 'counts', 'profileContextUnevaluatedCount', 'limitations'], 'Bulk mail exposure report');
1048
+ if (report.version !== 1)
1049
+ fail('Bulk mail exposure report');
1050
+ iso(report.generatedAt, 'Bulk mail exposure generatedAt');
1051
+ iso(report.observedAt, 'Bulk mail exposure observedAt', true);
1052
+ const baseline = exact(report.baseline, ['profile', 'officialDomains', 'label', 'limitations'], 'Bulk mail exposure baseline');
1053
+ if (baseline.profile !== null)
1054
+ enumeration(baseline.profile, ['defensive_no_mail', 'parked', 'standard'], 'Bulk mail exposure baseline');
1055
+ const officialDomains = strings(baseline.officialDomains, 'Bulk mail exposure official domains', 20, 253);
1056
+ officialDomains.forEach((item) => domain(item, 'Bulk mail exposure official domain'));
1057
+ text(baseline.label, 'Bulk mail exposure baseline label', 200);
1058
+ strings(baseline.limitations, 'Bulk mail exposure baseline limitations', 8, 600);
1059
+ const rows = array(report.rows, 'Bulk mail exposure rows', MAX_BULK_MAIL_EXPOSURE_ROWS);
1060
+ const counts = exact(report.counts, [...MAIL_STATES], 'Bulk mail exposure counts');
1061
+ const actual = new Map(MAIL_STATES.map((state) => [state, 0]));
1062
+ let unevaluated = 0;
1063
+ for (const [index, candidate] of rows.entries()) {
1064
+ const row = exact(candidate, ['domain', 'state', 'label', 'detail', 'baselineRelation', 'baselineDetail', 'mutationTypes', 'registration', 'sourceCoverage', 'profileContextState', 'profileContextLimitation', 'limitations'], `Bulk mail exposure row ${index + 1}`);
1065
+ domain(row.domain, 'Bulk mail exposure row domain');
1066
+ const state = enumeration(row.state, MAIL_STATES, 'Bulk mail exposure row state');
1067
+ actual.set(state, (actual.get(state) ?? 0) + 1);
1068
+ text(row.label, 'Bulk mail exposure row label', 200);
1069
+ text(row.detail, 'Bulk mail exposure row detail', 600);
1070
+ enumeration(row.baselineRelation, ['aligned', 'inconclusive', 'review'], 'Bulk mail exposure baseline relation');
1071
+ text(row.baselineDetail, 'Bulk mail exposure baseline detail', 600);
1072
+ strings(row.mutationTypes, 'Bulk mail exposure mutation types', 40, 120);
1073
+ text(row.registration, 'Bulk mail exposure registration', 160, true);
1074
+ array(row.sourceCoverage, 'Bulk mail exposure source coverage', 12).forEach((item, sourceIndex) => validateSourceCoverage(item, `Bulk mail exposure source ${sourceIndex + 1}`));
1075
+ const profileState = enumeration(row.profileContextState, ['mixed', 'ready', 'unavailable'], 'Bulk mail exposure profile context state');
1076
+ if (profileState !== 'ready')
1077
+ unevaluated += 1;
1078
+ text(row.profileContextLimitation, 'Bulk mail exposure profile context limitation', 300, true);
1079
+ strings(row.limitations, 'Bulk mail exposure row limitations', 6, 600);
1080
+ }
1081
+ for (const state of MAIL_STATES) {
1082
+ if (integer(counts[state], `Bulk mail exposure ${state} count`, 0, rows.length) !== actual.get(state))
1083
+ fail('Bulk mail exposure counts');
1084
+ }
1085
+ const declaredUnevaluated = integer(report.profileContextUnevaluatedCount, 'Bulk mail exposure unevaluated count', 0, rows.length);
1086
+ if (declaredUnevaluated < unevaluated)
1087
+ fail('Bulk mail exposure unevaluated count');
1088
+ strings(report.limitations, 'Bulk mail exposure limitations', 12, 600);
1089
+ validateIntegrity(root.integrity, 'Bulk mail exposure integrity', root.version, 1, BULK_MAIL_EXPOSURE_EXPORT_VERSION);
1090
+ }
1091
+ function validateBulkView(value, label) {
1092
+ const view = exact(value, ['primaryFilter', 'mutationFilter', 'signalFilters', 'sourceFilter', 'lifecycleFilter', 'ageFilter', 'mailFilter', 'registrarFilter', 'caseDispositionFilter', 'reviewStateFilter', 'groupBy', 'sortKey', 'sortDirection'], label);
1093
+ text(view.primaryFilter, label, 60);
1094
+ text(view.mutationFilter, label, 60, true);
1095
+ strings(view.signalFilters, label, 5, 40);
1096
+ text(view.sourceFilter, label, 60, true);
1097
+ text(view.lifecycleFilter, label, 60, true);
1098
+ text(view.ageFilter, label, 60, true);
1099
+ text(view.mailFilter, label, 60, true);
1100
+ text(view.registrarFilter, label, 200, true);
1101
+ text(view.caseDispositionFilter, label, 60, true);
1102
+ enumeration(view.reviewStateFilter, ['', 'unreviewed', 'reviewing', 'reviewed', 'deferred'], label);
1103
+ text(view.groupBy, label, 60, true);
1104
+ enumeration(view.sortKey, ['domain', 'availability', 'risk', 'opportunity', 'activity', 'registrar', 'mutation'], label);
1105
+ if (view.sortDirection !== 1 && view.sortDirection !== -1)
1106
+ fail(label);
1107
+ }
1108
+ function validateBulkReviewManifest(value) {
1109
+ const root = exact(value, ['schema', 'version', 'generatedAt', 'observedAt', 'lookupProfile', 'selection', 'view', 'rows', 'limitations', 'integrity'], 'Bulk review manifest');
1110
+ iso(root.generatedAt, 'Bulk review manifest generatedAt');
1111
+ iso(root.observedAt, 'Bulk review manifest observedAt');
1112
+ enumeration(root.lookupProfile, ['deep', 'fast'], 'Bulk review manifest lookup profile');
1113
+ const selection = exact(root.selection, ['count', 'domains'], 'Bulk review manifest selection');
1114
+ const domains = strings(selection.domains, 'Bulk review manifest selected domains', 2_000, 253);
1115
+ domains.forEach((item) => domain(item, 'Bulk review manifest selected domain'));
1116
+ if (integer(selection.count, 'Bulk review manifest selection count', 0, 2_000) !== domains.length)
1117
+ fail('Bulk review manifest selection');
1118
+ validateBulkView(root.view, 'Bulk review manifest view');
1119
+ const rows = array(root.rows, 'Bulk review manifest rows', 2_000);
1120
+ for (const [index, candidate] of rows.entries()) {
1121
+ const row = exact(candidate, ['domain', 'reviewState', 'resultState', 'scanDepth', 'sourceCoverage', 'profileContext'], `Bulk review manifest row ${index + 1}`);
1122
+ domain(row.domain, 'Bulk review manifest row domain');
1123
+ enumeration(row.reviewState, ['unreviewed', 'reviewing', 'reviewed', 'deferred'], 'Bulk review manifest review state');
1124
+ enumeration(row.resultState, ['complete', 'error'], 'Bulk review manifest result state');
1125
+ enumeration(row.scanDepth, ['deep', 'fast'], 'Bulk review manifest scan depth');
1126
+ array(row.sourceCoverage, 'Bulk review manifest source coverage', 12).forEach((item, sourceIndex) => validateSourceCoverage(item, `Bulk review manifest source ${sourceIndex + 1}`));
1127
+ validateProfileContext(row.profileContext, 'Bulk review manifest profile context');
1128
+ if (domains[index] !== row.domain)
1129
+ fail('Bulk review manifest selection');
1130
+ }
1131
+ if (rows.length !== domains.length)
1132
+ fail('Bulk review manifest selection');
1133
+ strings(root.limitations, 'Bulk review manifest limitations', 8, 600);
1134
+ validateIntegrity(root.integrity, 'Bulk review manifest integrity', root.version, 1, BULK_REVIEW_MANIFEST_VERSION);
1135
+ }
1136
+ function validateInvestigationManifest(value) {
1137
+ const root = exact(value, ['schema', 'version', 'generatedAt', 'application', 'workflow', 'configuration', 'artifacts', 'steps', 'summary', 'limitations', 'integrity'], 'Investigation manifest');
1138
+ iso(root.generatedAt, 'Investigation manifest generatedAt');
1139
+ const application = exact(root.application, ['name', 'version'], 'Investigation manifest application');
1140
+ if (application.name !== 'WHOISleuth CLI' || typeof application.version !== 'string'
1141
+ || !/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(application.version))
1142
+ fail('Investigation manifest application');
1143
+ text(root.workflow, 'Investigation manifest workflow', 160);
1144
+ const configuration = exact(root.configuration, ['digestSha256'], 'Investigation manifest configuration');
1145
+ if (configuration.digestSha256 !== null)
1146
+ digest(configuration.digestSha256, 'Investigation manifest configuration');
1147
+ const artifacts = array(root.artifacts, 'Investigation manifest artifacts', MAX_INVESTIGATION_MANIFEST_ARTIFACTS, 1);
1148
+ const steps = array(root.steps, 'Investigation manifest steps', MAX_INVESTIGATION_MANIFEST_ARTIFACTS, 1);
1149
+ let totalBytes = 0;
1150
+ for (const [index, candidate] of artifacts.entries()) {
1151
+ const item = exact(candidate, ['sequence', 'id', 'schema', 'version', 'byteLength', 'contentDigestSha256', 'canonicalDigestSha256'], `Investigation manifest artifact ${index + 1}`);
1152
+ if (integer(item.sequence, 'Investigation manifest artifact sequence', 1, artifacts.length) !== index + 1
1153
+ || item.id !== `artifact-${index + 1}`)
1154
+ fail('Investigation manifest artifact order');
1155
+ if (item.schema !== null)
1156
+ text(item.schema, 'Investigation manifest artifact schema', 160);
1157
+ if (item.version !== null)
1158
+ integer(item.version, 'Investigation manifest artifact version', 1, 1_000);
1159
+ totalBytes += integer(item.byteLength, 'Investigation manifest artifact bytes', 1, 15 * 1024 * 1024);
1160
+ digest(item.contentDigestSha256, 'Investigation manifest content digest');
1161
+ digest(item.canonicalDigestSha256, 'Investigation manifest canonical digest');
1162
+ const step = exact(steps[index], ['sequence', 'artifactId', 'contentDigestSha256'], `Investigation manifest step ${index + 1}`);
1163
+ if (step.sequence !== item.sequence || step.artifactId !== item.id || step.contentDigestSha256 !== item.contentDigestSha256)
1164
+ fail('Investigation manifest step linkage');
1165
+ }
1166
+ const summary = exact(root.summary, ['artifactCount', 'totalBytes'], 'Investigation manifest summary');
1167
+ if (integer(summary.artifactCount, 'Investigation manifest artifact count', 1, MAX_INVESTIGATION_MANIFEST_ARTIFACTS) !== artifacts.length
1168
+ || integer(summary.totalBytes, 'Investigation manifest total bytes', 1, MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES) !== totalBytes)
1169
+ fail('Investigation manifest summary');
1170
+ strings(root.limitations, 'Investigation manifest limitations', 8, 600);
1171
+ validateIntegrity(root.integrity, 'Investigation manifest integrity', root.version, 1, INVESTIGATION_MANIFEST_VERSION);
1172
+ }
1173
+ function validateActionSummary(value, label) {
1174
+ const summary = exact(value, ['total', 'active', 'submitted', 'acknowledged', 'resolved', 'closed', 'overdue', 'followUpDue', 'withOutcome', 'latestOutcomes'], label);
1175
+ const total = integer(summary.total, label, 0, MAX_CASE_ACTIONS);
1176
+ for (const key of ['active', 'submitted', 'acknowledged', 'resolved', 'closed', 'overdue', 'followUpDue', 'withOutcome'])
1177
+ integer(summary[key], label, 0, total);
1178
+ if (Number(summary.resolved) + Number(summary.closed) + Number(summary.active) !== total)
1179
+ fail(label);
1180
+ const outcomes = array(summary.latestOutcomes, label, 5);
1181
+ for (const candidate of outcomes) {
1182
+ const outcome = exact(candidate, ['actionId', 'recipient', 'state', 'outcome', 'updatedAt'], label);
1183
+ text(outcome.actionId, label, 64);
1184
+ text(outcome.recipient, label, 320);
1185
+ enumeration(outcome.state, CASE_ACTION_STATES, label);
1186
+ text(outcome.outcome, label, 2_000);
1187
+ iso(outcome.updatedAt, label);
1188
+ }
1189
+ }
1190
+ const CASE_RESPONSE_PREFLIGHT_IDS = [
1191
+ 'required_incident_fields',
1192
+ 'evidence_pins',
1193
+ 'analyst_decision',
1194
+ 'recipient_route',
1195
+ 'profile_recipient',
1196
+ 'case_disposition',
1197
+ 'evidence_freshness',
1198
+ 'contradictory_evidence',
1199
+ 'action_tracking',
1200
+ ];
1201
+ function expectedObservationAge(observedAt, generatedAt) {
1202
+ const ageSeconds = Math.floor((Date.parse(generatedAt) - Date.parse(observedAt)) / 1_000);
1203
+ if (ageSeconds < -300)
1204
+ return { ageSeconds, band: 'future_or_clock_skew', refreshRecommended: true };
1205
+ if (ageSeconds < 86_400)
1206
+ return { ageSeconds: Math.max(0, ageSeconds), band: 'under_24_hours', refreshRecommended: false };
1207
+ if (ageSeconds <= 604_800)
1208
+ return { ageSeconds, band: 'one_to_seven_days', refreshRecommended: false };
1209
+ return { ageSeconds, band: 'over_seven_days', refreshRecommended: true };
1210
+ }
1211
+ function validateCaseResponsePacket(value) {
1212
+ const root = exact(value, ['schema', 'schemaVersion', 'generatedAt', 'reviewRequired', 'submissionPerformed', 'profile', 'case', 'incident', 'contacts', 'preflight', 'escalationHistory', 'provenance', 'integrity'], 'Case-response packet');
1213
+ if (root.schemaVersion !== LEGACY_CASE_RESPONSE_PACKET_VERSION && root.schemaVersion !== CASE_RESPONSE_PACKET_VERSION)
1214
+ fail('Case-response packet');
1215
+ iso(root.generatedAt, 'Case-response packet generatedAt');
1216
+ if (root.reviewRequired !== true || root.submissionPerformed !== false)
1217
+ fail('Case-response packet review state');
1218
+ const profile = exact(root.profile, ['id', 'label', 'audience', 'subject', 'checklist', 'evidenceOrder', 'includedEvidence', 'excludedEvidence', 'redactions', 'attachments', 'followUpFields'], 'Case-response profile');
1219
+ const profileId = enumeration(profile.id, RESPONSE_PACKET_PROFILE_IDS, 'Case-response profile id');
1220
+ text(profile.label, 'Case-response profile label', 200);
1221
+ text(profile.audience, 'Case-response profile audience', 300);
1222
+ text(profile.subject, 'Case-response profile subject', 500);
1223
+ for (const key of ['checklist', 'evidenceOrder', 'includedEvidence', 'excludedEvidence', 'redactions', 'attachments', 'followUpFields'])
1224
+ strings(profile[key], `Case-response profile ${key}`, 24, 500);
1225
+ const caseRecord = exact(root.case, ['id', 'domain', 'status', 'disposition', 'updatedAt'], 'Case-response case');
1226
+ text(caseRecord.id, 'Case-response case id', 128);
1227
+ domain(caseRecord.domain, 'Case-response case domain');
1228
+ text(caseRecord.status, 'Case-response case status', 80);
1229
+ text(caseRecord.disposition, 'Case-response case disposition', 80);
1230
+ iso(caseRecord.updatedAt, 'Case-response case updatedAt');
1231
+ const incident = exact(root.incident, ['category', 'affectedParty', 'abusiveUrls', 'observedHarm', 'observedAt'], 'Case-response incident');
1232
+ text(incident.category, 'Case-response category', 80);
1233
+ text(incident.affectedParty, 'Case-response affected party', 200);
1234
+ const urls = strings(incident.abusiveUrls, 'Case-response abusive URLs', MAX_ABUSIVE_URLS, 2_048);
1235
+ if (!urls.length || urls.some((item) => { try {
1236
+ return !['http:', 'https:'].includes(new URL(item).protocol);
1237
+ }
1238
+ catch {
1239
+ return true;
1240
+ } }))
1241
+ fail('Case-response abusive URLs');
1242
+ text(incident.observedHarm, 'Case-response observed harm', 2_000);
1243
+ iso(incident.observedAt, 'Case-response observedAt');
1244
+ const selectedProfile = RESPONSE_PACKET_PROFILES.find((candidate) => candidate.id === profileId);
1245
+ if (!selectedProfile
1246
+ || profile.label !== selectedProfile.label
1247
+ || profile.audience !== selectedProfile.audience
1248
+ || profile.subject !== `${selectedProfile.subjectPrefix}: ${caseRecord.domain} (${incident.category})`
1249
+ || !sameValues(profile.checklist, selectedProfile.checklist)
1250
+ || !sameValues(profile.evidenceOrder, selectedProfile.evidenceOrder)
1251
+ || !sameValues(profile.includedEvidence, selectedProfile.includedEvidence)
1252
+ || !sameValues(profile.excludedEvidence, selectedProfile.excludedEvidence)
1253
+ || !sameValues(profile.redactions, selectedProfile.redactions)
1254
+ || !sameValues(profile.attachments, selectedProfile.attachments)
1255
+ || !sameValues(profile.followUpFields, selectedProfile.followUpFields))
1256
+ fail('Case-response profile');
1257
+ const canonicalUrls = urls.map((item) => {
1258
+ try {
1259
+ const parsed = new URL(item);
1260
+ return ['http:', 'https:'].includes(parsed.protocol) && !parsed.username && !parsed.password
1261
+ ? parsed.toString()
1262
+ : null;
1263
+ }
1264
+ catch {
1265
+ return null;
1266
+ }
1267
+ });
1268
+ if (canonicalUrls.some((item, index) => item === null || item !== urls[index])
1269
+ || new Set(urls).size !== urls.length)
1270
+ fail('Case-response abusive URLs');
1271
+ const contacts = array(root.contacts, 'Case-response contacts', MAX_RESPONSE_CONTACTS);
1272
+ const contactKeys = new Set();
1273
+ for (const candidate of contacts) {
1274
+ const contact = exact(candidate, ['kind', 'contact', 'source', 'limitations'], 'Case-response contact');
1275
+ const kind = enumeration(contact.kind, RESPONSE_CONTACT_KINDS, 'Case-response contact kind');
1276
+ const contactValue = text(contact.contact, 'Case-response contact value', MAX_RESPONSE_RECIPIENT_LENGTH);
1277
+ const contactKey = `${kind}\u0000${contactValue.toLowerCase()}`;
1278
+ if (contactKeys.has(contactKey))
1279
+ fail('Case-response contacts');
1280
+ contactKeys.add(contactKey);
1281
+ text(contact.source, 'Case-response contact source', MAX_RESPONSE_LABEL_LENGTH);
1282
+ strings(contact.limitations, 'Case-response contact limitations', MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH);
1283
+ }
1284
+ const preflight = exact(root.preflight, ['version', 'status', 'canExport', 'counts', 'checks', 'actionSummary'], 'Case-response preflight');
1285
+ if (preflight.version !== 1)
1286
+ fail('Case-response preflight');
1287
+ const checks = array(preflight.checks, 'Case-response preflight checks', CASE_RESPONSE_PREFLIGHT_IDS.length, CASE_RESPONSE_PREFLIGHT_IDS.length);
1288
+ const actualCounts = { block: 0, caution: 0, pass: 0 };
1289
+ for (const [index, candidate] of checks.entries()) {
1290
+ const check = exact(candidate, ['id', 'label', 'state', 'detail'], 'Case-response preflight check');
1291
+ const id = text(check.id, 'Case-response preflight id', 80);
1292
+ if (id !== CASE_RESPONSE_PREFLIGHT_IDS[index])
1293
+ fail('Case-response preflight check order');
1294
+ text(check.label, 'Case-response preflight label', 200);
1295
+ const state = enumeration(check.state, ['block', 'caution', 'pass'], 'Case-response preflight state');
1296
+ actualCounts[state] += 1;
1297
+ text(check.detail, 'Case-response preflight detail', 1_000);
1298
+ }
1299
+ const counts = exact(preflight.counts, ['block', 'caution', 'pass'], 'Case-response preflight counts');
1300
+ for (const state of ['block', 'caution', 'pass']) {
1301
+ if (integer(counts[state], 'Case-response preflight count', 0, checks.length) !== actualCounts[state])
1302
+ fail('Case-response preflight counts');
1303
+ }
1304
+ const expectedStatus = actualCounts.block ? 'needs_input' : actualCounts.caution ? 'review_cautions' : 'ready_for_review';
1305
+ if (preflight.status !== expectedStatus || preflight.canExport !== (actualCounts.block === 0))
1306
+ fail('Case-response preflight status');
1307
+ validateActionSummary(preflight.actionSummary, 'Case-response action summary');
1308
+ const history = array(root.escalationHistory, 'Case-response escalation history', MAX_RESPONSE_ACTION_HISTORY);
1309
+ for (const candidate of history) {
1310
+ const action = exact(candidate, ['type', 'recipient', 'contactSource', 'state', 'reference', 'outcome', 'createdAt', 'updatedAt'], 'Case-response escalation action');
1311
+ enumeration(action.type, CASE_ACTION_TYPES, 'Case-response action type');
1312
+ text(action.recipient, 'Case-response action recipient', 320, true);
1313
+ text(action.contactSource, 'Case-response action source', 120, true);
1314
+ enumeration(action.state, CASE_ACTION_STATES, 'Case-response action state');
1315
+ optionalText(action.reference, 'Case-response action reference', 500);
1316
+ optionalText(action.outcome, 'Case-response action outcome', 2_000);
1317
+ iso(action.createdAt, 'Case-response action createdAt');
1318
+ iso(action.updatedAt, 'Case-response action updatedAt');
1319
+ }
1320
+ const provenance = exact(root.provenance, ['latestEvidenceCapturedAt', 'evidencePinCount', 'decisionCount', 'assertionCount', 'observationAge', 'limitations'], 'Case-response provenance');
1321
+ iso(provenance.latestEvidenceCapturedAt, 'Case-response evidence time', true);
1322
+ integer(provenance.evidencePinCount, 'Case-response evidence pin count', 0, MAX_CASE_EVIDENCE_PINS);
1323
+ integer(provenance.decisionCount, 'Case-response decision count', 0, MAX_CASE_DECISIONS);
1324
+ integer(provenance.assertionCount, 'Case-response assertion count', 0, MAX_CASE_ASSERTIONS);
1325
+ const age = exact(provenance.observationAge, ['ageSeconds', 'band', 'refreshRecommended'], 'Case-response observation age');
1326
+ integer(age.ageSeconds, 'Case-response observation age seconds', Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER);
1327
+ enumeration(age.band, ['future_or_clock_skew', 'one_to_seven_days', 'over_seven_days', 'under_24_hours'], 'Case-response observation age band');
1328
+ boolean(age.refreshRecommended, 'Case-response refresh recommendation');
1329
+ const expectedAge = expectedObservationAge(incident.observedAt, root.generatedAt);
1330
+ if (age.ageSeconds !== expectedAge.ageSeconds || age.band !== expectedAge.band
1331
+ || age.refreshRecommended !== expectedAge.refreshRecommended)
1332
+ fail('Case-response observation age');
1333
+ strings(provenance.limitations, 'Case-response limitations', 8, 600);
1334
+ const integrity = exact(root.integrity, ['algorithm', 'canonicalization', 'scope', 'digestSha256'], 'Case-response integrity');
1335
+ if (integrity.algorithm !== 'SHA-256'
1336
+ || integrity.canonicalization !== (root.schemaVersion === CASE_RESPONSE_PACKET_VERSION ? 'sorted-json-v2' : 'sorted-json-v1')
1337
+ || integrity.scope !== 'packet excluding integrity')
1338
+ fail('Case-response integrity');
1339
+ if (typeof integrity.digestSha256 !== 'string' || !HEX_DIGEST_RE.test(integrity.digestSha256))
1340
+ fail('Case-response integrity');
1341
+ }
1342
+ function validateBriefFact(value, label) {
1343
+ const fact = exact(value, ['label', 'value', 'detail', 'provenance'], label);
1344
+ text(fact.label, label, 320);
1345
+ text(fact.value, label, 2_000);
1346
+ text(fact.detail, label, 1_000, true);
1347
+ const provenance = exact(fact.provenance, ['sources', 'observedAt', 'fieldFamilies', 'normalization', 'completeness', 'limitations', 'conflicts', 'decisionImpact'], label);
1348
+ strings(provenance.sources, label, 8, 320);
1349
+ text(provenance.observedAt, label, 64);
1350
+ strings(provenance.fieldFamilies, label, 8, 320);
1351
+ text(provenance.normalization, label, 320);
1352
+ text(provenance.completeness, label, 320);
1353
+ strings(provenance.limitations, label, 8, 320);
1354
+ strings(provenance.conflicts, label, 8, 320);
1355
+ text(provenance.decisionImpact, label, 320);
1356
+ }
1357
+ function validateDecisionEntry(value, label) {
1358
+ const entry = exact(value, ['id', 'state', 'importance', 'title', 'detail', 'sources', 'href'], label);
1359
+ text(entry.id, label, 80);
1360
+ enumeration(entry.state, ['conflict', 'uncertain'], label);
1361
+ enumeration(entry.importance, ['high', 'medium', 'low'], label);
1362
+ text(entry.title, label, 320);
1363
+ text(entry.detail, label, 1_000);
1364
+ strings(entry.sources, label, 12, 320);
1365
+ if (typeof entry.href !== 'string' || !/^#[^\u0000-\u001f\u007f]{1,160}$/u.test(entry.href))
1366
+ fail(label);
1367
+ }
1368
+ function validateBrief(value) {
1369
+ const brief = exact(value, ['schema', 'schemaVersion', 'generatedAt', 'target', 'targetType', 'task', 'taskLabel', 'question', 'summary', 'observation', 'verifiedFacts', 'contradictions', 'unknowns', 'nextActions', 'relationships', 'limitations'], 'Investigation capsule brief');
1370
+ if (brief.schema !== LOOKUP_INVESTIGATION_BRIEF_SCHEMA || brief.schemaVersion !== 1)
1371
+ fail('Investigation capsule brief');
1372
+ iso(brief.generatedAt, 'Investigation capsule brief generatedAt');
1373
+ text(brief.target, 'Investigation capsule brief target', 253);
1374
+ text(brief.targetType, 'Investigation capsule brief target type', 40);
1375
+ enumeration(brief.task, ['general', 'acquisition', 'brand', 'incident', 'owned'], 'Investigation capsule brief task');
1376
+ text(brief.taskLabel, 'Investigation capsule brief task label', 320);
1377
+ text(brief.question, 'Investigation capsule brief question', 320);
1378
+ text(brief.summary, 'Investigation capsule brief summary', 500);
1379
+ const observation = exact(brief.observation, ['observedAt', 'evidenceAgeDays', 'completeSources', 'limitedSources', 'freshnessPolicy'], 'Investigation capsule observation');
1380
+ iso(observation.observedAt, 'Investigation capsule observation time', true);
1381
+ if (observation.evidenceAgeDays !== null)
1382
+ integer(observation.evidenceAgeDays, 'Investigation capsule evidence age', 0, 1_000_000);
1383
+ integer(observation.completeSources, 'Investigation capsule complete sources', 0, 100);
1384
+ integer(observation.limitedSources, 'Investigation capsule limited sources', 0, 100);
1385
+ const policy = exact(observation.freshnessPolicy, ['version', 'id', 'task', 'thresholdsDays'], 'Investigation capsule freshness policy');
1386
+ if (policy.version !== 1)
1387
+ fail('Investigation capsule freshness policy');
1388
+ enumeration(policy.id, ['task-default', 'analyst-custom'], 'Investigation capsule freshness policy');
1389
+ if (policy.task !== brief.task)
1390
+ fail('Investigation capsule freshness policy');
1391
+ const thresholds = exact(policy.thresholdsDays, ['registration', 'network', 'web'], 'Investigation capsule freshness thresholds');
1392
+ for (const key of ['registration', 'network', 'web'])
1393
+ integer(thresholds[key], 'Investigation capsule freshness threshold', 0, 3650);
1394
+ array(brief.verifiedFacts, 'Investigation capsule verified facts', 12).forEach((item, index) => validateBriefFact(item, `Investigation capsule fact ${index + 1}`));
1395
+ array(brief.contradictions, 'Investigation capsule contradictions', 24).forEach((item, index) => validateDecisionEntry(item, `Investigation capsule contradiction ${index + 1}`));
1396
+ array(brief.unknowns, 'Investigation capsule unknowns', 24).forEach((item, index) => validateDecisionEntry(item, `Investigation capsule unknown ${index + 1}`));
1397
+ for (const [index, candidate] of array(brief.nextActions, 'Investigation capsule next actions', 6).entries()) {
1398
+ const action = exact(candidate, ['id', 'label', 'reason', 'expectedOutcome', 'href', 'priority'], `Investigation capsule next action ${index + 1}`);
1399
+ text(action.id, 'Investigation capsule next action id', 80);
1400
+ text(action.label, 'Investigation capsule next action label', 320);
1401
+ text(action.reason, 'Investigation capsule next action reason', 500);
1402
+ text(action.expectedOutcome, 'Investigation capsule next action outcome', 500);
1403
+ if (typeof action.href !== 'string' || !/^#[^\u0000-\u001f\u007f]{1,160}$/u.test(action.href))
1404
+ fail('Investigation capsule next action href');
1405
+ enumeration(action.priority, ['high', 'medium', 'low'], 'Investigation capsule next action priority');
1406
+ }
1407
+ const relationships = exact(brief.relationships, ['nodes', 'edges', 'truncated', 'kinds'], 'Investigation capsule relationship summary');
1408
+ integer(relationships.nodes, 'Investigation capsule relationship node count', 0, 72);
1409
+ integer(relationships.edges, 'Investigation capsule relationship edge count', 0, 120);
1410
+ boolean(relationships.truncated, 'Investigation capsule relationship truncation');
1411
+ strings(relationships.kinds, 'Investigation capsule relationship kinds', 12, 320);
1412
+ strings(brief.limitations, 'Investigation capsule brief limitations', 20, 320);
1413
+ }
1414
+ function validateGraph(value) {
1415
+ const graph = exact(value, ['version', 'targetId', 'nodes', 'edges', 'sources', 'truncated', 'limitations'], 'Investigation capsule graph');
1416
+ if (graph.version !== 2)
1417
+ fail('Investigation capsule graph');
1418
+ text(graph.targetId, 'Investigation capsule graph target', 160);
1419
+ const nodes = array(graph.nodes, 'Investigation capsule graph nodes', 72, 1);
1420
+ const nodeIds = new Set();
1421
+ for (const candidate of nodes) {
1422
+ const node = exact(candidate, ['id', 'label', 'kind', 'detail'], 'Investigation capsule graph node');
1423
+ const id = text(node.id, 'Investigation capsule graph node id', 160);
1424
+ if (nodeIds.has(id))
1425
+ fail('Investigation capsule graph node ids');
1426
+ nodeIds.add(id);
1427
+ text(node.label, 'Investigation capsule graph node label', 320);
1428
+ enumeration(node.kind, ['address', 'certificate', 'hostname', 'identity', 'issuer', 'key', 'network', 'observation', 'origin', 'prefix', 'registrar', 'target', 'tracker'], 'Investigation capsule graph node kind');
1429
+ text(node.detail, 'Investigation capsule graph node detail', 500, true);
1430
+ }
1431
+ if (!nodeIds.has(graph.targetId))
1432
+ fail('Investigation capsule graph target');
1433
+ const sourceIds = new Set();
1434
+ for (const candidate of array(graph.sources, 'Investigation capsule graph sources', 32)) {
1435
+ const source = exact(candidate, ['id', 'label', 'href', 'observedAt', 'completeness', 'limitations'], 'Investigation capsule graph source');
1436
+ const id = text(source.id, 'Investigation capsule graph source id', 160);
1437
+ if (sourceIds.has(id))
1438
+ fail('Investigation capsule graph source ids');
1439
+ sourceIds.add(id);
1440
+ text(source.label, 'Investigation capsule graph source label', 320);
1441
+ if (typeof source.href !== 'string' || !/^#[^\u0000-\u001f\u007f]{1,160}$/u.test(source.href))
1442
+ fail('Investigation capsule graph source href');
1443
+ iso(source.observedAt, 'Investigation capsule graph source observedAt', true);
1444
+ enumeration(source.completeness, ['complete', 'partial', 'unknown'], 'Investigation capsule graph source completeness');
1445
+ strings(source.limitations, 'Investigation capsule graph source limitations', 5, 320);
1446
+ }
1447
+ const edges = array(graph.edges, 'Investigation capsule graph edges', 120);
1448
+ const edgeIds = new Set();
1449
+ for (const candidate of edges) {
1450
+ const edge = exactOptional(candidate, ['id', 'sourceId', 'source', 'target', 'kind', 'label', 'sourceLabel', 'observedAt', 'completeness', 'limitations', 'lenses', 'href'], ['boundary'], 'Investigation capsule graph edge');
1451
+ const id = text(edge.id, 'Investigation capsule graph edge id', 160);
1452
+ if (edgeIds.has(id))
1453
+ fail('Investigation capsule graph edge ids');
1454
+ edgeIds.add(id);
1455
+ const sourceId = text(edge.sourceId, 'Investigation capsule graph edge source id', 160);
1456
+ if (!sourceIds.has(sourceId))
1457
+ fail('Investigation capsule graph edge source');
1458
+ if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target))
1459
+ fail('Investigation capsule graph edge endpoints');
1460
+ text(edge.kind, 'Investigation capsule graph edge kind', 160);
1461
+ text(edge.label, 'Investigation capsule graph edge label', 320);
1462
+ text(edge.sourceLabel, 'Investigation capsule graph edge source label', 320);
1463
+ iso(edge.observedAt, 'Investigation capsule graph edge observedAt', true);
1464
+ enumeration(edge.completeness, ['complete', 'partial', 'unknown'], 'Investigation capsule graph edge completeness');
1465
+ strings(edge.limitations, 'Investigation capsule graph edge limitations', 5, 320);
1466
+ strings(edge.lenses, 'Investigation capsule graph edge lenses', 4, 40).forEach((lens) => enumeration(lens, ['all', 'identity', 'delegation', 'certificate'], 'Investigation capsule graph edge lens'));
1467
+ if (typeof edge.href !== 'string' || !/^#[^\u0000-\u001f\u007f]{1,160}$/u.test(edge.href))
1468
+ fail('Investigation capsule graph edge href');
1469
+ if (edge.boundary !== undefined)
1470
+ enumeration(edge.boundary, ['external', 'reviewed_profile', 'same_origin', 'same_registrable_domain', 'unresolved'], 'Investigation capsule graph edge boundary');
1471
+ }
1472
+ boolean(graph.truncated, 'Investigation capsule graph truncation');
1473
+ strings(graph.limitations, 'Investigation capsule graph limitations', 20, 320);
1474
+ }
1475
+ function validateAnalystRecords(value) {
1476
+ if (value === null)
1477
+ return;
1478
+ const records = exact(value, ['caseId', 'status', 'disposition', 'decisions', 'assertions'], 'Investigation capsule analyst records');
1479
+ text(records.caseId, 'Investigation capsule case id', 128);
1480
+ text(records.status, 'Investigation capsule case status', 80);
1481
+ text(records.disposition, 'Investigation capsule case disposition', 80);
1482
+ for (const candidate of array(records.decisions, 'Investigation capsule decisions', MAX_CASE_DECISIONS)) {
1483
+ const decision = exact(candidate, ['id', 'summary', 'rationale', 'evidencePinIds', 'createdAt'], 'Investigation capsule decision');
1484
+ text(decision.id, 'Investigation capsule decision id', 64);
1485
+ text(decision.summary, 'Investigation capsule decision summary', 500);
1486
+ text(decision.rationale, 'Investigation capsule decision rationale', 2_000, true);
1487
+ strings(decision.evidencePinIds, 'Investigation capsule decision evidence pins', MAX_DECISION_PIN_REFERENCES, 64);
1488
+ iso(decision.createdAt, 'Investigation capsule decision createdAt');
1489
+ }
1490
+ for (const candidate of array(records.assertions, 'Investigation capsule assertions', MAX_CASE_ASSERTIONS)) {
1491
+ const assertion = exact(candidate, ['id', 'kind', 'statement', 'rationale', 'evidencePinIds', 'state', 'createdAt', 'updatedAt'], 'Investigation capsule assertion');
1492
+ text(assertion.id, 'Investigation capsule assertion id', 64);
1493
+ text(assertion.kind, 'Investigation capsule assertion kind', 80);
1494
+ text(assertion.statement, 'Investigation capsule assertion statement', 2_000);
1495
+ optionalText(assertion.rationale, 'Investigation capsule assertion rationale', 2_000);
1496
+ strings(assertion.evidencePinIds, 'Investigation capsule assertion evidence pins', MAX_DECISION_PIN_REFERENCES, 64);
1497
+ text(assertion.state, 'Investigation capsule assertion state', 80);
1498
+ iso(assertion.createdAt, 'Investigation capsule assertion createdAt');
1499
+ iso(assertion.updatedAt, 'Investigation capsule assertion updatedAt');
1500
+ }
1501
+ }
1502
+ export function validateInvestigationCapsuleStructure(value) {
1503
+ const root = exact(value, ['schema', 'schemaVersion', 'generatedAt', 'application', 'target', 'sourceContracts', 'investigationBrief', 'graphSnapshot', 'analystRecords', 'integrity', 'limitations'], 'Investigation capsule');
1504
+ if (root.schemaVersion !== LEGACY_INVESTIGATION_CAPSULE_VERSION && root.schemaVersion !== INVESTIGATION_CAPSULE_VERSION)
1505
+ fail('Investigation capsule');
1506
+ iso(root.generatedAt, 'Investigation capsule generatedAt');
1507
+ const application = exact(root.application, ['name', 'version'], 'Investigation capsule application');
1508
+ if (application.name !== 'WHOISleuth' || typeof application.version !== 'string'
1509
+ || !/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(application.version))
1510
+ fail('Investigation capsule application');
1511
+ const target = exact(root.target, ['value', 'type'], 'Investigation capsule target');
1512
+ text(target.value, 'Investigation capsule target value', 253);
1513
+ text(target.type, 'Investigation capsule target type', 40);
1514
+ const contracts = array(root.sourceContracts, 'Investigation capsule source contracts', 4, 3);
1515
+ const byId = new Map();
1516
+ for (const candidate of contracts) {
1517
+ const contract = exact(candidate, ['id', 'schema', 'version', 'digest', 'embedded'], 'Investigation capsule source contract');
1518
+ const id = enumeration(contract.id, ['lookup-evidence', 'investigation-brief', 'asset-graph', 'analyst-records'], 'Investigation capsule source contract id');
1519
+ if (byId.has(id))
1520
+ fail('Investigation capsule source contracts');
1521
+ byId.set(id, contract);
1522
+ text(contract.schema, 'Investigation capsule source schema', 120);
1523
+ integer(contract.version, 'Investigation capsule source version', 0, 10_000);
1524
+ digest(contract.digest, 'Investigation capsule source digest');
1525
+ boolean(contract.embedded, 'Investigation capsule embedded source');
1526
+ }
1527
+ const expectedContractIds = root.analystRecords === null
1528
+ ? ['lookup-evidence', 'investigation-brief', 'asset-graph']
1529
+ : ['lookup-evidence', 'investigation-brief', 'asset-graph', 'analyst-records'];
1530
+ if (!sameValues(contracts.map((candidate) => candidate.id), expectedContractIds))
1531
+ fail('Investigation capsule source contracts');
1532
+ if (!byId.has('lookup-evidence') || !byId.has('investigation-brief') || !byId.has('asset-graph')
1533
+ || byId.get('lookup-evidence')?.embedded !== false
1534
+ || byId.get('investigation-brief')?.schema !== LOOKUP_INVESTIGATION_BRIEF_SCHEMA
1535
+ || byId.get('investigation-brief')?.version !== 1
1536
+ || byId.get('investigation-brief')?.embedded !== true
1537
+ || byId.get('asset-graph')?.schema !== 'whoisleuth.lookup-asset-graph'
1538
+ || byId.get('asset-graph')?.version !== 2
1539
+ || byId.get('asset-graph')?.embedded !== true
1540
+ || (byId.has('analyst-records') && (byId.get('analyst-records')?.schema !== 'whoisleuth.case-analyst-records'
1541
+ || byId.get('analyst-records')?.version !== 1
1542
+ || byId.get('analyst-records')?.embedded !== true)))
1543
+ fail('Investigation capsule source contracts');
1544
+ validateBrief(root.investigationBrief);
1545
+ validateGraph(root.graphSnapshot);
1546
+ validateAnalystRecords(root.analystRecords);
1547
+ const brief = root.investigationBrief;
1548
+ const graph = root.graphSnapshot;
1549
+ if (target.value !== brief.target || target.type !== brief.targetType
1550
+ || graph.targetId === undefined
1551
+ || brief.relationships.nodes !== graph.nodes.length
1552
+ || brief.relationships.edges !== graph.edges.length)
1553
+ fail('Investigation capsule projection linkage');
1554
+ const current = root.schemaVersion === INVESTIGATION_CAPSULE_VERSION;
1555
+ const integrity = exact(root.integrity, current
1556
+ ? ['algorithm', 'canonicalization', 'scope', 'briefDigest', 'graphDigest', 'analystRecordsDigest', 'digestSha256']
1557
+ : ['algorithm', 'briefDigest', 'graphDigest', 'analystRecordsDigest'], 'Investigation capsule integrity');
1558
+ if (integrity.algorithm !== 'SHA-256')
1559
+ fail('Investigation capsule integrity');
1560
+ if (current) {
1561
+ if (integrity.canonicalization !== 'sorted-json-v2' || integrity.scope !== 'capsule excluding integrity')
1562
+ fail('Investigation capsule integrity');
1563
+ digest(integrity.digestSha256, 'Investigation capsule digest');
1564
+ }
1565
+ digest(integrity.briefDigest, 'Investigation capsule brief digest');
1566
+ digest(integrity.graphDigest, 'Investigation capsule graph digest');
1567
+ if (integrity.analystRecordsDigest !== null)
1568
+ digest(integrity.analystRecordsDigest, 'Investigation capsule analyst digest');
1569
+ if (byId.get('investigation-brief')?.digest !== integrity.briefDigest
1570
+ || byId.get('asset-graph')?.digest !== integrity.graphDigest
1571
+ || (root.analystRecords === null) !== (integrity.analystRecordsDigest === null)
1572
+ || (root.analystRecords === null) !== !byId.has('analyst-records')
1573
+ || (byId.get('analyst-records')?.digest ?? null) !== integrity.analystRecordsDigest)
1574
+ fail('Investigation capsule digest linkage');
1575
+ strings(root.limitations, 'Investigation capsule limitations', 8, 600);
1576
+ }
1577
+ const DNS_TYPES = ['A', 'AAAA', 'CAA', 'CDNSKEY', 'CDS', 'CNAME', 'CSYNC', 'HTTPS', 'MX', 'NS', 'SRV', 'SVCB', 'TLSA', 'TXT'];
1578
+ function validateReviewMatrix(value, label) {
1579
+ for (const [rowIndex, candidate] of array(value, label, 500).entries()) {
1580
+ const row = exact(candidate, ['owner', 'type', 'state', 'observations'], `${label} row ${rowIndex + 1}`);
1581
+ text(row.owner, `${label} owner`, 253);
1582
+ enumeration(row.type, DNS_TYPES, `${label} type`);
1583
+ const observations = array(row.observations, `${label} observations`, 16);
1584
+ let complete = 0;
1585
+ const signatures = new Set();
1586
+ for (const observationCandidate of observations) {
1587
+ const observation = exact(observationCandidate, ['label', 'source', 'state', 'values', 'ttlRange'], `${label} observation`);
1588
+ text(observation.label, `${label} observation label`, 120);
1589
+ text(observation.source, `${label} observation source`, 240);
1590
+ const state = enumeration(observation.state, ['observed', 'partial', 'unavailable'], `${label} observation state`);
1591
+ const values = strings(observation.values, `${label} observation values`, 500, 16_384);
1592
+ if (state === 'observed') {
1593
+ complete += 1;
1594
+ signatures.add(JSON.stringify(values));
1595
+ }
1596
+ if (observation.ttlRange !== null) {
1597
+ const range = exact(observation.ttlRange, ['minimum', 'maximum'], `${label} TTL range`);
1598
+ const minimum = integer(range.minimum, `${label} minimum TTL`, 0, 0x7fff_ffff);
1599
+ const maximum = integer(range.maximum, `${label} maximum TTL`, minimum, 0x7fff_ffff);
1600
+ if (maximum < minimum)
1601
+ fail(`${label} TTL range`);
1602
+ }
1603
+ }
1604
+ const expected = complete < 2 ? 'insufficient' : signatures.size === 1 ? 'aligned' : 'different';
1605
+ if (row.state !== expected)
1606
+ fail(`${label} state`);
1607
+ }
1608
+ }
1609
+ function validateDomainChangeReview(value, label) {
1610
+ const review = exact(value, ['schema', 'version', 'generatedAt', 'domain', 'state', 'authoritativeRecordMatrix', 'resolverDivergenceMatrix', 'dnssecAutomation', 'acmeDependencies', 'certificate', 'services', 'hsts', 'gate', 'limitations'], label);
1611
+ if (review.schema !== 'whoisleuth.domain-change.review' || review.version !== 1)
1612
+ fail(label);
1613
+ iso(review.generatedAt, `${label} generatedAt`);
1614
+ domain(review.domain, `${label} domain`);
1615
+ enumeration(review.state, ['ready', 'review'], `${label} state`);
1616
+ validateReviewMatrix(review.authoritativeRecordMatrix, `${label} authority matrix`);
1617
+ validateReviewMatrix(review.resolverDivergenceMatrix, `${label} resolver matrix`);
1618
+ const automation = exact(review.dnssecAutomation, ['state', 'cdsObserved', 'cdnskeyObserved', 'csyncObserved', 'conflictingTypes', 'detail'], `${label} DNSSEC automation`);
1619
+ enumeration(automation.state, ['not_observed', 'conflict', 'partial', 'review_ready'], `${label} DNSSEC automation`);
1620
+ boolean(automation.cdsObserved, `${label} CDS observed`);
1621
+ boolean(automation.cdnskeyObserved, `${label} CDNSKEY observed`);
1622
+ boolean(automation.csyncObserved, `${label} CSYNC observed`);
1623
+ strings(automation.conflictingTypes, `${label} conflicting types`, 3, 16).forEach((item) => enumeration(item, ['CDS', 'CDNSKEY', 'CSYNC'], `${label} conflicting type`));
1624
+ text(automation.detail, `${label} DNSSEC detail`, 600);
1625
+ for (const candidate of array(review.acmeDependencies, `${label} ACME dependencies`, 64)) {
1626
+ const dependency = exact(candidate, ['method', 'owner', 'target', 'provider', 'state'], `${label} ACME dependency`);
1627
+ enumeration(dependency.method, ['dns-01', 'http-01', 'tls-alpn-01'], `${label} ACME method`);
1628
+ text(dependency.owner, `${label} ACME owner`, 253);
1629
+ optionalText(dependency.target, `${label} ACME target`, 253);
1630
+ optionalText(dependency.provider, `${label} ACME provider`, 120);
1631
+ enumeration(dependency.state, ['confirmed', 'partial', 'unknown'], `${label} ACME state`);
1632
+ }
1633
+ const certificate = exact(review.certificate, ['state', 'continuity', 'findings'], `${label} certificate`);
1634
+ enumeration(certificate.state, ['not_supplied', 'observed', 'partial', 'unavailable'], `${label} certificate state`);
1635
+ enumeration(certificate.continuity, ['unknown', 'retained', 'changes'], `${label} certificate continuity`);
1636
+ strings(certificate.findings, `${label} certificate findings`, 8, 600);
1637
+ for (const candidate of array(review.services, `${label} services`, 1_000)) {
1638
+ const service = exact(candidate, ['owner', 'value', 'source', 'state', 'observedAt'], `${label} service`);
1639
+ text(service.owner, `${label} service owner`, 253);
1640
+ text(service.value, `${label} service value`, 16_384);
1641
+ text(service.source, `${label} service source`, 240);
1642
+ enumeration(service.state, ['observed', 'partial', 'unavailable'], `${label} service state`);
1643
+ iso(service.observedAt, `${label} service observedAt`);
1644
+ }
1645
+ if (review.hsts !== null) {
1646
+ const hsts = exact(review.hsts, ['state', 'observedAt', 'header', 'preloadState', 'source'], `${label} HSTS`);
1647
+ enumeration(hsts.state, ['observed', 'partial', 'unavailable'], `${label} HSTS state`);
1648
+ iso(hsts.observedAt, `${label} HSTS observedAt`);
1649
+ optionalText(hsts.header, `${label} HSTS header`, 1_024);
1650
+ enumeration(hsts.preloadState, ['listed', 'not_listed', 'unavailable'], `${label} HSTS preload`);
1651
+ text(hsts.source, `${label} HSTS source`, 240);
1652
+ }
1653
+ const gate = exact(review.gate, ['pass', 'reasons'], `${label} gate`);
1654
+ boolean(gate.pass, `${label} gate`);
1655
+ const reasons = strings(gate.reasons, `${label} gate reasons`, 2_000, 600);
1656
+ if (gate.pass !== (reasons.length === 0) || review.state !== (gate.pass ? 'ready' : 'review'))
1657
+ fail(`${label} gate`);
1658
+ strings(review.limitations, `${label} limitations`, 8, 600);
1659
+ return review;
1660
+ }
1661
+ function validatePlannedAssurance(value, label) {
1662
+ const assurance = exact(value, ['schema', 'version', 'generatedAt', 'result', 'limitations'], label);
1663
+ if (assurance.schema !== 'whoisleuth.domain-assurance' || assurance.version !== 2)
1664
+ fail(label);
1665
+ iso(assurance.generatedAt, `${label} generatedAt`);
1666
+ const result = exact(assurance.result, ['kind', 'domain', 'reference', 'window', 'milestones', 'rollbackCriteria', 'postChangeChecks', 'review'], `${label} result`);
1667
+ if (result.kind !== 'planned-change')
1668
+ fail(`${label} result`);
1669
+ domain(result.domain, `${label} domain`);
1670
+ text(result.reference, `${label} reference`, 120);
1671
+ const window = exact(result.window, ['startsAt', 'endsAt'], `${label} window`);
1672
+ iso(window.startsAt, `${label} startsAt`);
1673
+ iso(window.endsAt, `${label} endsAt`);
1674
+ if (Date.parse(window.endsAt) <= Date.parse(window.startsAt))
1675
+ fail(`${label} window`);
1676
+ const ids = new Set();
1677
+ for (const candidate of array(result.milestones, `${label} milestones`, 24, 1)) {
1678
+ const item = exact(candidate, ['id', 'label', 'expectedBy', 'evidenceSource', 'state', 'observedAt', 'evidenceReference'], `${label} milestone`);
1679
+ const id = text(item.id, `${label} milestone id`, 64);
1680
+ if (ids.has(id))
1681
+ fail(`${label} item ids`);
1682
+ ids.add(id);
1683
+ text(item.label, `${label} milestone label`, 180);
1684
+ iso(item.expectedBy, `${label} milestone expectedBy`);
1685
+ text(item.evidenceSource, `${label} milestone source`, 120);
1686
+ const state = enumeration(item.state, ['missed', 'not_checked', 'observed', 'planned'], `${label} milestone state`);
1687
+ iso(item.observedAt, `${label} milestone observedAt`, true);
1688
+ optionalText(item.evidenceReference, `${label} milestone reference`, 300);
1689
+ if (['observed', 'missed'].includes(state) !== (item.observedAt !== null && item.evidenceReference !== null))
1690
+ fail(`${label} milestone evidence`);
1691
+ }
1692
+ for (const candidate of array(result.rollbackCriteria, `${label} rollback criteria`, 16, 1)) {
1693
+ const item = exact(candidate, ['id', 'condition', 'owner', 'state'], `${label} rollback criterion`);
1694
+ const id = text(item.id, `${label} rollback id`, 64);
1695
+ if (ids.has(id))
1696
+ fail(`${label} item ids`);
1697
+ ids.add(id);
1698
+ text(item.condition, `${label} rollback condition`, 240);
1699
+ text(item.owner, `${label} rollback owner`, 120);
1700
+ enumeration(item.state, ['met', 'not_checked', 'not_met'], `${label} rollback state`);
1701
+ }
1702
+ for (const candidate of array(result.postChangeChecks, `${label} post-change checks`, 24, 1)) {
1703
+ const item = exact(candidate, ['id', 'label', 'expectedState', 'evidenceSource', 'state', 'evidenceReference'], `${label} post-change check`);
1704
+ const id = text(item.id, `${label} post-change id`, 64);
1705
+ if (ids.has(id))
1706
+ fail(`${label} item ids`);
1707
+ ids.add(id);
1708
+ text(item.label, `${label} post-change label`, 180);
1709
+ text(item.expectedState, `${label} expected state`, 240);
1710
+ text(item.evidenceSource, `${label} post-change source`, 120);
1711
+ const state = enumeration(item.state, ['matched', 'not_checked', 'unexpected'], `${label} post-change state`);
1712
+ optionalText(item.evidenceReference, `${label} post-change reference`, 300);
1713
+ if ((state === 'not_checked') !== (item.evidenceReference === null))
1714
+ fail(`${label} post-change evidence`);
1715
+ }
1716
+ const review = exact(result.review, ['state', 'reasons'], `${label} review`);
1717
+ enumeration(review.state, ['incomplete', 'needs_review', 'ready'], `${label} review state`);
1718
+ const reasons = strings(review.reasons, `${label} review reasons`, 20, 600);
1719
+ if ((review.state === 'ready') !== (reasons.length === 0))
1720
+ fail(`${label} review`);
1721
+ strings(assurance.limitations, `${label} limitations`, 8, 600);
1722
+ return assurance;
1723
+ }
1724
+ function expectedDomainChangeSummary(before, after) {
1725
+ const rows = (review) => new Map(review.authoritativeRecordMatrix.map((row) => [`${row.owner}\u0000${row.type}`, row]));
1726
+ const beforeRows = rows(before);
1727
+ const afterRows = rows(after);
1728
+ const keys = [...new Set([...beforeRows.keys(), ...afterRows.keys()])].sort();
1729
+ return keys.slice(0, 500).flatMap((key) => {
1730
+ const left = beforeRows.get(key);
1731
+ const right = afterRows.get(key);
1732
+ const values = (row) => [...new Set((row?.observations ?? [])
1733
+ .flatMap((observation) => observation.values))].sort();
1734
+ const beforeValues = values(left);
1735
+ const afterValues = values(right);
1736
+ if (sameValues(beforeValues, afterValues))
1737
+ return [];
1738
+ const [owner, type] = key.split('\u0000');
1739
+ return [{ owner: owner ?? '', type: type ?? '', beforeValues, afterValues }];
1740
+ });
1741
+ }
1742
+ function validateDomainChangePacket(value) {
1743
+ const root = exact(value, ['schema', 'version', 'generatedAt', 'domain', 'reference', 'state', 'gate', 'summary', 'evidence', 'limitations', 'integrity'], 'Domain change packet');
1744
+ iso(root.generatedAt, 'Domain change packet generatedAt');
1745
+ domain(root.domain, 'Domain change packet domain');
1746
+ text(root.reference, 'Domain change packet reference', 200);
1747
+ enumeration(root.state, ['ready', 'review'], 'Domain change packet state');
1748
+ const gate = exact(root.gate, ['pass', 'reasons'], 'Domain change packet gate');
1749
+ boolean(gate.pass, 'Domain change packet gate');
1750
+ const gateReasons = strings(gate.reasons, 'Domain change packet gate reasons', 100, 700);
1751
+ if (gate.pass !== (gateReasons.length === 0) || root.state !== (gate.pass ? 'ready' : 'review'))
1752
+ fail('Domain change packet gate');
1753
+ const summary = exact(root.summary, ['changedAuthoritativeRecordSets', 'preChangeState', 'postChangeState', 'assuranceState'], 'Domain change packet summary');
1754
+ const changed = array(summary.changedAuthoritativeRecordSets, 'Domain change packet changes', 500);
1755
+ for (const candidate of changed) {
1756
+ const item = exact(candidate, ['owner', 'type', 'beforeValues', 'afterValues'], 'Domain change packet changed record set');
1757
+ text(item.owner, 'Domain change packet changed owner', 253);
1758
+ enumeration(item.type, DNS_TYPES, 'Domain change packet changed type');
1759
+ strings(item.beforeValues, 'Domain change packet before values', 500, 16_384);
1760
+ strings(item.afterValues, 'Domain change packet after values', 500, 16_384);
1761
+ }
1762
+ enumeration(summary.preChangeState, ['ready', 'review'], 'Domain change packet pre-change state');
1763
+ enumeration(summary.postChangeState, ['ready', 'review'], 'Domain change packet post-change state');
1764
+ enumeration(summary.assuranceState, ['incomplete', 'needs_review', 'ready'], 'Domain change packet assurance state');
1765
+ const evidence = exact(root.evidence, ['preChange', 'postChange', 'assurance'], 'Domain change packet evidence');
1766
+ const pre = validateDomainChangeReview(evidence.preChange, 'Domain change packet pre-change review');
1767
+ const post = validateDomainChangeReview(evidence.postChange, 'Domain change packet post-change review');
1768
+ const assurance = validatePlannedAssurance(evidence.assurance, 'Domain change packet assurance');
1769
+ const assuranceResult = assurance.result;
1770
+ if (pre.generatedAt !== root.generatedAt || post.generatedAt !== root.generatedAt || assurance.generatedAt !== root.generatedAt
1771
+ || pre.domain !== root.domain || post.domain !== root.domain || assuranceResult.domain !== root.domain
1772
+ || pre.state !== summary.preChangeState || post.state !== summary.postChangeState
1773
+ || assuranceResult.review.state !== summary.assuranceState)
1774
+ fail('Domain change packet evidence linkage');
1775
+ const expectedReasons = [
1776
+ ...pre.gate.reasons,
1777
+ ].map((reason) => `Pre-change evidence: ${reason}`);
1778
+ expectedReasons.push(...post.gate.reasons.map((reason) => `Post-change evidence: ${reason}`), ...(assuranceResult.review.reasons.map((reason) => `Change plan: ${reason}`)));
1779
+ const boundedReasons = expectedReasons.slice(0, 100);
1780
+ if (!sameValues(gateReasons, boundedReasons)
1781
+ || gate.pass !== (boundedReasons.length === 0)
1782
+ || root.state !== (boundedReasons.length === 0 ? 'ready' : 'review'))
1783
+ fail('Domain change packet gate');
1784
+ const expectedChanges = expectedDomainChangeSummary(pre, post);
1785
+ if (changed.length !== expectedChanges.length || changed.some((candidate, index) => {
1786
+ const item = candidate;
1787
+ const expected = expectedChanges[index];
1788
+ return item.owner !== expected.owner || item.type !== expected.type
1789
+ || !sameValues(item.beforeValues, expected.beforeValues)
1790
+ || !sameValues(item.afterValues, expected.afterValues);
1791
+ }))
1792
+ fail('Domain change packet summary');
1793
+ strings(root.limitations, 'Domain change packet limitations', 8, 600);
1794
+ validateIntegrity(root.integrity, 'Domain change packet integrity', root.version, 1, DOMAIN_CHANGE_PACKET_VERSION);
1795
+ }
1796
+ export function validateSignedDigestArtifactStructure(schema, value) {
1797
+ if (value.schema !== schema)
1798
+ fail('Signed review artefact');
1799
+ if (schema === ACQUISITION_DECISION_PACKET_SCHEMA)
1800
+ validateAcquisition(value);
1801
+ else if (schema === LOOKUP_CLAIM_PASSPORT_SCHEMA)
1802
+ validateLookupClaimPassport(value);
1803
+ else if (schema === BULK_DOMAIN_COMPARISON_SCHEMA)
1804
+ validateDomainComparison(value);
1805
+ else if (schema === BULK_MAIL_EXPOSURE_SCHEMA)
1806
+ validateMailExposure(value);
1807
+ else if (schema === BULK_REVIEW_MANIFEST_SCHEMA)
1808
+ validateBulkReviewManifest(value);
1809
+ else if (schema === DOMAIN_CONTROL_MANIFEST_SCHEMA) {
1810
+ try {
1811
+ normalizeDomainControlPassportDocument(value);
1812
+ }
1813
+ catch {
1814
+ fail('Domain control manifest');
1815
+ }
1816
+ }
1817
+ else if (schema === DOMAIN_CHANGE_PACKET_SCHEMA)
1818
+ validateDomainChangePacket(value);
1819
+ else if (schema === INVESTIGATION_MANIFEST_SCHEMA)
1820
+ validateInvestigationManifest(value);
1821
+ else
1822
+ fail('Signed review artefact');
1823
+ }
1824
+ export function validateOfflineArtifactStructure(schema, value) {
1825
+ if (schema === CASE_RESPONSE_PACKET_SCHEMA)
1826
+ validateCaseResponsePacket(value);
1827
+ else if (schema === INVESTIGATION_CAPSULE_SCHEMA)
1828
+ validateInvestigationCapsuleStructure(value);
1829
+ else if (schema === LOOKUP_EVIDENCE_SCHEMA)
1830
+ validateLookupEvidenceArtifactStructure(value);
1831
+ else
1832
+ validateSignedDigestArtifactStructure(schema, value);
1833
+ }