@slicedearth/whoisleuth-cli 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/cli/arguments.mjs +21 -5
  2. package/cli/artifact-validation/case-response.mjs +771 -0
  3. package/cli/artifact-validation/investigation-capsule.mjs +426 -0
  4. package/cli/artifact-validation/lookup-evidence.mjs +718 -0
  5. package/cli/artifact-validation/signed-review.mjs +598 -0
  6. package/cli/artifact-validation/structure-primitives.mjs +105 -0
  7. package/cli/artifact-verify.mjs +1 -1
  8. package/cli/assurance-command-runner.mjs +324 -0
  9. package/cli/command-reference.mjs +1175 -843
  10. package/cli/completion.mjs +56 -9
  11. package/cli/discriminated-command-handlers.mjs +11 -0
  12. package/cli/doctor.mjs +2 -2
  13. package/cli/evidence-command-runner.mjs +41 -31
  14. package/cli/export-evidence.mjs +1 -1
  15. package/cli/fail-policy.mjs +33 -8
  16. package/cli/formatters/terminal-command-formats.mjs +437 -0
  17. package/cli/formatters/terminal-lookup.mjs +758 -0
  18. package/cli/formatters/terminal-metadata.mjs +58 -0
  19. package/cli/formatters/terminal-shared.mjs +83 -0
  20. package/cli/formatters/terminal.mjs +5 -1232
  21. package/cli/history-command-runner.mjs +132 -0
  22. package/cli/inline-command-families.mjs +45 -0
  23. package/cli/inline-command-runner.mjs +55 -0
  24. package/cli/investigation-plan.mjs +9 -2
  25. package/cli/investigation-run.mjs +114 -9
  26. package/cli/lookup-brief.mjs +141 -23
  27. package/cli/lookup-browser-view.mjs +289 -0
  28. package/cli/lookup-browser.mjs +3 -288
  29. package/cli/mail-review.mjs +2 -6
  30. package/cli/offline-artifact-validation.mjs +19 -0
  31. package/cli/review-command-runner.mjs +269 -0
  32. package/cli/runner.mjs +15 -972
  33. package/cli/saved-lookup.mjs +19 -2
  34. package/cli/support-command-runner.mjs +229 -0
  35. package/cli/workflow-command-runner.mjs +129 -0
  36. package/docs/cli-reference.md +17 -10
  37. package/docs/cli.md +5 -3
  38. package/lib/availability.mjs +12 -9
  39. package/lib/bounded-contract-normalizers.mjs +2 -9
  40. package/lib/browser-library-profile.mjs +1 -2
  41. package/lib/client-behavior-profile.mjs +1 -2
  42. package/lib/credential-surface-profile.mjs +1 -1
  43. package/lib/domain-posture-analysis.mjs +10 -1
  44. package/lib/domain-posture.mjs +51 -23
  45. package/lib/evidence-export.mjs +46 -5
  46. package/lib/evidence-report-markdown.mjs +4 -0
  47. package/lib/evidence-report.mjs +50 -0
  48. package/lib/generated/cisa-kev-catalog.mjs +34 -4
  49. package/lib/generated/registrar-standing-catalogue.mjs +367 -0
  50. package/lib/json-record.mjs +12 -0
  51. package/lib/lookup-child-profile-contract.mjs +858 -0
  52. package/lib/lookup-contract-primitives.mjs +98 -0
  53. package/lib/lookup-diagnostics.mjs +96 -0
  54. package/lib/lookup-network-evidence-bounds.mjs +1 -0
  55. package/lib/lookup-response.mjs +367 -0
  56. package/lib/lookup.mjs +28 -443
  57. package/lib/page-role-profile.mjs +1 -3
  58. package/lib/project-metadata.mjs +1 -1
  59. package/lib/registrar-standing-catalogue-contract.mjs +70 -0
  60. package/lib/registrar-standing-contract.mjs +300 -0
  61. package/lib/registrar-standing.mjs +269 -0
  62. package/lib/registry-insights.mjs +2 -8
  63. package/lib/risk-scoring.mjs +26 -10
  64. package/lib/sslbl-certificates.generated.mjs +138 -136
  65. package/lib/structured-data-identity.mjs +1 -3
  66. package/lib/technology-evidence-role.mjs +51 -0
  67. package/lib/threat-intelligence-runtime.mjs +3 -3
  68. package/lib/threat-intelligence-types.mjs +13 -0
  69. package/lib/threatfox-intelligence.mjs +1 -3
  70. package/lib/tls-intelligence.mjs +1 -2
  71. package/lib/urlhaus-intelligence.mjs +1 -3
  72. package/lib/urlscan-intelligence.mjs +1 -3
  73. package/lib/website-security-posture.mjs +1 -2
  74. package/lib/website-technology.mjs +72 -23
  75. package/lib/whois-common-formats.mjs +276 -0
  76. package/lib/whois-dialects.mjs +534 -0
  77. package/lib/whois-parser-support.mjs +305 -0
  78. package/lib/whois-parser.mjs +135 -0
  79. package/lib/whois.mjs +6 -1187
  80. package/package.json +3 -3
  81. package/packages/cases/case-evidence-model.mjs +9 -0
  82. package/packages/cases/case-migration-model.mjs +17 -11
  83. package/packages/cases/case-response-model.mjs +13 -9
  84. package/packages/cases/case-response-packet.mjs +297 -112
  85. package/packages/comparison/page-similarity.mjs +23 -0
  86. package/packages/contracts/case-portability.mjs +227 -63
  87. package/packages/contracts/cli-lookup.mjs +4 -3
  88. package/packages/contracts/domain-control-manifest.mjs +1 -1
  89. package/packages/contracts/investigation-portability.mjs +1 -1
  90. package/packages/contracts/risk-calibration.mjs +2 -2
  91. package/packages/contracts/workspace-portability.mjs +34 -6
  92. package/packages/investigation/evidence-coverage-ledger.mjs +21 -0
  93. package/packages/investigation/lookup-claim-readiness.mjs +2 -2
  94. package/packages/workspace/brand-profile-model.mjs +1 -1
  95. package/packages/workspace/website-snapshot-model.mjs +79 -6
  96. package/third-party-notices.txt +1 -1
  97. package/cli/artifact-structure.mjs +0 -2507
@@ -0,0 +1,718 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+ import { assertLookupEvidencePrivacySafeTree, assertLookupEvidencePortableTree, LOOKUP_EVIDENCE_SCHEMA, LOOKUP_EVIDENCE_SCHEMA_VERSION, PRIVACY_MINIMIZED_LOOKUP_EVIDENCE_SCHEMA_VERSION, V1_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION, projectLookupEvidenceAvailability, projectLookupEvidenceAvailabilityPublic, projectLookupEvidenceQuery, projectLookupEvidenceRdapPublication, projectLookupEvidenceRegistryInsights, projectLookupEvidenceWhoisPublication, SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS, } from "../../lib/evidence-export.mjs";
3
+ import { registrarStandingObservedBy, resolveRegistrarIanaId, validRegistrarStanding, } from "../../lib/registrar-standing-contract.mjs";
4
+ import { compareRegistrySources } from "../../lib/registry-comparison.mjs";
5
+ import { buildRegistryInsights } from "../../lib/registry-insights.mjs";
6
+ import { WHOISLEUTH_SOURCE_REPOSITORY_URL } from "../../lib/project-metadata.mjs";
7
+ import { validHttpDeliveryMetadata, validPagePublicationMetadata, } from "../../lib/homepage-metadata-contract.mjs";
8
+ import { HEX_DIGEST_RE, SEMANTIC_VERSION_RE, absoluteUrl, array, boolean, domain, enumeration, exact, exactOptional, fail, integer, iso, nullableRecord, optionalText, record, strings, text, } from "./structure-primitives.mjs";
9
+ const LOOKUP_RDAP_ATTEMPT_OUTCOMES = [
10
+ 'success', 'not_found', 'no_results', 'unsupported', 'rate_limited',
11
+ 'server_error', 'client_error', 'invalid_json', 'invalid_response',
12
+ 'timeout', 'network_error', 'unknown',
13
+ ];
14
+ const LOOKUP_TIMING_SOURCES = [
15
+ 'rdap', 'whois', 'domain_evidence', 'reverse_dns', 'registrar_rdap',
16
+ 'network_context', 'security_txt', 'external_intelligence',
17
+ 'malware_host_intelligence', 'malware_ioc_intelligence',
18
+ ];
19
+ const PUBLIC_LOOKUP_AVAILABILITY_ANALYSIS_KEYS = [
20
+ 'applicable', 'type', 'domain', 'state', 'confidence', 'detail', 'source',
21
+ 'rdapServer', 'nameservers', 'statuses', 'registrar', 'registrant', 'abuse',
22
+ 'createdDate', 'expiryDate', 'createdDateIso', 'expiryDateIso', 'domainAgeDays',
23
+ 'expiresInDays', 'privacyProtected', 'dnssec', 'activityStatus',
24
+ 'websiteProbeStatus', 'websiteProbeDetail', 'http', 'deepScanComplete',
25
+ 'faviconHash', 'faviconPHash', 'pageTitle', 'hasPasswordField',
26
+ 'phishingLanguageMatch', 'hasExternalFormAction', 'externalAssetHosts',
27
+ 'pageIdentity', 'credentialSurfaceProfile', 'structuredDataIdentity',
28
+ 'technologyProfile', 'pageRoleProfile', 'clientBehaviorProfile',
29
+ 'securityPosture', 'dns', 'tls', 'hasMx', 'hasNullMx', 'mxHosts', 'hasSpf',
30
+ 'hasDmarc', 'bulkComparison', 'limitations',
31
+ ];
32
+ const LOOKUP_AVAILABILITY_ANALYSIS_KEYS = PUBLIC_LOOKUP_AVAILABILITY_ANALYSIS_KEYS.filter((key) => !['registrar', 'registrant', 'abuse'].includes(key));
33
+ const LOOKUP_IDN_ANALYSIS_KEYS = [
34
+ 'version', 'mappingVersion', 'asciiDomain', 'unicodeDomain', 'hasIdn',
35
+ 'scripts', 'labels', 'mixedScript', 'skeleton', 'referenceMatches', 'findings',
36
+ 'truncated', 'limitations',
37
+ ];
38
+ const LOOKUP_REGISTRAR_COMPARISON_STATUSES = [
39
+ 'equivalent', 'conflict', 'registry_only', 'registrar_only',
40
+ 'registry_redacted', 'registrar_redacted', 'registry_unavailable',
41
+ 'registrar_unavailable', 'registry_incomplete', 'registrar_incomplete',
42
+ ];
43
+ function validateLookupEvidenceHomepageMetadata(availability, version) {
44
+ const pageIdentity = availability.pageIdentity === null || availability.pageIdentity === undefined
45
+ ? null
46
+ : record(availability.pageIdentity, 'Lookup evidence page identity');
47
+ const http = availability.http === null || availability.http === undefined
48
+ ? null
49
+ : record(availability.http, 'Lookup evidence HTTP analysis');
50
+ const response = http?.response === null || http?.response === undefined
51
+ ? null
52
+ : record(http.response, 'Lookup evidence HTTP response');
53
+ const publicationPresent = Boolean(pageIdentity && Object.hasOwn(pageIdentity, 'publicationMetadata'));
54
+ const deliveryPresent = Boolean(response && Object.hasOwn(response, 'deliveryMetadata'));
55
+ if (version < PRIVACY_MINIMIZED_LOOKUP_EVIDENCE_SCHEMA_VERSION && (publicationPresent || deliveryPresent)) {
56
+ fail('Lookup evidence homepage metadata epoch');
57
+ }
58
+ if (publicationPresent && !validPagePublicationMetadata(pageIdentity?.publicationMetadata)) {
59
+ fail('Lookup evidence page publication metadata');
60
+ }
61
+ if (publicationPresent && !['success', 'partial'].includes(String(pageIdentity?.status))) {
62
+ fail('Lookup evidence page publication metadata source state');
63
+ }
64
+ if (deliveryPresent && !validHttpDeliveryMetadata(response?.deliveryMetadata)) {
65
+ fail('Lookup evidence HTTP delivery metadata');
66
+ }
67
+ if (deliveryPresent && !['success', 'partial'].includes(String(http?.status))) {
68
+ fail('Lookup evidence HTTP delivery metadata source state');
69
+ }
70
+ }
71
+ function validateLookupEvidenceRdapAttempt(value, label) {
72
+ const attempt = exact(value, ['endpoint', 'transportSecurity', 'status', 'outcome', 'detail', 'selected'], label);
73
+ let endpoint = null;
74
+ if (attempt.endpoint !== null)
75
+ endpoint = absoluteUrl(attempt.endpoint, `${label} endpoint`, ['http:', 'https:']);
76
+ if (attempt.transportSecurity !== null)
77
+ enumeration(attempt.transportSecurity, ['http', 'https'], `${label} transport`);
78
+ if (endpoint && attempt.transportSecurity !== endpoint.protocol.slice(0, -1))
79
+ fail(`${label} transport`);
80
+ if (!endpoint && attempt.transportSecurity !== null)
81
+ fail(`${label} transport`);
82
+ if (attempt.status !== null)
83
+ integer(attempt.status, `${label} status`, 100, 599);
84
+ enumeration(attempt.outcome, LOOKUP_RDAP_ATTEMPT_OUTCOMES, `${label} outcome`);
85
+ optionalText(attempt.detail, `${label} detail`, 240);
86
+ boolean(attempt.selected, `${label} selection`);
87
+ }
88
+ function validateLookupEvidenceWhoisHop(value, label) {
89
+ const hop = exact(value, ['server', 'address', 'queriedAt', 'queryProfile', 'responseEncoding', 'status', 'detail'], label);
90
+ optionalText(hop.server, `${label} server`, 253);
91
+ optionalText(hop.address, `${label} address`, 64);
92
+ iso(hop.queriedAt, `${label} queriedAt`, true);
93
+ if (hop.queryProfile !== null)
94
+ enumeration(hop.queryProfile, [
95
+ 'plain-domain', 'denic-domain-ace', 'jprs-domain-english',
96
+ 'registry-domain-unicode', 'not-issued',
97
+ ], `${label} query profile`);
98
+ if (hop.responseEncoding !== null)
99
+ enumeration(hop.responseEncoding, ['utf-8'], `${label} response encoding`);
100
+ enumeration(hop.status, ['success', 'error', 'not_issued', 'unknown'], `${label} status`);
101
+ optionalText(hop.detail, `${label} detail`, 240);
102
+ }
103
+ function validateLookupEvidenceTiming(value) {
104
+ if (value === null)
105
+ return;
106
+ const timing = exact(value, ['version', 'totalMs', 'sources'], 'Lookup evidence timing');
107
+ if (timing.version !== 1)
108
+ fail('Lookup evidence timing');
109
+ if (timing.totalMs !== null)
110
+ integer(timing.totalMs, 'Lookup evidence total timing', 0, 120_000);
111
+ const seen = new Set();
112
+ array(timing.sources, 'Lookup evidence timing sources', LOOKUP_TIMING_SOURCES.length)
113
+ .forEach((item, index) => {
114
+ const entry = exact(item, ['source', 'outcome', 'durationMs', 'completedAfterMs'], `Lookup evidence timing source ${index + 1}`);
115
+ const source = enumeration(entry.source, LOOKUP_TIMING_SOURCES, `Lookup evidence timing source ${index + 1}`);
116
+ if (seen.has(source))
117
+ fail('Lookup evidence timing source');
118
+ seen.add(source);
119
+ enumeration(entry.outcome, ['fulfilled', 'rejected'], `Lookup evidence timing outcome ${index + 1}`);
120
+ if (entry.durationMs !== null)
121
+ integer(entry.durationMs, `Lookup evidence timing duration ${index + 1}`, 0, 120_000);
122
+ if (entry.completedAfterMs !== null)
123
+ integer(entry.completedAfterMs, `Lookup evidence timing completion ${index + 1}`, 0, 120_000);
124
+ });
125
+ }
126
+ function validateLookupEvidenceRegistryAccess(value) {
127
+ if (value === null)
128
+ return;
129
+ const access = exactOptional(value, [
130
+ 'suffix', 'coverageState', 'whoisAccessProfile', 'rdapAccessProfile',
131
+ 'limitation', 'authority',
132
+ ], ['officialLookupUrl'], 'Lookup evidence registry access');
133
+ optionalText(access.suffix, 'Lookup evidence registry suffix', 253);
134
+ optionalText(access.coverageState, 'Lookup evidence registry coverage', 40);
135
+ optionalText(access.whoisAccessProfile, 'Lookup evidence WHOIS access profile', 80);
136
+ optionalText(access.rdapAccessProfile, 'Lookup evidence RDAP access profile', 80);
137
+ if (access.officialLookupUrl !== undefined && access.officialLookupUrl !== null)
138
+ absoluteUrl(access.officialLookupUrl, 'Lookup evidence official lookup URL', ['http:', 'https:']);
139
+ optionalText(access.limitation, 'Lookup evidence registry limitation', 500);
140
+ if (access.authority !== null)
141
+ enumeration(access.authority, ['context_only'], 'Lookup evidence registry authority');
142
+ }
143
+ function validateLookupEvidenceRegistrarDiagnostic(value) {
144
+ if (value === null)
145
+ return;
146
+ const registrar = exact(value, ['status', 'endpoint', 'transportSecurity', 'httpStatus', 'fetchedAt', 'attempt'], 'Lookup evidence registrar diagnostic');
147
+ enumeration(registrar.status, ['success', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled'], 'Lookup evidence registrar diagnostic status');
148
+ let endpoint = null;
149
+ if (registrar.endpoint !== null)
150
+ endpoint = absoluteUrl(registrar.endpoint, 'Lookup evidence registrar diagnostic endpoint', ['http:', 'https:']);
151
+ if (registrar.transportSecurity !== null)
152
+ enumeration(registrar.transportSecurity, ['http', 'https'], 'Lookup evidence registrar diagnostic transport');
153
+ if (endpoint && registrar.transportSecurity !== endpoint.protocol.slice(0, -1))
154
+ fail('Lookup evidence registrar diagnostic transport');
155
+ if (!endpoint && registrar.transportSecurity !== null)
156
+ fail('Lookup evidence registrar diagnostic transport');
157
+ if (registrar.httpStatus !== null)
158
+ integer(registrar.httpStatus, 'Lookup evidence registrar diagnostic HTTP status', 100, 599);
159
+ iso(registrar.fetchedAt, 'Lookup evidence registrar diagnostic fetchedAt', true);
160
+ if (registrar.attempt !== null)
161
+ validateLookupEvidenceRdapAttempt(registrar.attempt, 'Lookup evidence registrar diagnostic attempt');
162
+ }
163
+ function validateLookupEvidenceCurrentDiagnostics(value) {
164
+ const diagnostics = exactOptional(value, ['rdap', 'whois'], [
165
+ 'version', 'timing', 'registryAccess', 'availability', 'reverseDns',
166
+ 'network', 'securityTxt', 'sslbl',
167
+ ], 'Lookup evidence diagnostics');
168
+ if (diagnostics.version !== undefined)
169
+ integer(diagnostics.version, 'Lookup evidence diagnostics version', 1, 10);
170
+ if (diagnostics.timing !== undefined)
171
+ validateLookupEvidenceTiming(diagnostics.timing);
172
+ if (diagnostics.registryAccess !== undefined)
173
+ validateLookupEvidenceRegistryAccess(diagnostics.registryAccess);
174
+ const rdap = exactOptional(diagnostics.rdap, ['status', 'errorCode', 'endpoint', 'transportSecurity', 'httpStatus', 'fetchedAt', 'attempts'], ['registrar'], 'Lookup evidence RDAP diagnostics');
175
+ const rdapStatus = enumeration(rdap.status, ['success', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled'], 'Lookup evidence RDAP diagnostic status');
176
+ optionalText(rdap.errorCode, 'Lookup evidence RDAP diagnostic error', 80);
177
+ let rdapEndpoint = null;
178
+ if (rdap.endpoint !== null)
179
+ rdapEndpoint = absoluteUrl(rdap.endpoint, 'Lookup evidence RDAP diagnostic endpoint', ['http:', 'https:']);
180
+ if (rdap.transportSecurity !== null)
181
+ enumeration(rdap.transportSecurity, ['http', 'https'], 'Lookup evidence RDAP diagnostic transport');
182
+ if (rdapEndpoint && rdap.transportSecurity !== rdapEndpoint.protocol.slice(0, -1))
183
+ fail('Lookup evidence RDAP diagnostic transport');
184
+ if (!rdapEndpoint && rdap.transportSecurity !== null)
185
+ fail('Lookup evidence RDAP diagnostic transport');
186
+ if (rdap.httpStatus !== null)
187
+ integer(rdap.httpStatus, 'Lookup evidence RDAP diagnostic HTTP status', 100, 599);
188
+ iso(rdap.fetchedAt, 'Lookup evidence RDAP diagnostic fetchedAt', true);
189
+ array(rdap.attempts, 'Lookup evidence RDAP diagnostic attempts', 16)
190
+ .forEach((item, index) => validateLookupEvidenceRdapAttempt(item, `Lookup evidence RDAP diagnostic attempt ${index + 1}`));
191
+ if (rdap.registrar !== undefined)
192
+ validateLookupEvidenceRegistrarDiagnostic(rdap.registrar);
193
+ const whois = exact(diagnostics.whois, ['status', 'errorCode', 'queriedAt', 'authoritativeHop', 'failedHop', 'conflictingHop'], 'Lookup evidence WHOIS diagnostics');
194
+ const whoisStatus = enumeration(whois.status, ['complete', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled'], 'Lookup evidence WHOIS diagnostic status');
195
+ optionalText(whois.errorCode, 'Lookup evidence WHOIS diagnostic error', 80);
196
+ iso(whois.queriedAt, 'Lookup evidence WHOIS diagnostic queriedAt', true);
197
+ for (const key of ['authoritativeHop', 'failedHop', 'conflictingHop'])
198
+ optionalText(whois[key], `Lookup evidence WHOIS diagnostic ${key}`, 253);
199
+ if (diagnostics.availability !== undefined) {
200
+ const availability = exact(diagnostics.availability, ['status', 'errorCode', 'resultState'], 'Lookup evidence availability diagnostics');
201
+ enumeration(availability.status, ['complete', 'error', 'disabled', 'not_applicable'], 'Lookup evidence availability diagnostic status');
202
+ optionalText(availability.errorCode, 'Lookup evidence availability diagnostic error', 80);
203
+ optionalText(availability.resultState, 'Lookup evidence availability result', 40);
204
+ }
205
+ let reverseDnsState = null;
206
+ if (diagnostics.reverseDns !== undefined) {
207
+ const reverseDns = exact(diagnostics.reverseDns, ['status', 'observedAt', 'complete', 'truncated'], 'Lookup evidence reverse DNS diagnostics');
208
+ const status = enumeration(reverseDns.status, ['success', 'partial', 'not_found', 'unsupported', 'skipped', 'error'], 'Lookup evidence reverse DNS diagnostic status');
209
+ iso(reverseDns.observedAt, 'Lookup evidence reverse DNS diagnostic observedAt', true);
210
+ boolean(reverseDns.complete, 'Lookup evidence reverse DNS diagnostic completeness');
211
+ boolean(reverseDns.truncated, 'Lookup evidence reverse DNS diagnostic truncation');
212
+ const expectedComplete = reverseDns.truncated !== true && (status === 'success' || status === 'not_found');
213
+ if (reverseDns.complete !== expectedComplete)
214
+ fail('Lookup evidence reverse DNS diagnostic completeness');
215
+ reverseDnsState = { status, complete: Boolean(reverseDns.complete), truncated: Boolean(reverseDns.truncated) };
216
+ }
217
+ let networkState = null;
218
+ if (diagnostics.network !== undefined) {
219
+ const network = exact(diagnostics.network, ['status', 'address', 'family', 'addressSource', 'endpoint', 'transportSecurity', 'httpStatus', 'fetchedAt', 'attempts'], 'Lookup evidence network diagnostics');
220
+ networkState = enumeration(network.status, ['success', 'partial', 'not_found', 'unsupported', 'error'], 'Lookup evidence network diagnostic status');
221
+ optionalText(network.address, 'Lookup evidence network diagnostic address', 64);
222
+ if (network.family !== null && network.family !== 4 && network.family !== 6)
223
+ fail('Lookup evidence network diagnostic family');
224
+ if (network.addressSource !== null)
225
+ enumeration(network.addressSource, ['tls_connection', 'dns_a', 'dns_aaaa'], 'Lookup evidence network diagnostic address source');
226
+ let endpoint = null;
227
+ if (network.endpoint !== null)
228
+ endpoint = absoluteUrl(network.endpoint, 'Lookup evidence network diagnostic endpoint', ['http:', 'https:']);
229
+ if (network.transportSecurity !== null)
230
+ enumeration(network.transportSecurity, ['http', 'https'], 'Lookup evidence network diagnostic transport');
231
+ if (endpoint && network.transportSecurity !== endpoint.protocol.slice(0, -1))
232
+ fail('Lookup evidence network diagnostic transport');
233
+ if (!endpoint && network.transportSecurity !== null)
234
+ fail('Lookup evidence network diagnostic transport');
235
+ if (network.httpStatus !== null)
236
+ integer(network.httpStatus, 'Lookup evidence network diagnostic HTTP status', 100, 599);
237
+ iso(network.fetchedAt, 'Lookup evidence network diagnostic fetchedAt', true);
238
+ array(network.attempts, 'Lookup evidence network diagnostic attempts', 3)
239
+ .forEach((item, index) => validateLookupEvidenceRdapAttempt(item, `Lookup evidence network diagnostic attempt ${index + 1}`));
240
+ }
241
+ if (diagnostics.securityTxt !== undefined) {
242
+ const securityTxt = exact(diagnostics.securityTxt, ['status', 'state', 'endpoint', 'httpStatus', 'observedAt', 'complete', 'truncated'], 'Lookup evidence security.txt diagnostics');
243
+ optionalText(securityTxt.status, 'Lookup evidence security.txt diagnostic status', 40);
244
+ if (securityTxt.state !== null)
245
+ enumeration(securityTxt.state, ['present', 'stale', 'partial', 'absent', 'malformed', 'unsupported', 'unavailable'], 'Lookup evidence security.txt diagnostic state');
246
+ if (securityTxt.endpoint !== null)
247
+ absoluteUrl(securityTxt.endpoint, 'Lookup evidence security.txt diagnostic endpoint', ['https:']);
248
+ if (securityTxt.httpStatus !== null)
249
+ integer(securityTxt.httpStatus, 'Lookup evidence security.txt diagnostic HTTP status', 100, 599);
250
+ iso(securityTxt.observedAt, 'Lookup evidence security.txt diagnostic observedAt', true);
251
+ boolean(securityTxt.complete, 'Lookup evidence security.txt diagnostic completeness');
252
+ boolean(securityTxt.truncated, 'Lookup evidence security.txt diagnostic truncation');
253
+ }
254
+ if (diagnostics.sslbl !== undefined) {
255
+ const sslbl = exact(diagnostics.sslbl, ['status', 'verdict', 'observedAt', 'complete', 'snapshotUpdatedAt'], 'Lookup evidence SSLBL diagnostics');
256
+ if (sslbl.status !== null)
257
+ enumeration(sslbl.status, ['success', 'stale', 'unavailable'], 'Lookup evidence SSLBL diagnostic status');
258
+ if (sslbl.verdict !== null)
259
+ enumeration(sslbl.verdict, ['listed', 'not_listed', 'inconclusive'], 'Lookup evidence SSLBL diagnostic verdict');
260
+ iso(sslbl.observedAt, 'Lookup evidence SSLBL diagnostic observedAt', true);
261
+ boolean(sslbl.complete, 'Lookup evidence SSLBL diagnostic completeness');
262
+ iso(sslbl.snapshotUpdatedAt, 'Lookup evidence SSLBL diagnostic snapshot timestamp', true);
263
+ }
264
+ return { rdap: rdapStatus, whois: whoisStatus, reverseDns: reverseDnsState, network: networkState };
265
+ }
266
+ function validateLookupEvidenceTree(value) {
267
+ try {
268
+ assertLookupEvidencePortableTree(value);
269
+ }
270
+ catch {
271
+ fail('Lookup evidence portable bounds');
272
+ }
273
+ }
274
+ function validateLookupEvidenceRdap(value, version) {
275
+ const source = record(value, 'Lookup evidence RDAP source');
276
+ if (source.status === 'error') {
277
+ const error = exact(source, ['status', 'error', 'attempts'], 'Lookup evidence RDAP source');
278
+ text(error.error, 'Lookup evidence RDAP error', 10_000);
279
+ array(error.attempts, 'Lookup evidence RDAP attempts', 16).forEach((item, index) => {
280
+ validateLookupEvidenceRdapAttempt(item, `Lookup evidence RDAP attempt ${index + 1}`);
281
+ });
282
+ return 'error';
283
+ }
284
+ const complete = exact(source, version >= PRIVACY_MINIMIZED_LOOKUP_EVIDENCE_SCHEMA_VERSION
285
+ ? ['status', 'endpoint', 'transportSecurity', 'httpStatus', 'fetchedAt', 'attempts', 'parsed']
286
+ : ['status', 'endpoint', 'transportSecurity', 'httpStatus', 'fetchedAt', 'attempts', 'parsed', 'raw'], 'Lookup evidence RDAP source');
287
+ const status = enumeration(complete.status, ['success', 'partial', 'not_found', 'unsupported', 'skipped', 'disabled'], 'Lookup evidence RDAP status');
288
+ let endpoint = null;
289
+ if (complete.endpoint !== null)
290
+ endpoint = absoluteUrl(complete.endpoint, 'Lookup evidence RDAP endpoint', ['http:', 'https:']);
291
+ if (complete.transportSecurity !== null)
292
+ enumeration(complete.transportSecurity, ['http', 'https'], 'Lookup evidence RDAP transport');
293
+ if (endpoint && complete.transportSecurity !== endpoint.protocol.slice(0, -1))
294
+ fail('Lookup evidence RDAP transport');
295
+ if (!endpoint && complete.transportSecurity !== null)
296
+ fail('Lookup evidence RDAP transport');
297
+ if (complete.httpStatus !== null)
298
+ integer(complete.httpStatus, 'Lookup evidence RDAP HTTP status', 100, 599);
299
+ iso(complete.fetchedAt, 'Lookup evidence RDAP fetchedAt', true);
300
+ array(complete.attempts, 'Lookup evidence RDAP attempts', 16).forEach((item, index) => {
301
+ validateLookupEvidenceRdapAttempt(item, `Lookup evidence RDAP attempt ${index + 1}`);
302
+ });
303
+ nullableRecord(complete.parsed, 'Lookup evidence RDAP parsed data');
304
+ if (version < PRIVACY_MINIMIZED_LOOKUP_EVIDENCE_SCHEMA_VERSION)
305
+ nullableRecord(complete.raw, 'Lookup evidence RDAP raw data');
306
+ if (version >= PRIVACY_MINIMIZED_LOOKUP_EVIDENCE_SCHEMA_VERSION
307
+ && !isDeepStrictEqual(complete.parsed, projectLookupEvidenceRdapPublication(complete.parsed))) {
308
+ fail('Lookup evidence RDAP portable publication');
309
+ }
310
+ if (status === 'success' && complete.parsed === null)
311
+ fail('Lookup evidence RDAP publication');
312
+ if (!['success', 'partial'].includes(status)
313
+ && (complete.parsed !== null || (version < PRIVACY_MINIMIZED_LOOKUP_EVIDENCE_SCHEMA_VERSION && complete.raw !== null)))
314
+ fail('Lookup evidence RDAP unavailable publication');
315
+ return status;
316
+ }
317
+ function validateLookupEvidenceWhois(value, version) {
318
+ const source = record(value, 'Lookup evidence WHOIS source');
319
+ if (source.status === 'error') {
320
+ const error = exact(source, ['status', 'error'], 'Lookup evidence WHOIS source');
321
+ text(error.error, 'Lookup evidence WHOIS error', 10_000);
322
+ return 'error';
323
+ }
324
+ const complete = exact(source, ['status', 'queriedAt', 'authoritativeHop', 'failedHop', 'conflictingHop', 'parsed', 'chain'], 'Lookup evidence WHOIS source');
325
+ const status = enumeration(complete.status, ['complete', 'partial', 'not_found', 'unsupported', 'skipped', 'disabled'], 'Lookup evidence WHOIS status');
326
+ iso(complete.queriedAt, 'Lookup evidence WHOIS queriedAt', true);
327
+ optionalText(complete.authoritativeHop, 'Lookup evidence WHOIS authoritative hop', 253);
328
+ optionalText(complete.failedHop, 'Lookup evidence WHOIS failed hop', 253);
329
+ optionalText(complete.conflictingHop, 'Lookup evidence WHOIS conflicting hop', 253);
330
+ nullableRecord(complete.parsed, 'Lookup evidence WHOIS parsed data');
331
+ if (version >= PRIVACY_MINIMIZED_LOOKUP_EVIDENCE_SCHEMA_VERSION
332
+ && !isDeepStrictEqual(complete.parsed, projectLookupEvidenceWhoisPublication(complete.parsed))) {
333
+ fail('Lookup evidence WHOIS portable publication');
334
+ }
335
+ array(complete.chain, 'Lookup evidence WHOIS chain', 16).forEach((item, index) => {
336
+ validateLookupEvidenceWhoisHop(item, `Lookup evidence WHOIS chain item ${index + 1}`);
337
+ });
338
+ if (status === 'complete' && complete.parsed === null)
339
+ fail('Lookup evidence WHOIS publication');
340
+ if (!['complete', 'partial'].includes(status)
341
+ && (complete.parsed !== null || complete.chain.length !== 0))
342
+ fail('Lookup evidence WHOIS unavailable publication');
343
+ return status;
344
+ }
345
+ function validateLookupEvidenceReverseDns(value) {
346
+ if (value === null)
347
+ return null;
348
+ const source = exact(value, ['version', 'status', 'observedAt', 'scanMode', 'source', 'durationMs', 'complete', 'truncated', 'limitations', 'diagnostics', 'records'], 'Lookup evidence reverse DNS source');
349
+ if (source.version !== 1 || source.source !== 'reverse_dns')
350
+ fail('Lookup evidence reverse DNS source');
351
+ const status = enumeration(source.status, ['success', 'partial', 'not_found', 'unsupported', 'skipped', 'error'], 'Lookup evidence reverse DNS status');
352
+ iso(source.observedAt, 'Lookup evidence reverse DNS observedAt', true);
353
+ if (source.scanMode !== null)
354
+ enumeration(source.scanMode, ['deep'], 'Lookup evidence reverse DNS mode');
355
+ if (source.durationMs !== null)
356
+ integer(source.durationMs, 'Lookup evidence reverse DNS duration', 0, 120_000);
357
+ boolean(source.complete, 'Lookup evidence reverse DNS completeness');
358
+ boolean(source.truncated, 'Lookup evidence reverse DNS truncation');
359
+ strings(source.limitations, 'Lookup evidence reverse DNS limitations', 10, 300);
360
+ if (source.diagnostics !== null) {
361
+ const diagnostics = exact(source.diagnostics, ['ptr'], 'Lookup evidence reverse DNS diagnostics');
362
+ const ptr = exact(diagnostics.ptr, ['status', 'error', 'truncated', 'discarded'], 'Lookup evidence reverse DNS PTR diagnostics');
363
+ optionalText(ptr.status, 'Lookup evidence reverse DNS PTR status', 40);
364
+ optionalText(ptr.error, 'Lookup evidence reverse DNS PTR error', 180);
365
+ boolean(ptr.truncated, 'Lookup evidence reverse DNS PTR truncation');
366
+ if (ptr.discarded !== null)
367
+ integer(ptr.discarded, 'Lookup evidence reverse DNS PTR discarded count', 0, 1_000);
368
+ }
369
+ const records = exact(source.records, ['ptr'], 'Lookup evidence reverse DNS records');
370
+ const ptr = strings(records.ptr, 'Lookup evidence reverse DNS PTR records', 8, 253);
371
+ if (!['success', 'partial'].includes(status) && ptr.length)
372
+ fail('Lookup evidence reverse DNS unavailable records');
373
+ const expectedComplete = source.truncated !== true && (status === 'success' || status === 'not_found');
374
+ if (source.complete !== expectedComplete)
375
+ fail('Lookup evidence reverse DNS completeness');
376
+ return { status, complete: Boolean(source.complete), truncated: Boolean(source.truncated) };
377
+ }
378
+ function validateLookupEvidenceNetwork(value) {
379
+ if (value === null)
380
+ return null;
381
+ const source = exact(value, ['contextVersion', 'version', 'status', 'observedAt', 'scanMode', 'source', 'durationMs', 'complete', 'truncated', 'limitations', 'diagnostics', 'detail', 'endpoint', 'rdap', 'network'], 'Lookup evidence network source');
382
+ if (source.contextVersion !== 1 || (source.version !== 1 && source.version !== null))
383
+ fail('Lookup evidence network source');
384
+ const status = enumeration(source.status, ['success', 'partial', 'not_found', 'unsupported', 'error'], 'Lookup evidence network status');
385
+ iso(source.observedAt, 'Lookup evidence network observedAt', true);
386
+ if (source.scanMode !== null)
387
+ enumeration(source.scanMode, ['deep'], 'Lookup evidence network mode');
388
+ if (source.source !== null)
389
+ enumeration(source.source, ['ip_rdap'], 'Lookup evidence network kind');
390
+ if (source.durationMs !== null)
391
+ integer(source.durationMs, 'Lookup evidence network duration', 0, 120_000);
392
+ boolean(source.complete, 'Lookup evidence network completeness');
393
+ boolean(source.truncated, 'Lookup evidence network truncation');
394
+ strings(source.limitations, 'Lookup evidence network limitations', 10, 300);
395
+ if (source.diagnostics !== null) {
396
+ const diagnostics = exact(source.diagnostics, ['requestCount', 'addressSource', 'httpStatus', 'cidrCount'], 'Lookup evidence network diagnostics');
397
+ if (diagnostics.requestCount !== null)
398
+ integer(diagnostics.requestCount, 'Lookup evidence network request count', 0, 1);
399
+ if (diagnostics.addressSource !== null)
400
+ enumeration(diagnostics.addressSource, ['tls_connection', 'dns_a', 'dns_aaaa'], 'Lookup evidence network address source');
401
+ if (diagnostics.httpStatus !== null)
402
+ integer(diagnostics.httpStatus, 'Lookup evidence network HTTP status', 100, 599);
403
+ if (diagnostics.cidrCount !== null)
404
+ integer(diagnostics.cidrCount, 'Lookup evidence network CIDR count', 0, 16);
405
+ }
406
+ optionalText(source.detail, 'Lookup evidence network detail', 300);
407
+ if (source.endpoint !== null) {
408
+ const endpoint = exact(source.endpoint, ['address', 'family', 'selectedFrom'], 'Lookup evidence network endpoint');
409
+ optionalText(endpoint.address, 'Lookup evidence network address', 64);
410
+ if (endpoint.family !== null)
411
+ integer(endpoint.family, 'Lookup evidence network address family', 4, 6);
412
+ if (endpoint.family !== null && endpoint.family !== 4 && endpoint.family !== 6)
413
+ fail('Lookup evidence network address family');
414
+ if (endpoint.selectedFrom !== null)
415
+ enumeration(endpoint.selectedFrom, ['tls_connection', 'dns_a', 'dns_aaaa'], 'Lookup evidence network selected source');
416
+ }
417
+ if (source.rdap !== null) {
418
+ const rdap = exact(source.rdap, ['endpoint', 'transportSecurity', 'httpStatus', 'fetchedAt', 'attempts'], 'Lookup evidence network RDAP');
419
+ if (rdap.endpoint !== null)
420
+ absoluteUrl(rdap.endpoint, 'Lookup evidence network RDAP endpoint', ['http:', 'https:']);
421
+ if (rdap.transportSecurity !== null)
422
+ enumeration(rdap.transportSecurity, ['http', 'https'], 'Lookup evidence network RDAP transport');
423
+ if (rdap.httpStatus !== null)
424
+ integer(rdap.httpStatus, 'Lookup evidence network RDAP HTTP status', 100, 599);
425
+ iso(rdap.fetchedAt, 'Lookup evidence network RDAP fetchedAt', true);
426
+ array(rdap.attempts, 'Lookup evidence network RDAP attempts', 3).forEach((item, index) => {
427
+ const attempt = exact(item, ['endpoint', 'transportSecurity', 'status', 'outcome', 'detail', 'selected'], `Lookup evidence network RDAP attempt ${index + 1}`);
428
+ if (attempt.endpoint !== null)
429
+ absoluteUrl(attempt.endpoint, 'Lookup evidence network RDAP attempt endpoint', ['http:', 'https:']);
430
+ if (attempt.transportSecurity !== null)
431
+ enumeration(attempt.transportSecurity, ['http', 'https'], 'Lookup evidence network RDAP attempt transport');
432
+ if (attempt.status !== null)
433
+ integer(attempt.status, 'Lookup evidence network RDAP attempt status', 100, 599);
434
+ optionalText(attempt.outcome, 'Lookup evidence network RDAP attempt outcome', 40);
435
+ optionalText(attempt.detail, 'Lookup evidence network RDAP attempt detail', 240);
436
+ boolean(attempt.selected, 'Lookup evidence network RDAP attempt selection');
437
+ });
438
+ }
439
+ if (source.network !== null) {
440
+ const network = exact(source.network, ['handle', 'name', 'holder', 'cidrs', 'startAddress', 'endAddress', 'country', 'networkType', 'databaseUpdatedAt'], 'Lookup evidence network registration');
441
+ for (const key of ['handle', 'name', 'holder'])
442
+ optionalText(network[key], `Lookup evidence network ${key}`, 300);
443
+ strings(network.cidrs, 'Lookup evidence network CIDRs', 16, 96);
444
+ optionalText(network.startAddress, 'Lookup evidence network start address', 64);
445
+ optionalText(network.endAddress, 'Lookup evidence network end address', 64);
446
+ if (network.country !== null && !/^[A-Z]{2}$/u.test(String(network.country)))
447
+ fail('Lookup evidence network country');
448
+ optionalText(network.networkType, 'Lookup evidence network type', 160);
449
+ iso(network.databaseUpdatedAt, 'Lookup evidence network database timestamp', true);
450
+ }
451
+ if (!['success', 'partial'].includes(status)
452
+ && (source.endpoint !== null || source.rdap !== null || source.network !== null)) {
453
+ fail('Lookup evidence unavailable network publication');
454
+ }
455
+ const expectedComplete = source.truncated !== true && (status === 'success' || status === 'not_found');
456
+ if (source.complete !== expectedComplete)
457
+ fail('Lookup evidence network completeness');
458
+ return { status, complete: Boolean(source.complete), truncated: Boolean(source.truncated) };
459
+ }
460
+ function validateLookupEvidenceSecurityTxt(value) {
461
+ if (value === null)
462
+ return;
463
+ 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');
464
+ if (source.securityTxtVersion !== 1 || (source.version !== 1 && source.version !== null) || source.source !== 'security_txt')
465
+ fail('Lookup evidence security.txt source');
466
+ enumeration(source.state, ['present', 'stale', 'partial', 'absent', 'malformed', 'unsupported', 'unavailable'], 'Lookup evidence security.txt state');
467
+ optionalText(source.status, 'Lookup evidence security.txt status', 40);
468
+ iso(source.observedAt, 'Lookup evidence security.txt observedAt', true);
469
+ if (source.scanMode !== null)
470
+ enumeration(source.scanMode, ['deep'], 'Lookup evidence security.txt mode');
471
+ if (source.durationMs !== null)
472
+ integer(source.durationMs, 'Lookup evidence security.txt duration', 0, 120_000);
473
+ boolean(source.complete, 'Lookup evidence security.txt completeness');
474
+ boolean(source.truncated, 'Lookup evidence security.txt truncation');
475
+ strings(source.limitations, 'Lookup evidence security.txt limitations', 10, 300);
476
+ optionalText(source.detail, 'Lookup evidence security.txt detail', 300);
477
+ optionalText(source.requestedUrl, 'Lookup evidence security.txt requested URL', 2_048);
478
+ optionalText(source.finalUrl, 'Lookup evidence security.txt final URL', 2_048);
479
+ if (source.httpStatus !== null)
480
+ integer(source.httpStatus, 'Lookup evidence security.txt HTTP status', 100, 599);
481
+ if (source.redirectCount !== null)
482
+ integer(source.redirectCount, 'Lookup evidence security.txt redirects', 0, 3);
483
+ iso(source.expiresAt, 'Lookup evidence security.txt expiry', true);
484
+ boolean(source.signed, 'Lookup evidence security.txt signed state');
485
+ if (source.canonicalMatches !== null)
486
+ boolean(source.canonicalMatches, 'Lookup evidence security.txt canonical state');
487
+ strings(source.contacts, 'Lookup evidence security.txt contacts', 10, 2_048);
488
+ strings(source.policies, 'Lookup evidence security.txt policies', 10, 2_048);
489
+ strings(source.encryption, 'Lookup evidence security.txt encryption', 10, 2_048);
490
+ strings(source.canonical, 'Lookup evidence security.txt canonical URLs', 10, 2_048);
491
+ strings(source.preferredLanguages, 'Lookup evidence security.txt languages', 10, 40);
492
+ const expectedStatus = source.state === 'present'
493
+ ? 'success'
494
+ : source.state === 'absent'
495
+ ? 'not_found'
496
+ : source.state === 'unsupported'
497
+ ? 'unsupported'
498
+ : source.state === 'unavailable' || source.state === 'malformed'
499
+ ? 'error'
500
+ : 'partial';
501
+ if (source.status !== expectedStatus)
502
+ fail('Lookup evidence security.txt state');
503
+ if (source.complete !== (source.state === 'present' || source.state === 'absent'))
504
+ fail('Lookup evidence security.txt completeness');
505
+ }
506
+ function validateLookupEvidenceSslbl(value) {
507
+ if (value === null)
508
+ return;
509
+ const source = exact(value, ['sslblVersion', 'source', 'status', 'verdict', 'complete', 'observedAt', 'fingerprintSha1', 'referenceUrl', 'snapshot', 'detail', 'limitations'], 'Lookup evidence SSLBL source');
510
+ if (source.sslblVersion !== 1 || source.source !== 'sslbl')
511
+ fail('Lookup evidence SSLBL source');
512
+ enumeration(source.status, ['success', 'stale', 'unavailable'], 'Lookup evidence SSLBL status');
513
+ enumeration(source.verdict, ['listed', 'not_listed', 'inconclusive'], 'Lookup evidence SSLBL verdict');
514
+ boolean(source.complete, 'Lookup evidence SSLBL completeness');
515
+ iso(source.observedAt, 'Lookup evidence SSLBL observedAt', true);
516
+ if (source.fingerprintSha1 !== null && !/^[a-f0-9]{40}$/u.test(String(source.fingerprintSha1)))
517
+ fail('Lookup evidence SSLBL fingerprint');
518
+ optionalText(source.referenceUrl, 'Lookup evidence SSLBL reference', 2_048);
519
+ if (source.snapshot === null)
520
+ fail('Lookup evidence SSLBL snapshot');
521
+ const snapshot = exact(source.snapshot, ['sourceUpdatedAt', 'generatedAt', 'ageSeconds', 'entryCount', 'digestSha256'], 'Lookup evidence SSLBL snapshot');
522
+ iso(snapshot.sourceUpdatedAt, 'Lookup evidence SSLBL source timestamp', true);
523
+ iso(snapshot.generatedAt, 'Lookup evidence SSLBL generated timestamp', true);
524
+ if (snapshot.ageSeconds !== null)
525
+ integer(snapshot.ageSeconds, 'Lookup evidence SSLBL age', 0, 31_536_000);
526
+ if (snapshot.entryCount !== null)
527
+ integer(snapshot.entryCount, 'Lookup evidence SSLBL entry count', 0, 50_000);
528
+ if (snapshot.digestSha256 !== null && !HEX_DIGEST_RE.test(String(snapshot.digestSha256)))
529
+ fail('Lookup evidence SSLBL digest');
530
+ optionalText(source.detail, 'Lookup evidence SSLBL detail', 300);
531
+ strings(source.limitations, 'Lookup evidence SSLBL limitations', 10, 300);
532
+ const validState = (source.verdict === 'not_listed' && source.status === 'success' && source.complete === true)
533
+ || (source.verdict === 'listed'
534
+ && (source.status === 'success' || source.status === 'stale')
535
+ && source.complete === (source.status === 'success'))
536
+ || (source.verdict === 'inconclusive'
537
+ && (source.status === 'stale' || source.status === 'unavailable')
538
+ && source.complete === false);
539
+ if (!validState)
540
+ fail('Lookup evidence SSLBL state');
541
+ if (source.referenceUrl !== null) {
542
+ if (source.verdict !== 'listed' || source.fingerprintSha1 === null)
543
+ fail('Lookup evidence SSLBL reference');
544
+ const reference = absoluteUrl(source.referenceUrl, 'Lookup evidence SSLBL reference', ['https:']);
545
+ if (reference.hostname !== 'sslbl.abuse.ch'
546
+ || reference.port
547
+ || reference.pathname !== `/ssl-certificates/sha1/${String(source.fingerprintSha1)}/`)
548
+ fail('Lookup evidence SSLBL reference');
549
+ }
550
+ }
551
+ function lookupPublicationCondition(status) {
552
+ if (status === 'partial')
553
+ return 'incomplete';
554
+ return ['error', 'unsupported', 'not_found', 'skipped', 'disabled'].includes(status)
555
+ ? 'unavailable'
556
+ : 'complete';
557
+ }
558
+ function validateLookupEvidenceRegistrarComparison(value) {
559
+ if (value === null)
560
+ return;
561
+ const comparison = exact(value, ['fields', 'counts', 'sourceHealth'], 'Lookup evidence registrar comparison');
562
+ const counts = exact(comparison.counts, LOOKUP_REGISTRAR_COMPARISON_STATUSES, 'Lookup evidence registrar comparison counts');
563
+ const observedCounts = new Map();
564
+ const labels = new Set();
565
+ array(comparison.fields, 'Lookup evidence registrar comparison fields', 9).forEach((item, index) => {
566
+ const field = exact(item, [
567
+ 'label', 'status', 'registryState', 'registrarState', 'registryDisplay', 'registrarDisplay',
568
+ ], `Lookup evidence registrar comparison field ${index + 1}`);
569
+ const label = enumeration(field.label, [
570
+ 'Domain', 'Registrar', 'Registrar IANA ID', 'Created', 'Expires',
571
+ 'Last updated', 'DNSSEC', 'Statuses', 'Name servers',
572
+ ], `Lookup evidence registrar comparison field ${index + 1} label`);
573
+ if (labels.has(label))
574
+ fail('Lookup evidence registrar comparison fields');
575
+ labels.add(label);
576
+ const status = enumeration(field.status, LOOKUP_REGISTRAR_COMPARISON_STATUSES, `Lookup evidence registrar comparison field ${index + 1} status`);
577
+ enumeration(field.registryState, ['absent', 'redacted', 'value', 'incomplete', 'unavailable'], `Lookup evidence registrar comparison field ${index + 1} registry state`);
578
+ enumeration(field.registrarState, ['absent', 'redacted', 'value', 'incomplete', 'unavailable'], `Lookup evidence registrar comparison field ${index + 1} registrar state`);
579
+ text(field.registryDisplay, `Lookup evidence registrar comparison field ${index + 1} registry display`, 2_000);
580
+ text(field.registrarDisplay, `Lookup evidence registrar comparison field ${index + 1} registrar display`, 2_000);
581
+ observedCounts.set(status, (observedCounts.get(status) ?? 0) + 1);
582
+ });
583
+ for (const status of LOOKUP_REGISTRAR_COMPARISON_STATUSES) {
584
+ const count = integer(counts[status], `Lookup evidence registrar comparison ${status} count`, 0, 9);
585
+ if (count !== (observedCounts.get(status) ?? 0))
586
+ fail('Lookup evidence registrar comparison counts');
587
+ }
588
+ const sourceHealth = exact(comparison.sourceHealth, ['registry', 'registrar'], 'Lookup evidence registrar comparison source health');
589
+ for (const source of ['registry', 'registrar']) {
590
+ const health = exact(sourceHealth[source], ['status', 'condition'], `Lookup evidence registrar comparison ${source} health`);
591
+ const status = enumeration(health.status, [
592
+ 'success', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled',
593
+ ], `Lookup evidence registrar comparison ${source} status`);
594
+ if (health.condition !== lookupPublicationCondition(status))
595
+ fail(`Lookup evidence registrar comparison ${source} condition`);
596
+ }
597
+ }
598
+ export function validateLookupEvidenceArtifactStructure(value) {
599
+ validateLookupEvidenceTree(value);
600
+ const root = exact(value, ['schema', 'schemaVersion', 'generatedAt', 'application', 'query', 'diagnostics', 'sources', 'analysis'], 'Lookup evidence');
601
+ if (root.schema !== LOOKUP_EVIDENCE_SCHEMA
602
+ || !SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS.includes(Number(root.schemaVersion)))
603
+ fail('Lookup evidence');
604
+ const version = integer(root.schemaVersion, 'Lookup evidence version', V1_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION, LOOKUP_EVIDENCE_SCHEMA_VERSION);
605
+ iso(root.generatedAt, 'Lookup evidence generatedAt');
606
+ const application = exact(root.application, ['name', 'version', 'projectUrl'], 'Lookup evidence application');
607
+ if (application.name !== 'WHOISleuth' || application.projectUrl !== WHOISLEUTH_SOURCE_REPOSITORY_URL)
608
+ fail('Lookup evidence application');
609
+ if (application.version !== null && (typeof application.version !== 'string' || !SEMANTIC_VERSION_RE.test(application.version)))
610
+ fail('Lookup evidence application version');
611
+ const query = exact(root.query, ['submitted', 'type', 'inputHostname', 'registrableDomain', 'isSubdomain'], 'Lookup evidence query');
612
+ text(query.submitted, 'Lookup evidence submitted query', 1_024);
613
+ const queryType = enumeration(query.type, ['domain', 'ipv4', 'ipv6', 'asn'], 'Lookup evidence query type');
614
+ optionalText(query.inputHostname, 'Lookup evidence input hostname', 253);
615
+ optionalText(query.registrableDomain, 'Lookup evidence registrable domain', 253);
616
+ boolean(query.isSubdomain, 'Lookup evidence subdomain state');
617
+ if (queryType === 'domain') {
618
+ if (query.inputHostname !== null)
619
+ domain(query.inputHostname, 'Lookup evidence input hostname');
620
+ domain(query.registrableDomain, 'Lookup evidence registrable domain');
621
+ }
622
+ else if (query.inputHostname !== null || query.registrableDomain !== null || query.isSubdomain !== false)
623
+ fail('Lookup evidence query');
624
+ if (!isDeepStrictEqual(query, projectLookupEvidenceQuery(query)))
625
+ fail('Lookup evidence query');
626
+ let diagnostics;
627
+ let rdapDiagnosticStatus;
628
+ let whoisDiagnosticStatus;
629
+ diagnostics = record(root.diagnostics, 'Lookup evidence diagnostics');
630
+ const currentDiagnosticStates = validateLookupEvidenceCurrentDiagnostics(diagnostics);
631
+ rdapDiagnosticStatus = currentDiagnosticStates.rdap;
632
+ whoisDiagnosticStatus = currentDiagnosticStates.whois;
633
+ const sources = exact(root.sources, ['rdap', 'whois', 'reverseDns', 'network', 'securityTxt', 'sslbl'], 'Lookup evidence sources');
634
+ const rdapSourceStatus = validateLookupEvidenceRdap(sources.rdap, version);
635
+ const whoisSourceStatus = validateLookupEvidenceWhois(sources.whois, version);
636
+ if (rdapSourceStatus !== rdapDiagnosticStatus)
637
+ fail('Lookup evidence RDAP source state');
638
+ if (whoisSourceStatus !== whoisDiagnosticStatus)
639
+ fail('Lookup evidence WHOIS source state');
640
+ const reverseDnsState = validateLookupEvidenceReverseDns(sources.reverseDns);
641
+ const networkState = validateLookupEvidenceNetwork(sources.network);
642
+ if (currentDiagnosticStates.reverseDns
643
+ && !isDeepStrictEqual(reverseDnsState, currentDiagnosticStates.reverseDns)) {
644
+ fail('Lookup evidence reverse DNS source state');
645
+ }
646
+ if (currentDiagnosticStates.network !== null
647
+ && (networkState?.status ?? null) !== currentDiagnosticStates.network) {
648
+ fail('Lookup evidence network source state');
649
+ }
650
+ validateLookupEvidenceSecurityTxt(sources.securityTxt);
651
+ validateLookupEvidenceSslbl(sources.sslbl);
652
+ const analysis = exact(root.analysis, version >= LOOKUP_EVIDENCE_SCHEMA_VERSION
653
+ ? ['availability', 'idn', 'registryInsights', 'registryComparison', 'registrarPublicationComparison', 'registrarStanding']
654
+ : ['availability', 'idn', 'registryInsights', 'registryComparison', 'registrarPublicationComparison'], 'Lookup evidence analysis');
655
+ if (version >= LOOKUP_EVIDENCE_SCHEMA_VERSION
656
+ && analysis.registrarStanding !== null
657
+ && !validRegistrarStanding(analysis.registrarStanding))
658
+ fail('Lookup evidence registrar standing');
659
+ if (version >= LOOKUP_EVIDENCE_SCHEMA_VERSION
660
+ && analysis.registrarStanding !== null
661
+ && !registrarStandingObservedBy(analysis.registrarStanding, root.generatedAt)) {
662
+ fail('Lookup evidence registrar standing observation time');
663
+ }
664
+ if (analysis.availability !== null) {
665
+ const currentAvailability = version >= PRIVACY_MINIMIZED_LOOKUP_EVIDENCE_SCHEMA_VERSION;
666
+ const availability = exactOptional(analysis.availability, currentAvailability ? ['registryContactsExcluded'] : [], currentAvailability ? LOOKUP_AVAILABILITY_ANALYSIS_KEYS : PUBLIC_LOOKUP_AVAILABILITY_ANALYSIS_KEYS, 'Lookup evidence availability analysis');
667
+ if (currentAvailability && availability.registryContactsExcluded !== true) {
668
+ fail('Lookup evidence availability contact exclusion');
669
+ }
670
+ validateLookupEvidenceHomepageMetadata(availability, version);
671
+ if (!isDeepStrictEqual(analysis.availability, currentAvailability
672
+ ? projectLookupEvidenceAvailability(analysis.availability)
673
+ : projectLookupEvidenceAvailabilityPublic(analysis.availability)))
674
+ fail('Lookup evidence availability analysis');
675
+ }
676
+ if (analysis.idn !== null) {
677
+ exactOptional(analysis.idn, [], LOOKUP_IDN_ANALYSIS_KEYS, 'Lookup evidence IDN analysis');
678
+ }
679
+ const registryInsights = record(analysis.registryInsights, 'Lookup evidence registry insights');
680
+ const registryComparison = record(analysis.registryComparison, 'Lookup evidence registry comparison');
681
+ validateLookupEvidenceRegistrarComparison(analysis.registrarPublicationComparison);
682
+ {
683
+ const rdap = record(sources.rdap, 'Lookup evidence RDAP source');
684
+ const whois = record(sources.whois, 'Lookup evidence WHOIS source');
685
+ const rdapParsed = ['success', 'partial'].includes(rdapDiagnosticStatus) ? rdap.parsed : null;
686
+ const whoisParsed = ['complete', 'partial'].includes(whoisDiagnosticStatus) ? whois.parsed : null;
687
+ if (version >= LOOKUP_EVIDENCE_SCHEMA_VERSION
688
+ && validRegistrarStanding(analysis.registrarStanding)
689
+ && analysis.registrarStanding.ianaId !== resolveRegistrarIanaId(rdapParsed, whoisParsed)) {
690
+ fail('Lookup evidence registrar standing source binding');
691
+ }
692
+ const expectedComparison = compareRegistrySources(rdapParsed, whoisParsed, {
693
+ rdapStatus: rdapDiagnosticStatus,
694
+ whoisStatus: whoisDiagnosticStatus,
695
+ });
696
+ if (!isDeepStrictEqual(registryComparison, expectedComparison))
697
+ fail('Lookup evidence registry comparison derivation');
698
+ const rebuiltInsights = buildRegistryInsights({
699
+ rdapParsed,
700
+ rdapStatus: rdapDiagnosticStatus,
701
+ rdapFetchedAt: rdap.fetchedAt,
702
+ whoisParsed,
703
+ whoisStatus: whoisDiagnosticStatus,
704
+ whoisQueriedAt: whois.queriedAt,
705
+ });
706
+ const expectedInsights = version >= PRIVACY_MINIMIZED_LOOKUP_EVIDENCE_SCHEMA_VERSION
707
+ ? projectLookupEvidenceRegistryInsights(rebuiltInsights)
708
+ : rebuiltInsights;
709
+ if (!isDeepStrictEqual(registryInsights, expectedInsights))
710
+ fail('Lookup evidence registry insight derivation');
711
+ try {
712
+ assertLookupEvidencePrivacySafeTree(root);
713
+ }
714
+ catch {
715
+ fail('Lookup evidence privacy boundary');
716
+ }
717
+ }
718
+ }