@slicedearth/whoisleuth-cli 1.45.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +74 -124
  4. package/SECURITY.md +6 -3
  5. package/bin/whoisleuth.mjs +7 -4
  6. package/cli/archive-inspect.mjs +28 -10
  7. package/cli/arguments.mjs +450 -173
  8. package/cli/artifact-structure.mjs +2507 -0
  9. package/cli/artifact-verify.mjs +286 -64
  10. package/cli/bounded-json.mjs +1 -0
  11. package/cli/bulk-checkpoint.mjs +35 -23
  12. package/cli/bulk-command-runner.mjs +2 -5
  13. package/cli/bulk.mjs +9 -2
  14. package/cli/case-pack.mjs +435 -46
  15. package/cli/ci-report.mjs +6 -1
  16. package/cli/collection-preflight.mjs +2 -2
  17. package/cli/command-catalogue.mjs +12 -3
  18. package/cli/command-reference.mjs +948 -106
  19. package/cli/completion.mjs +504 -218
  20. package/cli/config-profile.mjs +13 -3
  21. package/cli/ct-event-intake.mjs +32 -2
  22. package/cli/discover.mjs +7 -1
  23. package/cli/discovery-observation-snapshot.mjs +24 -28
  24. package/cli/discovery-scan-command-runner.mjs +13 -14
  25. package/cli/discovery-scan.mjs +17 -3
  26. package/cli/discovery-snapshot.mjs +15 -7
  27. package/cli/discovery-workflow.mjs +2 -3
  28. package/cli/doctor.mjs +2 -2
  29. package/cli/domain-control-monitor.mjs +215 -36
  30. package/cli/domain-control-observations.mjs +34 -17
  31. package/cli/errors.mjs +8 -3
  32. package/cli/evidence-command-runner.mjs +17 -11
  33. package/cli/evidence-signing.mjs +68 -50
  34. package/cli/export-evidence.mjs +11 -4
  35. package/cli/external-observation-mapping.mjs +3 -3
  36. package/cli/formatters/html.mjs +3 -3
  37. package/cli/formatters/json.mjs +20 -32
  38. package/cli/formatters/terminal.mjs +586 -22
  39. package/cli/input.mjs +62 -0
  40. package/cli/interactive-launcher.mjs +226 -0
  41. package/cli/interchange-report.mjs +226 -0
  42. package/cli/investigation-manifest.mjs +16 -17
  43. package/cli/investigation-plan.mjs +167 -16
  44. package/cli/investigation-run.mjs +80 -24
  45. package/cli/local-mmdb-review.mjs +45 -14
  46. package/cli/lookalike-calibration.mjs +3 -1
  47. package/cli/lookup-brief.mjs +13 -8
  48. package/cli/lookup-browser.mjs +844 -0
  49. package/cli/lookup-command-runner.mjs +96 -33
  50. package/cli/lookup-diff.mjs +11 -2
  51. package/cli/lookup-plan.mjs +1 -0
  52. package/cli/mail-review.mjs +206 -94
  53. package/cli/manual.mjs +5 -5
  54. package/cli/network-command-runner.mjs +78 -0
  55. package/cli/offline-evidence-review.mjs +45 -14
  56. package/cli/open-asset-model-bridge.mjs +1 -1
  57. package/cli/output-file.mjs +70 -14
  58. package/cli/page-compare.mjs +18 -10
  59. package/cli/progress-events.mjs +2 -1
  60. package/cli/progress.mjs +3 -3
  61. package/cli/registry-cohort.mjs +343 -61
  62. package/cli/registry-doctor.mjs +67 -28
  63. package/cli/registry-support.mjs +2 -1
  64. package/cli/retained-artifact-diff.mjs +375 -0
  65. package/cli/risk-calibration.mjs +89 -40
  66. package/cli/runner.mjs +322 -246
  67. package/cli/saved-lookup.mjs +439 -49
  68. package/cli/sharing-review.mjs +18 -7
  69. package/cli/source-reliability.mjs +75 -36
  70. package/cli/terminal-presentation.mjs +43 -18
  71. package/docs/cli-reference.md +125 -1334
  72. package/docs/cli.md +87 -236
  73. package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -0
  74. package/frontend/src/lib/analysis/domain-control-records.js +3 -0
  75. package/lib/application-version.mjs +3 -0
  76. package/lib/availability.mjs +72 -52
  77. package/lib/bounded-contract-normalizers.mjs +2 -4
  78. package/lib/bounded-file.mjs +91 -4
  79. package/lib/bounded-json.mjs +287 -0
  80. package/lib/browser-library-profile.mjs +1 -1
  81. package/lib/certificate-extension-profile.mjs +2 -1
  82. package/lib/classify.mjs +56 -5
  83. package/lib/client-behavior-profile.mjs +1 -1
  84. package/lib/credential-surface-profile.mjs +1 -1
  85. package/lib/cryptographic-assurance.mjs +173 -0
  86. package/lib/ct-query.mjs +3 -3
  87. package/lib/ct-search.mjs +120 -64
  88. package/lib/distributed-operation-budget.mjs +4 -2
  89. package/lib/dns-delegation-health.mjs +9 -6
  90. package/lib/dns-intelligence.mjs +4 -3
  91. package/lib/dns-resolver-selection.mjs +6 -4
  92. package/lib/dnssec-chain-validation.mjs +1052 -0
  93. package/lib/dnssec-evidence-validation.mjs +23 -10
  94. package/lib/domain-assurance.mjs +12 -12
  95. package/lib/domain-change-packet.mjs +7 -5
  96. package/lib/domain-control-flight-recorder.mjs +442 -78
  97. package/lib/domain-control-manifest.mjs +314 -171
  98. package/lib/domain-posture-analysis.mjs +5 -2
  99. package/lib/domain-posture.mjs +7 -4
  100. package/lib/encrypted-dns-contract.mjs +2 -3
  101. package/lib/evidence-export.mjs +1243 -88
  102. package/lib/evidence-report-markdown.mjs +3 -0
  103. package/lib/evidence-report.mjs +5 -0
  104. package/lib/external-intelligence-risk.mjs +26 -10
  105. package/lib/favicon.mjs +8 -5
  106. package/lib/feature-policy.mjs +1 -1
  107. package/lib/homepage-metadata-contract.mjs +290 -0
  108. package/lib/hostname.mjs +1 -1
  109. package/lib/html-signals.mjs +89 -5
  110. package/lib/http-evidence-bounds.mjs +3 -0
  111. package/lib/http-intelligence.mjs +332 -6
  112. package/lib/interchange-fidelity-registry.mjs +124 -0
  113. package/lib/local-geoip-evidence.mjs +2 -1
  114. package/lib/lookup-cache.mjs +6 -4
  115. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  116. package/lib/lookup.mjs +27 -4
  117. package/lib/observed-network-context.mjs +1 -1
  118. package/lib/outbound-identity.mjs +2 -3
  119. package/lib/page-fingerprints.mjs +2 -2
  120. package/lib/page-role-profile.mjs +1 -1
  121. package/lib/perceptual-hash.mjs +12 -4
  122. package/lib/portable-generator.mjs +29 -0
  123. package/lib/provider-policy-admission.mjs +11 -4
  124. package/lib/rdap-bootstrap.mjs +76 -16
  125. package/lib/rdap-client.mjs +20 -12
  126. package/lib/rdap-normalization.mjs +324 -80
  127. package/lib/rdap-registrar.mjs +5 -1
  128. package/lib/rdap-transport.mjs +2 -2
  129. package/lib/rdap-validation.mjs +65 -14
  130. package/lib/rdap.mjs +2 -2
  131. package/lib/registrable-domain.mjs +36 -0
  132. package/lib/registry-capabilities.mjs +3 -0
  133. package/lib/registry-capability-catalogue.mjs +12 -2
  134. package/lib/registry-comparison.mjs +28 -11
  135. package/lib/registry-insights.mjs +7 -0
  136. package/lib/response-policy.mjs +27 -4
  137. package/lib/risk-calibration-summary.mjs +340 -0
  138. package/lib/risk-scoring.mjs +4 -3
  139. package/lib/rpki-evidence.mjs +9 -8
  140. package/lib/safe-fetch.mjs +121 -50
  141. package/lib/security-txt.mjs +27 -4
  142. package/lib/service-binding-dns.mjs +6 -1
  143. package/lib/smtp-transport-review.mjs +787 -0
  144. package/lib/sslbl-certificates.generated.mjs +136 -134
  145. package/lib/sslbl-intelligence.mjs +6 -8
  146. package/lib/static-html-analysis.mjs +301 -4
  147. package/lib/structured-data-identity.mjs +51 -19
  148. package/lib/threat-intelligence-fixtures.mjs +2 -4
  149. package/lib/threat-intelligence-manifests.mjs +2 -7
  150. package/lib/threat-intelligence-runtime.mjs +5 -15
  151. package/lib/threat-intelligence-types.mjs +21 -1
  152. package/lib/threatfox-intelligence.mjs +7 -3
  153. package/lib/tls-intelligence.mjs +15 -9
  154. package/lib/tlsa-evidence.mjs +37 -7
  155. package/lib/urlhaus-intelligence.mjs +4 -2
  156. package/lib/urlscan-intelligence.mjs +6 -5
  157. package/lib/website-security-posture.mjs +1 -1
  158. package/lib/website-technology.mjs +1 -1
  159. package/lib/whois-authority.mjs +43 -8
  160. package/lib/whois-chain.mjs +25 -3
  161. package/lib/whois-normalization.mjs +3 -1
  162. package/lib/whois-transport.mjs +12 -3
  163. package/lib/zone-intent-review.mjs +1 -1
  164. package/package.json +12 -4
  165. package/packages/cases/case-brand-profile-references.mjs +85 -0
  166. package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +43 -14
  167. package/packages/cases/case-investigation-branch-model.mjs +132 -0
  168. package/packages/cases/case-migration-model.mjs +476 -0
  169. package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
  170. package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -19
  171. package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
  172. package/packages/cases/case-record-model.mjs +9 -0
  173. package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +106 -33
  174. package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +143 -16
  175. package/packages/cases/case-response-model.mjs +1604 -0
  176. package/packages/cases/case-response-packet.mjs +1312 -0
  177. package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
  178. package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
  179. package/packages/cases/opaque-reference-id.mjs +9 -0
  180. package/packages/comparison/comparison-ledger-bulk.mjs +366 -0
  181. package/packages/comparison/comparison-ledger-contract.mjs +241 -0
  182. package/packages/comparison/comparison-ledger-json.mjs +80 -0
  183. package/packages/comparison/comparison-ledger-serialization.mjs +52 -0
  184. package/packages/comparison/favicon-similarity.mjs +78 -0
  185. package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
  186. package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
  187. package/packages/contracts/analyst-review-state-contract.mjs +61 -0
  188. package/packages/contracts/analyst-review-state.mjs +50 -0
  189. package/packages/contracts/case-portability.mjs +1310 -0
  190. package/packages/contracts/cli-command-catalogue.mjs +2 -0
  191. package/packages/contracts/cli-lookup.mjs +394 -0
  192. package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
  193. package/packages/contracts/domain-control-manifest.mjs +1027 -0
  194. package/packages/contracts/domain-control-monitor.mjs +310 -0
  195. package/packages/contracts/domain-control-review.mjs +828 -0
  196. package/packages/contracts/domain-name.mjs +4 -0
  197. package/packages/contracts/external-observation-interchange.mjs +224 -0
  198. package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
  199. package/packages/contracts/investigation-portability.mjs +331 -0
  200. package/packages/contracts/offline-comparison.mjs +160 -0
  201. package/packages/contracts/risk-calibration.mjs +1288 -0
  202. package/packages/contracts/schema-compatibility.mjs +71 -0
  203. package/packages/contracts/schema-lifecycle.mjs +1702 -0
  204. package/packages/contracts/sslbl-snapshot.mjs +17 -0
  205. package/packages/contracts/workspace-portability.mjs +1347 -0
  206. package/packages/evidence/artifact-integrity.mjs +65 -0
  207. package/packages/evidence/decision-fact.mjs +735 -0
  208. package/packages/evidence/domain-control-runtime.mjs +547 -0
  209. package/packages/evidence/domain-name.mjs +41 -0
  210. package/packages/evidence/observation.mjs +384 -0
  211. package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +86 -20
  212. package/packages/interchange/retained-text.mjs +8 -0
  213. package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +11 -8
  214. package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +28 -9
  215. package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +67 -18
  216. package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +10 -9
  217. package/packages/investigation/bulk-review-policy.mjs +1 -0
  218. package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +50 -18
  219. package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
  220. package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
  221. package/packages/investigation/lookup-claim-passport.mjs +232 -0
  222. package/packages/investigation/lookup-claim-readiness.mjs +287 -0
  223. package/packages/investigation/lookup-investigation-brief.mjs +172 -0
  224. package/packages/monitoring/analyst-review-state.mjs +680 -0
  225. package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +97 -50
  226. package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +15 -15
  227. package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +179 -39
  228. package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +12 -5
  229. package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
  230. package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
  231. package/packages/workspace/hostile-input.mjs +172 -0
  232. package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +51 -46
  233. package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +15 -15
  234. package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
  235. package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
  236. package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
  237. package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
  238. package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
  239. package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +18 -12
  240. package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
  241. package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +195 -84
  242. package/third-party-notices.txt +112 -5834
  243. package/frontend/src/lib/analysis/artifact-integrity.js +0 -21
  244. package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
  245. package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
  246. package/frontend/src/lib/analysis/case-migration-model.js +0 -253
  247. package/frontend/src/lib/analysis/case-record-model.js +0 -7
  248. package/frontend/src/lib/analysis/case-response-model.js +0 -669
  249. package/frontend/src/lib/analysis/case-response-packet.js +0 -573
  250. package/frontend/src/lib/analysis/lookup-decision-support.js +0 -487
  251. package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
  252. package/frontend/src/lib/analysis/lookup-presentation.js +0 -58
  253. package/frontend/src/lib/analysis/lookup-response.js +0 -2
  254. package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
  255. package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
  256. package/frontend/src/lib/analysis/scoring.js +0 -55
  257. package/frontend/src/lib/analysis/utils.js +0 -232
  258. package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
  259. package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
  260. package/frontend/src/lib/analysis/visualization-matrix.js +0 -88
  261. package/frontend/src/lib/analysis/visualization-models.js +0 -369
  262. package/frontend/src/lib/analysis/visualization-time-series.js +0 -144
  263. package/lib/bounded-json-response.mjs +0 -157
  264. package/lib/lookup-response-contract.mjs +0 -346
  265. package/lib/observation.mjs +0 -114
  266. /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
  267. /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
  268. /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
@@ -1,25 +1,576 @@
1
1
  import { compareRdapPublications, compareRegistrySources } from "./registry-comparison.mjs";
2
2
  import { buildRegistryInsights } from "./registry-insights.mjs";
3
- import { WHOISLEUTH_SOURCE_REPOSITORY_URL } from "./project-metadata.mjs";
4
- import { normalizeBoundedSemanticVersion } from "./semantic-version.mjs";
3
+ import { buildPortableGeneratorMetadata, isUriShapedLabel } from "./portable-generator.mjs";
4
+ import { assertBoundedJsonStructure, isSafeJsonObjectKey } from "./bounded-json.mjs";
5
+ import { validHttpDeliveryMetadata, validPagePublicationMetadata, } from "./homepage-metadata-contract.mjs";
6
+ import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
7
+ import { defineSchemaCompatibility } from "../packages/contracts/schema-compatibility.mjs";
8
+ import { isValidAsciiHostname } from "./hostname.mjs";
5
9
  export const LOOKUP_EVIDENCE_SCHEMA = 'whoisleuth.lookup-evidence';
6
- export const LOOKUP_EVIDENCE_SCHEMA_VERSION = 25;
10
+ export const PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION = 26;
11
+ export const LOOKUP_EVIDENCE_SCHEMA_VERSION = 27;
12
+ export const SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS = Object.freeze([
13
+ PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION,
14
+ LOOKUP_EVIDENCE_SCHEMA_VERSION,
15
+ ]);
16
+ export const LOOKUP_EVIDENCE_PORTABLE_MAX_BYTES = 5 * 1024 * 1024;
17
+ export const LOOKUP_EVIDENCE_PORTABLE_MAX_ENTRIES = 20_000;
18
+ export const LOOKUP_EVIDENCE_PORTABLE_MAX_DEPTH = 24;
19
+ export const LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS = 10_000;
20
+ export const LOOKUP_EVIDENCE_PORTABLE_MAX_KEY_LENGTH = 256;
21
+ export const LOOKUP_EVIDENCE_PORTABLE_MAX_STRING_LENGTH = 1024 * 1024;
22
+ export const LOOKUP_EVIDENCE_COMPATIBILITY = defineSchemaCompatibility({
23
+ id: 'export.lookup-evidence',
24
+ kind: 'export',
25
+ schema: LOOKUP_EVIDENCE_SCHEMA,
26
+ currentVersion: LOOKUP_EVIDENCE_SCHEMA_VERSION,
27
+ supportedVersions: SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS,
28
+ acceptsUnversionedLegacy: false,
29
+ futureVersionBehavior: 'reject',
30
+ migration: 'read_only',
31
+ writeSemantics: 'read_only',
32
+ byteBudget: LOOKUP_EVIDENCE_PORTABLE_MAX_BYTES,
33
+ owner: 'lib/evidence-export.mts',
34
+ note: 'Exact v1.47.4 version 26 remains replayable; version 27 is the single v2 successor with bounded homepage and privacy-minimised registration projections.',
35
+ });
7
36
  const REGISTRAR_RDAP_STATUSES = new Set([
8
37
  'success', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled',
9
38
  ]);
39
+ const LOOKUP_QUERY_TYPES = new Set(['domain', 'ipv4', 'ipv6', 'asn']);
40
+ const RDAP_SOURCE_STATUSES = new Set([
41
+ 'success', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled',
42
+ ]);
43
+ const WHOIS_SOURCE_STATUSES = new Set([
44
+ 'complete', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled',
45
+ ]);
46
+ const AVAILABILITY_DIAGNOSTIC_STATUSES = new Set(['complete', 'error', 'disabled', 'not_applicable']);
10
47
  const NETWORK_CONTEXT_STATUSES = new Set(['success', 'partial', 'not_found', 'unsupported', 'error']);
11
48
  const NETWORK_ADDRESS_SOURCES = new Set(['tls_connection', 'dns_a', 'dns_aaaa']);
12
49
  const REVERSE_DNS_STATUSES = new Set(['success', 'partial', 'not_found', 'unsupported', 'skipped', 'error']);
13
50
  const SECURITY_TXT_STATES = new Set(['present', 'stale', 'partial', 'absent', 'malformed', 'unsupported', 'unavailable']);
14
51
  const SSLBL_STATES = new Set(['success', 'stale', 'unavailable']);
15
52
  const SSLBL_VERDICTS = new Set(['listed', 'not_listed', 'inconclusive']);
53
+ const RDAP_ATTEMPT_OUTCOMES = new Set([
54
+ 'success', 'not_found', 'no_results', 'unsupported', 'rate_limited',
55
+ 'server_error', 'client_error', 'invalid_json', 'invalid_response',
56
+ 'timeout', 'network_error',
57
+ ]);
58
+ const WHOIS_QUERY_PROFILES = new Set([
59
+ 'plain-domain', 'denic-domain-ace', 'jprs-domain-english',
60
+ 'registry-domain-unicode', 'not-issued',
61
+ ]);
62
+ const LOOKUP_TIMING_SOURCES = new Set([
63
+ 'rdap', 'whois', 'domain_evidence', 'reverse_dns', 'registrar_rdap',
64
+ 'network_context', 'security_txt', 'external_intelligence',
65
+ 'malware_host_intelligence', 'malware_ioc_intelligence',
66
+ ]);
67
+ const PUBLIC_LOOKUP_AVAILABILITY_ANALYSIS_KEYS = new Set([
68
+ 'applicable', 'type', 'domain', 'state', 'confidence', 'detail', 'source',
69
+ 'rdapServer', 'nameservers', 'statuses', 'registrar', 'registrant', 'abuse',
70
+ 'createdDate', 'expiryDate', 'createdDateIso', 'expiryDateIso', 'domainAgeDays',
71
+ 'expiresInDays', 'privacyProtected', 'dnssec', 'activityStatus',
72
+ 'websiteProbeStatus', 'websiteProbeDetail', 'http', 'deepScanComplete',
73
+ 'faviconHash', 'faviconPHash', 'pageTitle', 'hasPasswordField',
74
+ 'phishingLanguageMatch', 'hasExternalFormAction', 'externalAssetHosts',
75
+ 'pageIdentity', 'credentialSurfaceProfile', 'structuredDataIdentity',
76
+ 'technologyProfile', 'pageRoleProfile', 'clientBehaviorProfile',
77
+ 'securityPosture', 'dns', 'tls', 'hasMx', 'hasNullMx', 'mxHosts', 'hasSpf',
78
+ 'hasDmarc', 'bulkComparison', 'limitations',
79
+ ]);
80
+ const LOOKUP_AVAILABILITY_ANALYSIS_KEYS = new Set([
81
+ ...[...PUBLIC_LOOKUP_AVAILABILITY_ANALYSIS_KEYS]
82
+ .filter((key) => !['registrar', 'registrant', 'abuse'].includes(key)),
83
+ 'registryContactsExcluded',
84
+ ]);
85
+ const LOOKUP_IDN_ANALYSIS_KEYS = new Set([
86
+ 'version', 'mappingVersion', 'asciiDomain', 'unicodeDomain', 'hasIdn',
87
+ 'scripts', 'labels', 'mixedScript', 'skeleton', 'referenceMatches', 'findings',
88
+ 'truncated', 'limitations',
89
+ ]);
90
+ const PRIVATE_EVIDENCE_KEYS = new Set([
91
+ 'authorization', 'proxyauthorization', 'cookie', 'cookies', 'setcookie',
92
+ 'session', 'sessionid', 'sessiontoken', 'token', 'accesstoken', 'refreshtoken',
93
+ 'apikey', 'password', 'secret', 'credentials', 'requestheaders', 'responseheaders',
94
+ 'headers', 'rawbody', 'responsebody',
95
+ ]);
96
+ const SAFE_EVIDENCE_KEYS = new Set([
97
+ 'credentialsurfaceprofile', 'credentialsurfaceversion', 'haspasswordfield', 'tokencount',
98
+ ]);
99
+ const PORTABLE_URL_KEYS = new Set([
100
+ 'endpoint', 'url', 'uri', 'href', 'rdapserver', 'finalurl', 'requestedurl',
101
+ 'requesturl', 'officiallookupurl', 'declaredorigin', 'actionurl', 'from', 'to',
102
+ ]);
103
+ const PORTABLE_ORIGIN_COLLECTION_KEYS = new Set([
104
+ 'embeddedorigins', 'externalactionorigins', 'externalorigins',
105
+ ]);
106
+ // Availability is assembled from a finite set of versioned, bounded analysis
107
+ // models. Portable exports retain only their declared output vocabulary rather
108
+ // than recursively copying arbitrary browser-local extensions. This set is
109
+ // intentionally exact and case-sensitive; new model fields must be reviewed at
110
+ // this privacy boundary before they can enter a portable artefact.
111
+ const LOOKUP_AVAILABILITY_PORTABLE_NESTED_KEYS = new Set([
112
+ // Shared observation and summary fields.
113
+ 'version', 'profileVersion', 'status', 'state', 'source', 'observedAt',
114
+ 'scanMode', 'durationMs', 'complete', 'truncated', 'limitations',
115
+ 'diagnostics', 'detail', 'error', 'count', 'total', 'discarded', 'summary',
116
+ 'findings', 'id', 'label', 'tone', 'category', 'confidence', 'evidence',
117
+ 'value', 'values', 'name', 'type', 'types', 'mode', 'owner', 'target',
118
+ 'url', 'uri', 'href',
119
+ 'priority', 'ttl', 'records',
120
+ // HTTP observation and retained response metadata.
121
+ 'requestUrl', 'finalUrl', 'transportSecurity', 'redirectCount',
122
+ 'redirectLimitReached', 'redirects', 'crossOriginRedirect', 'httpsDowngrade',
123
+ 'attempts', 'response', 'contentType', 'contentLanguage', 'server',
124
+ 'declaredContentLength', 'capturedBodyBytes', 'bodyInspected', 'bodyTruncated',
125
+ 'bodyHash', 'securityHeaders', 'strictTransportSecurity',
126
+ 'contentSecurityPolicy', 'xFrameOptions', 'xContentTypeOptions',
127
+ 'referrerPolicy', 'algorithm', 'scope', 'bytes', 'from', 'to',
128
+ 'queryOmitted', 'pathTruncated', 'outcome', 'httpStatus', 'attemptCount',
129
+ // DNS observation, effective CAA, and HTTPS/SVCB parameter summaries.
130
+ 'a', 'aaaa', 'caa', 'cname', 'dmarc', 'https', 'mx', 'ns', 'ptr', 'soa',
131
+ 'spf', 'delegation', 'caaPolicy', 'policyVersion', 'queryLimit',
132
+ 'queriedOwners', 'effectiveOwner', 'inherited', 'tree', 'registryEvidence',
133
+ 'exchange', 'nsname', 'hostmaster', 'serial', 'refresh', 'retry', 'expire', 'minttl',
134
+ 'tag', 'critical', 'parameters', 'parametersIgnored', 'mandatory', 'alpn',
135
+ 'noDefaultAlpn', 'port', 'ipv4hint', 'ipv6hint', 'opaque', 'key', 'length',
136
+ 'unknownKeys', 'unsupportedMandatoryKeys', 'targetIsOwner',
137
+ 'serviceUnavailable', 'compatible', 'effectiveOwner', 'queriedOwners',
138
+ // TLS profile, certificate, extension, and connection summaries. Runtime
139
+ // authorisation objects are deliberately absent from this portable set.
140
+ 'connectedAddress', 'connectedFamily', 'sniHost', 'protocol', 'alpnProtocol',
141
+ 'cipher', 'ephemeralKey', 'hostname', 'matches', 'validity', 'certificate',
142
+ 'chain', 'chainTruncated', 'standardName', 'size', 'subject', 'issuer',
143
+ 'serialNumber', 'validFrom', 'validTo', 'fingerprintSha1',
144
+ 'fingerprintSha256', 'isCertificateAuthority', 'subjectAltNames',
145
+ 'dnsNames', 'ipAddresses', 'otherNames', 'publicKey', 'bits', 'curve',
146
+ 'signature', 'oid', 'extendedKeyUsage', 'authorityInformationAccess',
147
+ 'ocsp', 'caIssuers', 'unknownMethods', 'extensionProfile', 'parsed', 'partial',
148
+ 'certificatePolicies', 'crlDistributionPoints', 'commonNames',
149
+ 'organizations', 'organizationalUnits', 'countries', 'localities', 'states',
150
+ 'connectionAttempts', 'resolvedAddressCount', 'discardedFields',
151
+ // Page identity, relationships, and bounded fingerprints.
152
+ 'identityVersion', 'documentLanguage', 'canonical', 'metaRefresh',
153
+ 'openGraph', 'title', 'siteName', 'generator', 'forms', 'postCount',
154
+ 'insecureActionCount', 'externalActionOrigins', 'resources', 'byType',
155
+ 'image', 'script', 'stylesheet', 'link', 'frame', 'media', 'object',
156
+ 'externalOrigins', 'embeddedOrigins', 'contactDomains', 'downloads',
157
+ 'explicitCount', 'riskyCount', 'riskyFileTypes', 'trackingIdentifiers',
158
+ 'fingerprints', 'fingerprintVersion', 'exact', 'normalizedHtml',
159
+ 'visibleText', 'domStructure', 'formStructure', 'resourceHosts',
160
+ 'identifiers', 'featureCount', 'nodeCount', 'formCount', 'controlCount',
161
+ 'parser', 'tokenCount', 'tagsExamined', 'discardedUrls', 'formsObserved',
162
+ 'relationshipTagsExamined', 'relationshipUrlsDiscarded',
163
+ 'trackingIdentifiersTruncated',
164
+ // Credential, structured identity, role, behaviour, technology, and posture
165
+ // projections. Only fixed categories/counts and bounded descriptions enter.
166
+ 'credentialSurfaceVersion', 'methods', 'actions', 'missing', 'get', 'post',
167
+ 'dialog', 'other', 'sameOrigin', 'external', 'cleartext', 'unclassified',
168
+ 'inputs', 'classifiedCount', 'categories', 'password', 'email', 'username',
169
+ 'one_time_code', 'payment', 'inputsObserved', 'classifiedInputs',
170
+ 'unclassifiedActions', 'structuredDataVersion', 'scriptsObserved',
171
+ 'scriptsExamined', 'charactersExamined', 'documentsParsed', 'malformedScripts',
172
+ 'externalScriptsSkipped', 'objectsExamined', 'arrayItemsExamined',
173
+ 'discardedProperties', 'entities', 'declaredOrigin', 'sameAsHosts',
174
+ 'pageRoleProfileVersion', 'primaryRole', 'role', 'rolesObserved',
175
+ 'clientBehaviorProfileVersion', 'scriptSummary', 'elementsObserved',
176
+ 'referencedScripts', 'inlineScripts', 'moduleScripts', 'indicators',
177
+ 'evidenceClass', 'occurrences', 'explanation', 'indicatorsObserved',
178
+ 'scriptElementsExamined', 'inlineCharactersExamined', 'technologyProfile',
179
+ 'browserLibraryProfile', 'catalog', 'knownExploitedCatalog', 'releasedAt',
180
+ 'sourceRevision', 'apparentVersion', 'detectionMethods', 'advisoryCount',
181
+ 'highestSeverity', 'advisoryIdentifiers', 'knownExploitedCount',
182
+ 'knownExploitedIdentifiers', 'weaknessClasses', 'htmlEvaluated',
183
+ 'generatorEvaluated', 'serverEvaluated', 'resourceOriginsEvaluated',
184
+ 'passiveHeadersEvaluated', 'tagLimitReached', 'referencesExamined',
185
+ 'inlineScriptsExamined', 'inlineSignatureCharactersExamined',
186
+ 'inlineSignatureTimedOut', 'inlineSignatureUnavailable', 'catalogComponents',
187
+ 'advisoryMatches', 'knownExploitedMatches', 'postureVersion',
188
+ 'potentialExposure', 'observedAbsence', 'unavailable',
189
+ // Bounded cross-row comparison evidence.
190
+ 'bulkComparison', 'technology', 'tls', 'ids', 'issuerLabel', 'spkiSha256',
191
+ ]);
192
+ const LOOKUP_AVAILABILITY_CREDENTIAL_CATEGORY_KEYS = new Set([
193
+ 'password', 'email', 'username', 'one_time_code', 'payment',
194
+ ]);
195
+ const LOOKUP_AVAILABILITY_VALUE_PATHS = new Set([
196
+ 'dns.records.caa',
197
+ 'dns.caaPolicy.records',
198
+ 'http.response.bodyHash',
199
+ 'pageIdentity.trackingIdentifiers',
200
+ 'pageIdentity.fingerprints.exact',
201
+ 'pageIdentity.fingerprints.normalizedHtml',
202
+ 'pageIdentity.fingerprints.visibleText',
203
+ 'pageIdentity.fingerprints.domStructure',
204
+ 'pageIdentity.fingerprints.domStructure.similarity',
205
+ 'pageIdentity.fingerprints.formStructure',
206
+ 'pageIdentity.fingerprints.resourceHosts',
207
+ 'pageIdentity.fingerprints.identifiers',
208
+ 'pageIdentity.fingerprints.identifiers.values',
209
+ ]);
210
+ const LOOKUP_AVAILABILITY_OWNER_PATHS = new Set([
211
+ 'dns.records.https',
212
+ 'dns.caaPolicy.queriedOwners',
213
+ ]);
16
214
  function recordOrNull(value) {
17
215
  return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
18
216
  }
19
- function cloneJson(value) {
20
- if (value === undefined)
217
+ function normalizedEvidenceKey(value) {
218
+ return value.toLowerCase().replace(/[^a-z0-9]/gu, '');
219
+ }
220
+ function privateEvidenceKey(value, item) {
221
+ const normalized = normalizedEvidenceKey(value);
222
+ if (SAFE_EVIDENCE_KEYS.has(normalized))
223
+ return false;
224
+ if (normalized === 'password' && (typeof item === 'number' || typeof item === 'boolean'))
225
+ return false;
226
+ return PRIVATE_EVIDENCE_KEYS.has(normalized)
227
+ || normalized.includes('authorization')
228
+ || normalized.includes('authheader')
229
+ || normalized.includes('cookie')
230
+ || normalized.includes('apikey')
231
+ || normalized.includes('secret')
232
+ || normalized.includes('sessionkey')
233
+ || normalized.includes('passwordvalue')
234
+ || normalized.endsWith('password')
235
+ || /(?:access|auth|bearer|csrf|refresh|session)token$/u.test(normalized)
236
+ || /(?:request|response|authorization|proxy)headers?$/u.test(normalized)
237
+ || (normalized.includes('credential') && !normalized.startsWith('credentialsurface'));
238
+ }
239
+ function portableUri(value) {
240
+ const text = boundedString(value, 2048);
241
+ if (!text)
21
242
  return null;
22
- return JSON.parse(JSON.stringify(value));
243
+ try {
244
+ const url = new URL(text);
245
+ if (!['http:', 'https:', 'mailto:', 'tel:', 'dns:', 'openpgp4fpr:'].includes(url.protocol)
246
+ || url.username
247
+ || url.password)
248
+ return null;
249
+ if (url.protocol === 'http:' || url.protocol === 'https:') {
250
+ url.search = '';
251
+ url.hash = '';
252
+ }
253
+ return url.toString().slice(0, 2048);
254
+ }
255
+ catch {
256
+ return null;
257
+ }
258
+ }
259
+ function portableOrigin(value) {
260
+ const text = boundedString(value, 2048);
261
+ if (!text)
262
+ return null;
263
+ try {
264
+ const url = new URL(text);
265
+ return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password
266
+ ? url.origin
267
+ : null;
268
+ }
269
+ catch {
270
+ return null;
271
+ }
272
+ }
273
+ function consumePortableProjectionEntry(state, depth) {
274
+ state.entries += 1;
275
+ if (state.entries > LOOKUP_EVIDENCE_PORTABLE_MAX_ENTRIES) {
276
+ throw new TypeError(`Lookup evidence projection exceeds the ${LOOKUP_EVIDENCE_PORTABLE_MAX_ENTRIES.toLocaleString('en')}-entry portable limit.`);
277
+ }
278
+ if (depth > LOOKUP_EVIDENCE_PORTABLE_MAX_DEPTH) {
279
+ throw new TypeError(`Lookup evidence projection exceeds the ${LOOKUP_EVIDENCE_PORTABLE_MAX_DEPTH}-level portable nesting limit.`);
280
+ }
281
+ }
282
+ function projectPortableString(value) {
283
+ const sanitized = value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, ' ');
284
+ return sanitized.length <= LOOKUP_EVIDENCE_PORTABLE_MAX_STRING_LENGTH
285
+ ? sanitized
286
+ : sanitized.slice(0, LOOKUP_EVIDENCE_PORTABLE_MAX_STRING_LENGTH);
287
+ }
288
+ function projectLookupEvidenceAvailabilityString(value) {
289
+ const sanitized = projectPortableString(value)
290
+ .replace(/[\u0080-\u009f]/gu, ' ')
291
+ .replace(/\p{Default_Ignorable_Code_Point}/gu, '');
292
+ return isUriShapedLabel(value) ? null : sanitized;
293
+ }
294
+ function projectLookupEvidencePrivacySafeTreeValue(value, state, depth) {
295
+ consumePortableProjectionEntry(state, depth);
296
+ if (value === null || typeof value === 'boolean' || typeof value === 'number')
297
+ return value;
298
+ if (typeof value === 'string')
299
+ return projectPortableString(value);
300
+ if (Array.isArray(value)) {
301
+ return value.slice(0, LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS)
302
+ .map((item) => projectLookupEvidencePrivacySafeTreeValue(item, state, depth + 1));
303
+ }
304
+ const source = recordOrNull(value);
305
+ if (!source)
306
+ return null;
307
+ const output = {};
308
+ for (const [key, item] of Object.entries(source).slice(0, LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS)) {
309
+ if (!isSafeJsonObjectKey(key))
310
+ throw new TypeError('Lookup evidence projection contains an unsafe object key.');
311
+ const normalized = normalizedEvidenceKey(key);
312
+ if (privateEvidenceKey(normalized, item))
313
+ continue;
314
+ if (typeof item === 'string' && PORTABLE_URL_KEYS.has(normalized)) {
315
+ consumePortableProjectionEntry(state, depth + 1);
316
+ output[key] = portableUri(item);
317
+ continue;
318
+ }
319
+ output[key] = projectLookupEvidencePrivacySafeTreeValue(item, state, depth + 1);
320
+ }
321
+ return output;
322
+ }
323
+ export function projectLookupEvidencePrivacySafeTree(value) {
324
+ return projectLookupEvidencePrivacySafeTreeValue(value, { entries: 0 }, 0);
325
+ }
326
+ function projectLookupEvidenceAvailabilityPublicValue(value, parentKey, state, depth) {
327
+ consumePortableProjectionEntry(state, depth);
328
+ if (value === null || typeof value === 'boolean' || typeof value === 'number')
329
+ return value;
330
+ if (typeof value === 'string') {
331
+ const normalizedParent = normalizedEvidenceKey(parentKey || '');
332
+ if (PORTABLE_URL_KEYS.has(normalizedParent))
333
+ return portableUri(value);
334
+ if (PORTABLE_ORIGIN_COLLECTION_KEYS.has(normalizedParent))
335
+ return portableOrigin(value);
336
+ return projectPortableString(value);
337
+ }
338
+ if (Array.isArray(value)) {
339
+ return value.slice(0, LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS)
340
+ .map((item) => projectLookupEvidenceAvailabilityPublicValue(item, parentKey, state, depth + 1));
341
+ }
342
+ const source = recordOrNull(value);
343
+ if (!source)
344
+ return null;
345
+ const output = {};
346
+ for (const [key, item] of Object.entries(source).slice(0, LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS)) {
347
+ if (!LOOKUP_AVAILABILITY_PORTABLE_NESTED_KEYS.has(key)
348
+ || privateEvidenceKey(key, item))
349
+ continue;
350
+ output[key] = projectLookupEvidenceAvailabilityPublicValue(item, key, state, depth + 1);
351
+ }
352
+ return output;
353
+ }
354
+ function projectLookupEvidenceAvailabilityValue(value, path, state, depth) {
355
+ consumePortableProjectionEntry(state, depth);
356
+ if (value === null || typeof value === 'boolean' || typeof value === 'number')
357
+ return value;
358
+ if (typeof value === 'string') {
359
+ const normalizedParent = normalizedEvidenceKey(path.at(-1) || '');
360
+ if (PORTABLE_URL_KEYS.has(normalizedParent))
361
+ return portableUri(value);
362
+ if (PORTABLE_ORIGIN_COLLECTION_KEYS.has(normalizedParent))
363
+ return portableOrigin(value);
364
+ return projectLookupEvidenceAvailabilityString(value);
365
+ }
366
+ if (Array.isArray(value)) {
367
+ return value.slice(0, LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS)
368
+ .map((item) => projectLookupEvidenceAvailabilityValue(item, path, state, depth + 1));
369
+ }
370
+ const source = recordOrNull(value);
371
+ if (!source)
372
+ return null;
373
+ const output = {};
374
+ for (const [key, item] of Object.entries(source).slice(0, LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS)) {
375
+ if (!LOOKUP_AVAILABILITY_PORTABLE_NESTED_KEYS.has(key)
376
+ || privateEvidenceKey(key, item))
377
+ continue;
378
+ const parentPath = path.join('.');
379
+ if (LOOKUP_AVAILABILITY_CREDENTIAL_CATEGORY_KEYS.has(key)
380
+ && (parentPath !== 'credentialSurfaceProfile.inputs.categories'
381
+ || !Number.isSafeInteger(item)
382
+ || Number(item) < 0
383
+ || Number(item) > LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS))
384
+ continue;
385
+ if (key === 'owner'
386
+ && (!LOOKUP_AVAILABILITY_OWNER_PATHS.has(parentPath) || typeof item !== 'string'))
387
+ continue;
388
+ if (key === 'value'
389
+ && (!LOOKUP_AVAILABILITY_VALUE_PATHS.has(parentPath)
390
+ || (item !== null && typeof item !== 'string')))
391
+ continue;
392
+ output[key] = projectLookupEvidenceAvailabilityValue(item, [...path, key], state, depth + 1);
393
+ }
394
+ return output;
395
+ }
396
+ /**
397
+ * Canonical portable projection for the bounded Lookup availability models.
398
+ * The root and every nested record are positive allowlists, so an imported
399
+ * extension cannot become authenticated evidence merely by avoiding a
400
+ * credential-key denylist.
401
+ */
402
+ function projectLookupEvidenceAvailabilityWithKeys(value, rootKeys, registryContactsExcluded, currentPrivacyRules) {
403
+ const source = recordOrNull(value);
404
+ if (!source)
405
+ return null;
406
+ const state = { entries: 0 };
407
+ consumePortableProjectionEntry(state, 0);
408
+ const output = {};
409
+ for (const key of rootKeys) {
410
+ if (!Object.hasOwn(source, key))
411
+ continue;
412
+ const item = source[key];
413
+ if (privateEvidenceKey(key, item))
414
+ continue;
415
+ output[key] = currentPrivacyRules
416
+ ? projectLookupEvidenceAvailabilityValue(item, [key], state, 1)
417
+ : projectLookupEvidenceAvailabilityPublicValue(item, key, state, 1);
418
+ }
419
+ if (registryContactsExcluded)
420
+ output.registryContactsExcluded = true;
421
+ const pageIdentity = recordOrNull(source.pageIdentity);
422
+ const publicationMetadata = pageIdentity?.publicationMetadata;
423
+ if (publicationMetadata !== undefined) {
424
+ if (!['success', 'partial'].includes(String(pageIdentity?.status))
425
+ || !validPagePublicationMetadata(publicationMetadata)) {
426
+ throw new TypeError('Lookup evidence contains invalid page publication metadata.');
427
+ }
428
+ const projectedPageIdentity = recordOrNull(output.pageIdentity);
429
+ if (projectedPageIdentity) {
430
+ projectedPageIdentity.publicationMetadata = projectLookupEvidencePrivacySafeTree(publicationMetadata);
431
+ }
432
+ }
433
+ const httpResponse = recordOrNull(recordOrNull(source.http)?.response);
434
+ const sourceHttp = recordOrNull(source.http);
435
+ const deliveryMetadata = httpResponse?.deliveryMetadata;
436
+ if (deliveryMetadata !== undefined) {
437
+ if (!['success', 'partial'].includes(String(sourceHttp?.status))
438
+ || !validHttpDeliveryMetadata(deliveryMetadata)) {
439
+ throw new TypeError('Lookup evidence contains invalid HTTP delivery metadata.');
440
+ }
441
+ const projectedHttp = recordOrNull(output.http);
442
+ const projectedResponse = recordOrNull(projectedHttp?.response);
443
+ if (projectedResponse) {
444
+ projectedResponse.deliveryMetadata = projectLookupEvidencePrivacySafeTree(deliveryMetadata);
445
+ }
446
+ }
447
+ return output;
448
+ }
449
+ /** Exact availability projection used only to validate public schema 26. */
450
+ export function projectLookupEvidenceAvailabilityPublic(value) {
451
+ return projectLookupEvidenceAvailabilityWithKeys(value, PUBLIC_LOOKUP_AVAILABILITY_ANALYSIS_KEYS, false, false);
452
+ }
453
+ /**
454
+ * Current availability projection. Registry-derived registrar, registrant,
455
+ * and abuse contact routes are deliberately excluded at the root boundary;
456
+ * the marker prevents that privacy omission from being read as source absence.
457
+ */
458
+ export function projectLookupEvidenceAvailability(value) {
459
+ return projectLookupEvidenceAvailabilityWithKeys(value, LOOKUP_AVAILABILITY_ANALYSIS_KEYS, true, true);
460
+ }
461
+ export function assertLookupEvidencePrivacySafeTree(value) {
462
+ assertLookupEvidencePortableTree(value);
463
+ const pending = [value];
464
+ while (pending.length) {
465
+ const current = pending.pop();
466
+ if (current === null || typeof current === 'boolean' || typeof current === 'number')
467
+ continue;
468
+ if (typeof current === 'string') {
469
+ if (projectLookupEvidencePrivacySafeTree(current) !== current) {
470
+ throw new TypeError('Lookup evidence contains a non-portable string value.');
471
+ }
472
+ continue;
473
+ }
474
+ if (Array.isArray(current)) {
475
+ for (const item of current)
476
+ pending.push(item);
477
+ continue;
478
+ }
479
+ const source = recordOrNull(current);
480
+ if (!source)
481
+ throw new TypeError('Lookup evidence contains a non-JSON value.');
482
+ for (const [key, item] of Object.entries(source)) {
483
+ const normalized = normalizedEvidenceKey(key);
484
+ if (privateEvidenceKey(normalized, item)) {
485
+ throw new TypeError('Lookup evidence contains excluded private request or session material.');
486
+ }
487
+ if (typeof item === 'string' && PORTABLE_URL_KEYS.has(normalized) && portableUri(item) !== item) {
488
+ throw new TypeError('Lookup evidence contains a credential-bearing or non-portable URL.');
489
+ }
490
+ pending.push(item);
491
+ }
492
+ }
493
+ }
494
+ function projectedKnownRecord(value, keys) {
495
+ const source = recordOrNull(value);
496
+ if (!source)
497
+ return null;
498
+ const output = {};
499
+ for (const key of keys) {
500
+ if (Object.hasOwn(source, key))
501
+ output[key] = projectLookupEvidencePrivacySafeTree(source[key]);
502
+ }
503
+ return output;
504
+ }
505
+ export function assertLookupEvidencePortableTree(value) {
506
+ const pending = [{ value, depth: 0 }];
507
+ let entries = 0;
508
+ while (pending.length) {
509
+ const current = pending.pop();
510
+ if (!current)
511
+ break;
512
+ entries += 1;
513
+ if (entries > LOOKUP_EVIDENCE_PORTABLE_MAX_ENTRIES) {
514
+ throw new TypeError(`Lookup evidence exceeds the ${LOOKUP_EVIDENCE_PORTABLE_MAX_ENTRIES.toLocaleString('en')}-entry portable limit.`);
515
+ }
516
+ if (current.depth > LOOKUP_EVIDENCE_PORTABLE_MAX_DEPTH) {
517
+ throw new TypeError(`Lookup evidence exceeds the ${LOOKUP_EVIDENCE_PORTABLE_MAX_DEPTH}-level portable nesting limit.`);
518
+ }
519
+ if (typeof current.value === 'string') {
520
+ if (current.value.length > LOOKUP_EVIDENCE_PORTABLE_MAX_STRING_LENGTH) {
521
+ throw new TypeError('Lookup evidence contains an over-bound string.');
522
+ }
523
+ continue;
524
+ }
525
+ if (current.value === null || typeof current.value === 'boolean')
526
+ continue;
527
+ if (typeof current.value === 'number') {
528
+ if (!Number.isFinite(current.value)) {
529
+ throw new TypeError('Lookup evidence contains a non-finite number.');
530
+ }
531
+ continue;
532
+ }
533
+ if (Array.isArray(current.value)) {
534
+ if (current.value.length > LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS) {
535
+ throw new TypeError('Lookup evidence contains an over-bound array.');
536
+ }
537
+ for (const item of current.value)
538
+ pending.push({ value: item, depth: current.depth + 1 });
539
+ continue;
540
+ }
541
+ if (current.value && typeof current.value === 'object') {
542
+ const source = current.value;
543
+ const prototype = Object.getPrototypeOf(source);
544
+ if (prototype !== Object.prototype && prototype !== null) {
545
+ throw new TypeError('Lookup evidence contains a non-JSON object.');
546
+ }
547
+ const keys = Object.keys(source);
548
+ if (keys.length > LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS
549
+ || keys.some((key) => key.length > LOOKUP_EVIDENCE_PORTABLE_MAX_KEY_LENGTH
550
+ || /[\u0000-\u001f\u007f]/u.test(key)
551
+ || !isSafeJsonObjectKey(key))) {
552
+ throw new TypeError('Lookup evidence contains an over-bound object.');
553
+ }
554
+ for (const item of Object.values(source))
555
+ pending.push({ value: item, depth: current.depth + 1 });
556
+ continue;
557
+ }
558
+ throw new TypeError('Lookup evidence contains a non-JSON value.');
559
+ }
560
+ }
561
+ export function serializeLookupEvidence(value, pretty = false) {
562
+ assertLookupEvidencePortableTree(value);
563
+ let output;
564
+ try {
565
+ output = JSON.stringify(value, null, pretty ? 2 : 0);
566
+ }
567
+ catch {
568
+ throw new TypeError('Lookup evidence could not be serialized as JSON.');
569
+ }
570
+ if (new TextEncoder().encode(output).byteLength > LOOKUP_EVIDENCE_PORTABLE_MAX_BYTES) {
571
+ throw new TypeError('Lookup evidence exceeds the 5 MiB portable file limit.');
572
+ }
573
+ return output;
23
574
  }
24
575
  function boundedString(value, maximum) {
25
576
  if (typeof value !== 'string' || value.length > maximum || /[\u0000-\u001f\u007f]/.test(value))
@@ -34,18 +585,7 @@ function boundedHttpStatus(value) {
34
585
  }
35
586
  function boundedTimestamp(value) {
36
587
  const text = boundedString(value, 64);
37
- if (!text)
38
- return null;
39
- const time = Date.parse(text);
40
- return Number.isFinite(time) ? new Date(time).toISOString() : null;
41
- }
42
- function boundedSemanticVersion(value) {
43
- try {
44
- return normalizeBoundedSemanticVersion(value, 'Evidence application');
45
- }
46
- catch {
47
- return null;
48
- }
588
+ return text ? normalizeExplicitIsoTimestamp(text) : null;
49
589
  }
50
590
  function boundedEndpoint(value) {
51
591
  const text = boundedString(value, 2048);
@@ -70,11 +610,76 @@ function boundedStringList(value, count, length) {
70
610
  .filter((item) => item !== null))];
71
611
  }
72
612
  function boundedHostname(value) {
73
- const text = boundedString(value, 253)?.toLowerCase().replace(/\.+$/u, '') || null;
74
- if (!text || !text.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/iu.test(label))) {
613
+ const text = boundedString(value, 253)?.replace(/\.+$/u, '') || null;
614
+ return text && isValidAsciiHostname(text, { requireDot: false }) ? text.toLowerCase() : null;
615
+ }
616
+ function canonicalIpv4(value) {
617
+ const text = boundedString(value, 64);
618
+ if (!text || !/^\d{1,3}(?:\.\d{1,3}){3}$/u.test(text))
619
+ return null;
620
+ const octets = text.split('.').map(Number);
621
+ if (octets.some((octet) => octet > 255))
622
+ return null;
623
+ return octets.join('.');
624
+ }
625
+ function canonicalIpv6(value) {
626
+ const text = boundedString(value, 64)?.toLowerCase();
627
+ if (!text || !text.includes(':') || text.includes('%'))
75
628
  return null;
629
+ try {
630
+ const hostname = new URL(`http://[${text}]/`).hostname;
631
+ return hostname.startsWith('[') && hostname.endsWith(']')
632
+ ? hostname.slice(1, -1).toLowerCase()
633
+ : null;
76
634
  }
77
- return text;
635
+ catch {
636
+ return null;
637
+ }
638
+ }
639
+ function canonicalAsn(value) {
640
+ const text = boundedString(value, 32);
641
+ const match = text?.match(/^(?:AS)?(\d+)$/iu);
642
+ if (!match)
643
+ return null;
644
+ const number = Number(match[1]);
645
+ return Number.isInteger(number) && number >= 0 && number <= 4_294_967_295
646
+ ? `AS${number}`
647
+ : null;
648
+ }
649
+ export function projectLookupEvidenceQuery(value) {
650
+ const source = recordOrNull(value) || {};
651
+ const type = typeof source.type === 'string' && LOOKUP_QUERY_TYPES.has(source.type)
652
+ ? source.type
653
+ : 'domain';
654
+ if (type === 'domain') {
655
+ const inputHostname = [source.inputHostname, source.registrableDomain, source.submitted, source.query]
656
+ .map(boundedHostname)
657
+ .find((item) => Boolean(item)) || 'unknown.invalid';
658
+ const suppliedRegistrableDomain = boundedHostname(source.registrableDomain);
659
+ const registrableDomain = suppliedRegistrableDomain
660
+ && (inputHostname === suppliedRegistrableDomain || inputHostname.endsWith(`.${suppliedRegistrableDomain}`))
661
+ ? suppliedRegistrableDomain
662
+ : inputHostname;
663
+ return {
664
+ submitted: inputHostname,
665
+ type,
666
+ inputHostname,
667
+ registrableDomain,
668
+ isSubdomain: inputHostname !== registrableDomain,
669
+ };
670
+ }
671
+ const candidate = source.submitted ?? source.query;
672
+ const submitted = type === 'ipv4'
673
+ ? canonicalIpv4(candidate)
674
+ : type === 'ipv6'
675
+ ? canonicalIpv6(candidate)
676
+ : canonicalAsn(candidate);
677
+ if (!submitted)
678
+ throw new TypeError(`Lookup evidence contains an invalid ${type} query.`);
679
+ return { submitted, type, inputHostname: null, registrableDomain: null, isSubdomain: false };
680
+ }
681
+ function completeTerminalObservation(status, truncated) {
682
+ return truncated !== true && (status === 'success' || status === 'not_found');
78
683
  }
79
684
  function boundedPublishedUri(value, protocols) {
80
685
  const text = boundedString(value, 2048);
@@ -204,18 +809,189 @@ function sslblSource(value) {
204
809
  limitations: boundedStringList(source.limitations, 10, 300),
205
810
  };
206
811
  }
207
- function networkAttempt(value) {
812
+ function rdapAttempt(value) {
208
813
  const attempt = recordOrNull(value) || {};
209
814
  const endpoint = boundedEndpoint(attempt.endpoint);
210
815
  return {
211
816
  endpoint,
212
817
  transportSecurity: endpoint ? endpoint.startsWith('https:') ? 'https' : 'http' : null,
213
818
  status: boundedHttpStatus(attempt.status),
214
- outcome: boundedString(attempt.outcome, 40),
819
+ outcome: typeof attempt.outcome === 'string' && RDAP_ATTEMPT_OUTCOMES.has(attempt.outcome)
820
+ ? attempt.outcome
821
+ : 'unknown',
215
822
  detail: boundedString(attempt.detail, 240),
216
823
  selected: attempt.selected === true,
217
824
  };
218
825
  }
826
+ function boundedRdapAttempts(value, maximum) {
827
+ return (Array.isArray(value) ? value : [])
828
+ .slice(0, maximum)
829
+ .map(rdapAttempt);
830
+ }
831
+ function whoisChainHop(value) {
832
+ const hop = recordOrNull(value) || {};
833
+ const queryProfile = typeof hop.queryProfile === 'string' && WHOIS_QUERY_PROFILES.has(hop.queryProfile)
834
+ ? hop.queryProfile
835
+ : null;
836
+ const error = boundedString(hop.error, 240);
837
+ return {
838
+ server: boundedHostname(hop.server),
839
+ address: boundedString(hop.address, 64),
840
+ queriedAt: boundedTimestamp(hop.queriedAt),
841
+ queryProfile,
842
+ responseEncoding: hop.responseEncoding === 'utf-8' ? 'utf-8' : null,
843
+ status: queryProfile === 'not-issued'
844
+ ? 'not_issued'
845
+ : error
846
+ ? 'error'
847
+ : typeof hop.response === 'string'
848
+ ? 'success'
849
+ : 'unknown',
850
+ detail: error,
851
+ };
852
+ }
853
+ function boundedWhoisChain(value) {
854
+ return (Array.isArray(value) ? value : [])
855
+ .slice(0, 16)
856
+ .map(whoisChainHop);
857
+ }
858
+ function lookupTimingDiagnostics(value) {
859
+ const source = recordOrNull(value);
860
+ if (source?.version !== 1)
861
+ return null;
862
+ return {
863
+ version: 1,
864
+ totalMs: boundedInteger(source.totalMs, 120_000),
865
+ sources: (Array.isArray(source.sources) ? source.sources : [])
866
+ .slice(0, LOOKUP_TIMING_SOURCES.size)
867
+ .flatMap((item) => {
868
+ const entry = recordOrNull(item);
869
+ if (!entry
870
+ || typeof entry.source !== 'string'
871
+ || !LOOKUP_TIMING_SOURCES.has(entry.source)
872
+ || (entry.outcome !== 'fulfilled' && entry.outcome !== 'rejected'))
873
+ return [];
874
+ return [{
875
+ source: entry.source,
876
+ outcome: entry.outcome,
877
+ durationMs: boundedInteger(entry.durationMs, 120_000),
878
+ completedAfterMs: boundedInteger(entry.completedAfterMs, 120_000),
879
+ }];
880
+ }),
881
+ };
882
+ }
883
+ function registryAccessDiagnostics(value) {
884
+ const source = recordOrNull(value);
885
+ if (!source)
886
+ return null;
887
+ const officialLookupUrl = portableUri(source.officialLookupUrl);
888
+ return {
889
+ suffix: boundedHostname(source.suffix),
890
+ coverageState: boundedString(source.coverageState, 40),
891
+ whoisAccessProfile: boundedString(source.whoisAccessProfile, 80),
892
+ rdapAccessProfile: boundedString(source.rdapAccessProfile, 80),
893
+ ...(officialLookupUrl ? { officialLookupUrl } : {}),
894
+ limitation: boundedString(source.limitation, 500),
895
+ authority: source.authority === 'context_only' ? 'context_only' : null,
896
+ };
897
+ }
898
+ function registrarRdapDiagnostics(value) {
899
+ const source = recordOrNull(value);
900
+ if (!source)
901
+ return null;
902
+ const endpoint = boundedEndpoint(source.endpoint);
903
+ return {
904
+ status: sourceStatus(source.status, REGISTRAR_RDAP_STATUSES, 'error'),
905
+ endpoint,
906
+ transportSecurity: endpoint ? endpoint.startsWith('https:') ? 'https' : 'http' : null,
907
+ httpStatus: boundedHttpStatus(source.httpStatus),
908
+ fetchedAt: boundedTimestamp(source.fetchedAt),
909
+ attempt: source.attempt === null || source.attempt === undefined ? null : rdapAttempt(source.attempt),
910
+ };
911
+ }
912
+ function lookupDiagnostics(value) {
913
+ const source = recordOrNull(value) || {};
914
+ const rdap = recordOrNull(source.rdap) || {};
915
+ const whois = recordOrNull(source.whois) || {};
916
+ const availability = recordOrNull(source.availability) || {};
917
+ const reverseDns = recordOrNull(source.reverseDns);
918
+ const network = recordOrNull(source.network);
919
+ const securityTxt = recordOrNull(source.securityTxt);
920
+ const sslbl = recordOrNull(source.sslbl);
921
+ const rdapEndpoint = boundedEndpoint(rdap.endpoint);
922
+ const networkEndpoint = boundedEndpoint(network?.endpoint);
923
+ const networkPublicationAvailable = network?.status === 'success' || network?.status === 'partial';
924
+ const securityTxtEndpoint = boundedPublishedUri(securityTxt?.endpoint, ['https:']);
925
+ const version = boundedInteger(source.version, 10);
926
+ const timing = lookupTimingDiagnostics(source.timing);
927
+ const registryAccess = registryAccessDiagnostics(source.registryAccess);
928
+ const registrar = registrarRdapDiagnostics(rdap.registrar);
929
+ return {
930
+ ...(version === null ? {} : { version }),
931
+ ...(timing ? { timing } : {}),
932
+ ...(registryAccess ? { registryAccess } : {}),
933
+ rdap: {
934
+ status: sourceStatus(rdap.status, RDAP_SOURCE_STATUSES, 'error'),
935
+ errorCode: boundedString(rdap.errorCode, 80),
936
+ endpoint: rdapEndpoint,
937
+ transportSecurity: rdapEndpoint ? rdapEndpoint.startsWith('https:') ? 'https' : 'http' : null,
938
+ httpStatus: boundedHttpStatus(rdap.httpStatus),
939
+ fetchedAt: boundedTimestamp(rdap.fetchedAt),
940
+ attempts: boundedRdapAttempts(rdap.attempts, 16),
941
+ ...(registrar ? { registrar } : {}),
942
+ },
943
+ whois: {
944
+ status: sourceStatus(whois.status, WHOIS_SOURCE_STATUSES, 'error'),
945
+ errorCode: boundedString(whois.errorCode, 80),
946
+ queriedAt: boundedTimestamp(whois.queriedAt),
947
+ authoritativeHop: boundedHostname(whois.authoritativeHop),
948
+ failedHop: boundedHostname(whois.failedHop),
949
+ conflictingHop: boundedHostname(whois.conflictingHop),
950
+ },
951
+ ...(recordOrNull(source.availability) ? { availability: {
952
+ status: sourceStatus(availability.status, AVAILABILITY_DIAGNOSTIC_STATUSES, 'error'),
953
+ errorCode: boundedString(availability.errorCode, 80),
954
+ resultState: boundedString(availability.resultState, 40),
955
+ } } : {}),
956
+ ...(reverseDns ? { reverseDns: {
957
+ status: sourceStatus(reverseDns.status, REVERSE_DNS_STATUSES, 'error'),
958
+ observedAt: boundedTimestamp(reverseDns.observedAt),
959
+ complete: completeTerminalObservation(sourceStatus(reverseDns.status, REVERSE_DNS_STATUSES, 'error'), reverseDns.truncated),
960
+ truncated: reverseDns.truncated === true,
961
+ } } : {}),
962
+ ...(network ? { network: {
963
+ status: sourceStatus(network.status, NETWORK_CONTEXT_STATUSES, 'error'),
964
+ address: networkPublicationAvailable ? boundedString(network.address, 64) : null,
965
+ family: networkPublicationAvailable && (network.family === 4 || network.family === 6) ? network.family : null,
966
+ addressSource: networkPublicationAvailable && typeof network.addressSource === 'string' && NETWORK_ADDRESS_SOURCES.has(network.addressSource)
967
+ ? network.addressSource
968
+ : null,
969
+ endpoint: networkPublicationAvailable ? networkEndpoint : null,
970
+ transportSecurity: networkPublicationAvailable && networkEndpoint ? networkEndpoint.startsWith('https:') ? 'https' : 'http' : null,
971
+ httpStatus: networkPublicationAvailable ? boundedHttpStatus(network.httpStatus) : null,
972
+ fetchedAt: networkPublicationAvailable ? boundedTimestamp(network.fetchedAt) : null,
973
+ attempts: networkPublicationAvailable ? boundedRdapAttempts(network.attempts, 3) : [],
974
+ } } : {}),
975
+ ...(securityTxt ? { securityTxt: {
976
+ status: boundedString(securityTxt.status, 40),
977
+ state: typeof securityTxt.state === 'string' && SECURITY_TXT_STATES.has(securityTxt.state)
978
+ ? securityTxt.state
979
+ : null,
980
+ endpoint: securityTxtEndpoint,
981
+ httpStatus: boundedHttpStatus(securityTxt.httpStatus),
982
+ observedAt: boundedTimestamp(securityTxt.observedAt),
983
+ complete: securityTxt.complete === true,
984
+ truncated: securityTxt.truncated === true,
985
+ } } : {}),
986
+ ...(sslbl ? { sslbl: {
987
+ status: typeof sslbl.status === 'string' && SSLBL_STATES.has(sslbl.status) ? sslbl.status : null,
988
+ verdict: typeof sslbl.verdict === 'string' && SSLBL_VERDICTS.has(sslbl.verdict) ? sslbl.verdict : null,
989
+ observedAt: boundedTimestamp(sslbl.observedAt),
990
+ complete: sslbl.complete === true,
991
+ snapshotUpdatedAt: boundedTimestamp(sslbl.snapshotUpdatedAt),
992
+ } } : {}),
993
+ };
994
+ }
219
995
  function networkSource(value) {
220
996
  const source = recordOrNull(value);
221
997
  if (!source
@@ -228,6 +1004,7 @@ function networkSource(value) {
228
1004
  const network = recordOrNull(source.network);
229
1005
  const diagnostics = recordOrNull(source.diagnostics);
230
1006
  const rdapEndpoint = boundedEndpoint(rdap?.endpoint);
1007
+ const publicationAvailable = source.status === 'success' || source.status === 'partial';
231
1008
  return {
232
1009
  contextVersion: 1,
233
1010
  version: source.version === 1 ? 1 : null,
@@ -236,7 +1013,7 @@ function networkSource(value) {
236
1013
  scanMode: source.scanMode === 'deep' ? 'deep' : null,
237
1014
  source: source.source === 'ip_rdap' ? 'ip_rdap' : null,
238
1015
  durationMs: boundedInteger(source.durationMs, 120_000),
239
- complete: source.complete === true,
1016
+ complete: completeTerminalObservation(source.status, source.truncated),
240
1017
  truncated: source.truncated === true,
241
1018
  limitations: boundedStringList(source.limitations, 10, 300),
242
1019
  diagnostics: diagnostics ? {
@@ -249,7 +1026,7 @@ function networkSource(value) {
249
1026
  cidrCount: boundedInteger(diagnostics.cidrCount, 16),
250
1027
  } : null,
251
1028
  detail: boundedString(source.detail, 300),
252
- endpoint: endpoint ? {
1029
+ endpoint: publicationAvailable && endpoint ? {
253
1030
  address: boundedString(endpoint.address, 64),
254
1031
  family: endpoint.family === 4 || endpoint.family === 6 ? endpoint.family : null,
255
1032
  selectedFrom: typeof endpoint.selectedFrom === 'string'
@@ -257,14 +1034,14 @@ function networkSource(value) {
257
1034
  ? endpoint.selectedFrom
258
1035
  : null,
259
1036
  } : null,
260
- rdap: rdap ? {
1037
+ rdap: publicationAvailable && rdap ? {
261
1038
  endpoint: rdapEndpoint,
262
1039
  transportSecurity: rdapEndpoint ? rdapEndpoint.startsWith('https:') ? 'https' : 'http' : null,
263
1040
  httpStatus: boundedHttpStatus(rdap.httpStatus),
264
1041
  fetchedAt: boundedTimestamp(rdap.fetchedAt),
265
- attempts: (Array.isArray(rdap.attempts) ? rdap.attempts : []).slice(0, 3).map(networkAttempt),
1042
+ attempts: boundedRdapAttempts(rdap.attempts, 3),
266
1043
  } : null,
267
- network: network ? {
1044
+ network: publicationAvailable && network ? {
268
1045
  handle: boundedString(network.handle, 300),
269
1046
  name: boundedString(network.name, 300),
270
1047
  holder: boundedString(network.holder, 300),
@@ -289,7 +1066,8 @@ function reverseDnsSource(value) {
289
1066
  const records = recordOrNull(source.records);
290
1067
  const diagnostics = recordOrNull(source.diagnostics);
291
1068
  const ptrDiagnostics = recordOrNull(diagnostics?.ptr);
292
- const ptr = [...new Set((Array.isArray(records?.ptr) ? records.ptr : [])
1069
+ const publicationAvailable = source.status === 'success' || source.status === 'partial';
1070
+ const ptr = [...new Set((publicationAvailable && Array.isArray(records?.ptr) ? records.ptr : [])
293
1071
  .slice(0, 8)
294
1072
  .map(boundedHostname)
295
1073
  .filter((item) => item !== null))].sort();
@@ -300,7 +1078,7 @@ function reverseDnsSource(value) {
300
1078
  scanMode: source.scanMode === 'deep' ? 'deep' : null,
301
1079
  source: 'reverse_dns',
302
1080
  durationMs: boundedInteger(source.durationMs, 120_000),
303
- complete: source.complete === true,
1081
+ complete: completeTerminalObservation(source.status, source.truncated),
304
1082
  truncated: source.truncated === true,
305
1083
  limitations: boundedStringList(source.limitations, 10, 300),
306
1084
  diagnostics: ptrDiagnostics ? {
@@ -314,40 +1092,405 @@ function reverseDnsSource(value) {
314
1092
  records: { ptr },
315
1093
  };
316
1094
  }
317
- function rdapSource(rdap) {
1095
+ function boundedSourceError(value, fallback) {
1096
+ const normalized = String(value ?? '')
1097
+ .replace(/[\u0000-\u001f\u007f]+/gu, ' ')
1098
+ .replace(/\s+/gu, ' ')
1099
+ .trim();
1100
+ return normalized.slice(0, 10_000) || fallback;
1101
+ }
1102
+ function nullableText(maximum) {
1103
+ return (value) => value === null ? null : boundedString(value, maximum) ?? undefined;
1104
+ }
1105
+ function nullableTimestamp(value) {
1106
+ return value === null ? null : boundedTimestamp(value) ?? undefined;
1107
+ }
1108
+ function booleanValue(nullable = false) {
1109
+ return (value) => typeof value === 'boolean' ? value : nullable && value === null ? null : undefined;
1110
+ }
1111
+ function nullableInteger(maximum) {
1112
+ return (value) => value === null ? null : boundedInteger(value, maximum) ?? undefined;
1113
+ }
1114
+ function enumeration(values, nullable = false) {
1115
+ return (value) => typeof value === 'string' && values.has(value)
1116
+ ? value
1117
+ : nullable && value === null ? null : undefined;
1118
+ }
1119
+ const LOOKUP_RDAP_PORTABLE_SCALARS = Object.freeze({
1120
+ objectClassName: nullableText(80),
1121
+ language: nullableText(35),
1122
+ conformanceTruncated: booleanValue(),
1123
+ redactionsTruncated: booleanValue(),
1124
+ port43: nullableText(300),
1125
+ parentHandle: nullableText(300),
1126
+ linksTruncated: booleanValue(),
1127
+ noticesTruncated: booleanValue(),
1128
+ remarksTruncated: booleanValue(),
1129
+ serverTruncated: booleanValue(),
1130
+ statusesTruncated: booleanValue(),
1131
+ eventsTruncated: booleanValue(),
1132
+ domain: nullableText(253),
1133
+ unicodeDomain: nullableText(253),
1134
+ handle: nullableText(300),
1135
+ nameserversTruncated: booleanValue(),
1136
+ nameserverAddressesTruncated: booleanValue(),
1137
+ dnssec: enumeration(new Set(['Signed', 'Unsigned', 'Unknown']), true),
1138
+ zoneSigned: booleanValue(true),
1139
+ delegationSigned: booleanValue(true),
1140
+ dsDataTruncated: booleanValue(),
1141
+ variantsTruncated: booleanValue(),
1142
+ registrarIanaId: nullableText(300),
1143
+ entitiesTruncated: booleanValue(),
1144
+ name: nullableText(300),
1145
+ startAddress: nullableText(80),
1146
+ endAddress: nullableText(80),
1147
+ cidrsTruncated: booleanValue(),
1148
+ country: nullableText(2),
1149
+ networkType: nullableText(160),
1150
+ startAutnum: nullableInteger(4_294_967_295),
1151
+ endAutnum: nullableInteger(4_294_967_295),
1152
+ autnumType: nullableText(160),
1153
+ });
1154
+ const LOOKUP_RDAP_PORTABLE_STRING_LISTS = Object.freeze({
1155
+ conformance: [50, 160],
1156
+ serverTruncationReasons: [8, 160],
1157
+ statuses: [100, 160],
1158
+ nameservers: [200, 253],
1159
+ truncatedEntityRoles: [11, 80],
1160
+ cidrs: [200, 160],
1161
+ });
1162
+ const LOOKUP_RDAP_EVENT_SCALARS = Object.freeze({
1163
+ action: nullableText(100),
1164
+ date: nullableText(64),
1165
+ });
1166
+ const LOOKUP_RDAP_LIFECYCLE_SCALARS = Object.freeze({
1167
+ createdDate: nullableText(64),
1168
+ reregistrationDate: nullableText(64),
1169
+ expiryDate: nullableText(64),
1170
+ updatedDate: nullableText(64),
1171
+ transferDate: nullableText(64),
1172
+ deletionDate: nullableText(64),
1173
+ reinstantiationDate: nullableText(64),
1174
+ databaseUpdatedDate: nullableText(64),
1175
+ createdDateIso: nullableTimestamp,
1176
+ reregistrationDateIso: nullableTimestamp,
1177
+ expiryDateIso: nullableTimestamp,
1178
+ updatedDateIso: nullableTimestamp,
1179
+ transferDateIso: nullableTimestamp,
1180
+ deletionDateIso: nullableTimestamp,
1181
+ reinstantiationDateIso: nullableTimestamp,
1182
+ databaseUpdatedDateIso: nullableTimestamp,
1183
+ });
1184
+ const LOOKUP_RDAP_REDACTION_SCALARS = Object.freeze({
1185
+ name: nullableText(300),
1186
+ reason: nullableText(300),
1187
+ method: nullableText(80),
1188
+ pathLanguage: nullableText(80),
1189
+ prePath: nullableText(512),
1190
+ postPath: nullableText(512),
1191
+ replacementPath: nullableText(512),
1192
+ });
1193
+ const LOOKUP_RDAP_REGISTRAR_SCALARS = Object.freeze({
1194
+ handle: nullableText(200),
1195
+ name: nullableText(300),
1196
+ org: nullableText(300),
1197
+ truncated: booleanValue(),
1198
+ });
1199
+ const LOOKUP_RDAP_PUBLIC_ID_SCALARS = Object.freeze({
1200
+ type: nullableText(160),
1201
+ identifier: nullableText(300),
1202
+ });
1203
+ const LOOKUP_RDAP_NAMESERVER_SCALARS = Object.freeze({
1204
+ name: nullableText(253),
1205
+ });
1206
+ const LOOKUP_RDAP_DS_SCALARS = Object.freeze({
1207
+ keyTag: nullableInteger(65_535),
1208
+ algorithm: nullableInteger(255),
1209
+ digestType: nullableInteger(255),
1210
+ digest: nullableText(512),
1211
+ });
1212
+ const LOOKUP_RDAP_VARIANT_SCALARS = Object.freeze({
1213
+ idnTable: nullableText(300),
1214
+ });
1215
+ const LOOKUP_RDAP_VARIANT_NAME_SCALARS = Object.freeze({
1216
+ ldhName: nullableText(253),
1217
+ unicodeName: nullableText(253),
1218
+ });
1219
+ const LOOKUP_WHOIS_PORTABLE_SCALARS = Object.freeze({
1220
+ domainName: nullableText(253),
1221
+ registryDomainId: nullableText(300),
1222
+ registrar: nullableText(300),
1223
+ registrarIanaId: nullableText(300),
1224
+ createdDate: nullableText(100),
1225
+ expiryDate: nullableText(100),
1226
+ updatedDate: nullableText(100),
1227
+ createdDateIso: nullableTimestamp,
1228
+ expiryDateIso: nullableTimestamp,
1229
+ updatedDateIso: nullableTimestamp,
1230
+ statusesTruncated: booleanValue(),
1231
+ nameserversTruncated: booleanValue(),
1232
+ dnssec: nullableText(300),
1233
+ chainStatus: enumeration(new Set(['complete', 'partial'])),
1234
+ authoritativeHop: nullableText(253),
1235
+ failedHop: nullableText(253),
1236
+ conflictingHop: nullableText(253),
1237
+ });
1238
+ const LOOKUP_REGISTRY_INSIGHT_PORTABLE_KEYS = new Set([
1239
+ 'version', 'contactDisclosure', 'lifecycle', 'reconciliation', 'publications',
1240
+ 'rdapCapabilities',
1241
+ ]);
1242
+ function projectKnownArray(value, projectors, maximum) {
1243
+ if (!Array.isArray(value))
1244
+ return [];
1245
+ return value.slice(0, maximum)
1246
+ .map((item) => projectedKnownScalarRecord(item, projectors))
1247
+ .filter((item) => item !== null);
1248
+ }
1249
+ function projectedKnownScalarRecord(value, projectors) {
1250
+ const source = recordOrNull(value);
1251
+ if (!source)
1252
+ return null;
1253
+ const output = {};
1254
+ for (const [key, projector] of Object.entries(projectors)) {
1255
+ if (!Object.hasOwn(source, key))
1256
+ continue;
1257
+ const item = projector(source[key]);
1258
+ if (item !== undefined)
1259
+ output[key] = item;
1260
+ }
1261
+ return output;
1262
+ }
1263
+ /**
1264
+ * Positive portable projection for normalized RDAP. Contact entities, vCards,
1265
+ * entity inventories, arbitrary links/notices/remarks, and raw payloads never
1266
+ * cross this boundary. The marker prevents deliberate omission from becoming
1267
+ * an apparent source assertion that contacts were absent.
1268
+ */
1269
+ export function projectLookupEvidenceRdapPublication(value) {
1270
+ const source = recordOrNull(value);
1271
+ if (!source)
1272
+ return null;
1273
+ const output = projectedKnownScalarRecord(source, LOOKUP_RDAP_PORTABLE_SCALARS) || {};
1274
+ for (const [key, [count, length]] of Object.entries(LOOKUP_RDAP_PORTABLE_STRING_LISTS)) {
1275
+ if (Object.hasOwn(source, key))
1276
+ output[key] = boundedStringList(source[key], count, length);
1277
+ }
1278
+ if (Object.hasOwn(source, 'events')) {
1279
+ output.events = projectKnownArray(source.events, LOOKUP_RDAP_EVENT_SCALARS, 100)
1280
+ .filter((event) => typeof event.action === 'string' || typeof event.date === 'string');
1281
+ }
1282
+ if (Object.hasOwn(source, 'lifecycle')) {
1283
+ output.lifecycle = projectedKnownScalarRecord(source.lifecycle, LOOKUP_RDAP_LIFECYCLE_SCALARS);
1284
+ }
1285
+ if (Object.hasOwn(source, 'redactions')) {
1286
+ const inputRedactions = Array.isArray(source.redactions) ? source.redactions : [];
1287
+ const redactions = projectKnownArray(inputRedactions, LOOKUP_RDAP_REDACTION_SCALARS, 100)
1288
+ .filter((redaction) => Object.values(redaction).some((item) => item !== null));
1289
+ output.redactions = redactions;
1290
+ output.redactionsTruncated = source.redactionsTruncated === true
1291
+ || !Array.isArray(source.redactions)
1292
+ || inputRedactions.length > 100
1293
+ || redactions.length < Math.min(inputRedactions.length, 100);
1294
+ }
1295
+ if (Object.hasOwn(source, 'nameserverDetails')) {
1296
+ output.nameserverDetails = (Array.isArray(source.nameserverDetails) ? source.nameserverDetails : [])
1297
+ .slice(0, 200)
1298
+ .map((item) => {
1299
+ const nameserver = projectedKnownScalarRecord(item, LOOKUP_RDAP_NAMESERVER_SCALARS);
1300
+ const original = recordOrNull(item);
1301
+ if (!nameserver || !original || typeof nameserver.name !== 'string')
1302
+ return null;
1303
+ nameserver.addresses = boundedStringList(original.addresses, 20, 80);
1304
+ return nameserver;
1305
+ })
1306
+ .filter((item) => item !== null);
1307
+ }
1308
+ if (Object.hasOwn(source, 'dsData')) {
1309
+ output.dsData = projectKnownArray(source.dsData, LOOKUP_RDAP_DS_SCALARS, 50)
1310
+ .filter((item) => Number.isInteger(item.keyTag)
1311
+ && Number.isInteger(item.algorithm)
1312
+ && Number.isInteger(item.digestType)
1313
+ && typeof item.digest === 'string'
1314
+ && item.digest.length % 2 === 0
1315
+ && /^[0-9a-f]+$/iu.test(item.digest));
1316
+ }
1317
+ if (Object.hasOwn(source, 'variants') && Array.isArray(source.variants)) {
1318
+ output.variants = source.variants.slice(0, 20).map((item) => {
1319
+ const group = projectedKnownScalarRecord(item, LOOKUP_RDAP_VARIANT_SCALARS);
1320
+ if (!group)
1321
+ return null;
1322
+ const original = recordOrNull(item);
1323
+ const variantNames = projectKnownArray(original?.variantNames, LOOKUP_RDAP_VARIANT_NAME_SCALARS, 50)
1324
+ .filter((name) => typeof name.ldhName === 'string' || typeof name.unicodeName === 'string');
1325
+ const relation = boundedStringList(original?.relation, 20, 100);
1326
+ group.variantNames = variantNames;
1327
+ group.relation = relation;
1328
+ return typeof group.idnTable === 'string' || variantNames.length || relation.length
1329
+ ? group
1330
+ : null;
1331
+ }).filter((item) => item !== null);
1332
+ }
1333
+ if (Object.hasOwn(source, 'registrar')) {
1334
+ const registrar = projectedKnownScalarRecord(source.registrar, LOOKUP_RDAP_REGISTRAR_SCALARS);
1335
+ if (registrar) {
1336
+ const original = recordOrNull(source.registrar);
1337
+ registrar.roles = boundedStringList(original?.roles, 12, 80);
1338
+ registrar.publicIds = projectKnownArray(original?.publicIds, LOOKUP_RDAP_PUBLIC_ID_SCALARS, 20)
1339
+ .filter((publicId) => typeof publicId.type === 'string'
1340
+ && typeof publicId.identifier === 'string');
1341
+ }
1342
+ output.registrar = registrar;
1343
+ }
1344
+ output.contactsExcluded = true;
1345
+ return output;
1346
+ }
1347
+ /** Positive portable projection for normalized WHOIS publication fields. */
1348
+ export function projectLookupEvidenceWhoisPublication(value) {
1349
+ const input = recordOrNull(value);
1350
+ if (!input)
1351
+ return null;
1352
+ const output = projectedKnownScalarRecord(input, LOOKUP_WHOIS_PORTABLE_SCALARS) || {};
1353
+ if (Object.hasOwn(input, 'lifecycle')) {
1354
+ output.lifecycle = projectedKnownScalarRecord(input.lifecycle, LOOKUP_RDAP_LIFECYCLE_SCALARS);
1355
+ }
1356
+ for (const [key, count, length] of [
1357
+ ['statuses', 100, 160],
1358
+ ['nameservers', 200, 253],
1359
+ ['fieldsTruncated', 64, 80],
1360
+ ]) {
1361
+ if (Object.hasOwn(input, key))
1362
+ output[key] = boundedStringList(input[key], count, length);
1363
+ }
1364
+ output.contactsExcluded = true;
1365
+ return output;
1366
+ }
1367
+ /** Exact positive projection for one current-schema RDAP source wrapper. */
1368
+ export function projectLookupEvidenceRdapSourcePublication(value) {
1369
+ const source = recordOrNull(value);
1370
+ if (!source)
1371
+ return null;
1372
+ const status = sourceStatus(source.status, RDAP_SOURCE_STATUSES, 'error');
1373
+ const attempts = boundedRdapAttempts(source.attempts, 16);
1374
+ if (status === 'error')
1375
+ return {
1376
+ status,
1377
+ error: boundedSourceError(source.error, 'RDAP source reported an error.'),
1378
+ attempts,
1379
+ };
1380
+ const endpoint = boundedEndpoint(source.endpoint);
1381
+ const hasPublication = status === 'success' || status === 'partial';
1382
+ const parsed = hasPublication ? projectLookupEvidenceRdapPublication(source.parsed) : null;
1383
+ if (status === 'success' && parsed === null)
1384
+ return null;
1385
+ return {
1386
+ status,
1387
+ endpoint,
1388
+ transportSecurity: endpoint ? endpoint.startsWith('https:') ? 'https' : 'http' : null,
1389
+ httpStatus: boundedHttpStatus(source.httpStatus),
1390
+ fetchedAt: boundedTimestamp(source.fetchedAt),
1391
+ attempts,
1392
+ parsed,
1393
+ };
1394
+ }
1395
+ function projectedWhoisChainHop(value) {
1396
+ const hop = recordOrNull(value);
1397
+ if (!hop)
1398
+ return null;
1399
+ const queryProfile = typeof hop.queryProfile === 'string' && WHOIS_QUERY_PROFILES.has(hop.queryProfile)
1400
+ ? hop.queryProfile
1401
+ : null;
1402
+ const status = typeof hop.status === 'string'
1403
+ && ['success', 'error', 'not_issued', 'unknown'].includes(hop.status)
1404
+ ? hop.status
1405
+ : 'unknown';
1406
+ return {
1407
+ server: boundedHostname(hop.server),
1408
+ address: boundedString(hop.address, 64),
1409
+ queriedAt: boundedTimestamp(hop.queriedAt),
1410
+ queryProfile,
1411
+ responseEncoding: hop.responseEncoding === 'utf-8' ? 'utf-8' : null,
1412
+ status,
1413
+ detail: boundedString(hop.detail, 240),
1414
+ };
1415
+ }
1416
+ /** Exact positive projection for one current-schema WHOIS source wrapper. */
1417
+ export function projectLookupEvidenceWhoisSourcePublication(value) {
1418
+ const source = recordOrNull(value);
1419
+ if (!source)
1420
+ return null;
1421
+ const status = sourceStatus(source.status, WHOIS_SOURCE_STATUSES, 'error');
1422
+ if (status === 'error')
1423
+ return {
1424
+ status,
1425
+ error: boundedSourceError(source.error, 'WHOIS source reported an error.'),
1426
+ };
1427
+ const hasPublication = status === 'complete' || status === 'partial';
1428
+ const parsed = hasPublication ? projectLookupEvidenceWhoisPublication(source.parsed) : null;
1429
+ if (status === 'complete' && parsed === null)
1430
+ return null;
1431
+ return {
1432
+ status,
1433
+ queriedAt: boundedTimestamp(source.queriedAt),
1434
+ authoritativeHop: boundedHostname(source.authoritativeHop),
1435
+ failedHop: boundedHostname(source.failedHop),
1436
+ conflictingHop: boundedHostname(source.conflictingHop),
1437
+ parsed,
1438
+ chain: hasPublication
1439
+ ? (Array.isArray(source.chain) ? source.chain : [])
1440
+ .slice(0, 16)
1441
+ .map(projectedWhoisChainHop)
1442
+ .filter((hop) => hop !== null)
1443
+ : [],
1444
+ };
1445
+ }
1446
+ export function projectLookupEvidenceRegistryInsights(value) {
1447
+ return projectedKnownRecord(value, LOOKUP_REGISTRY_INSIGHT_PORTABLE_KEYS);
1448
+ }
1449
+ function sourceStatus(value, allowed, fallback) {
1450
+ return typeof value === 'string' && allowed.has(value) ? value : fallback;
1451
+ }
1452
+ function rdapSource(rdap, diagnostics) {
318
1453
  const source = recordOrNull(rdap) || {};
319
- if (source.error)
1454
+ const status = sourceStatus(diagnostics?.status, RDAP_SOURCE_STATUSES, 'error');
1455
+ const attempts = boundedRdapAttempts(source.attempts ?? diagnostics?.attempts, 16);
1456
+ if (status === 'error')
320
1457
  return {
321
1458
  status: 'error',
322
- error: String(source.error),
323
- attempts: cloneJson(source.attempts || []),
1459
+ error: boundedSourceError(source.error ?? diagnostics?.errorCode, 'RDAP source reported an error.'),
1460
+ attempts,
324
1461
  };
1462
+ const hasPublication = status === 'success' || status === 'partial';
1463
+ const endpoint = boundedEndpoint(source.rdapServer ?? diagnostics?.endpoint);
325
1464
  return {
326
- status: source.upstreamStatus === 404 ? 'not_found' : 'success',
327
- endpoint: source.rdapServer || null,
328
- transportSecurity: source.transportSecurity || null,
329
- httpStatus: source.upstreamStatus ?? null,
330
- fetchedAt: source.fetchedAt || null,
331
- attempts: cloneJson(source.attempts || []),
332
- parsed: cloneJson(source.parsed),
333
- raw: cloneJson(source.data),
1465
+ status,
1466
+ endpoint,
1467
+ transportSecurity: endpoint ? endpoint.startsWith('https:') ? 'https' : 'http' : null,
1468
+ httpStatus: boundedHttpStatus(source.upstreamStatus ?? diagnostics?.httpStatus),
1469
+ fetchedAt: boundedTimestamp(source.fetchedAt ?? diagnostics?.fetchedAt),
1470
+ attempts,
1471
+ parsed: hasPublication ? projectLookupEvidenceRdapPublication(source.parsed) : null,
334
1472
  };
335
1473
  }
336
- function whoisSource(whois) {
1474
+ function whoisSource(whois, diagnostics) {
337
1475
  const source = recordOrNull(whois) || {};
338
- if (source.error)
339
- return { status: 'error', error: String(source.error) };
1476
+ const status = sourceStatus(diagnostics?.status, WHOIS_SOURCE_STATUSES, 'error');
1477
+ if (status === 'error')
1478
+ return {
1479
+ status: 'error',
1480
+ error: boundedSourceError(source.error ?? diagnostics?.errorCode, 'WHOIS source reported an error.'),
1481
+ };
340
1482
  const parsed = recordOrNull(source.parsed);
341
- const chain = Array.isArray(source.chain) ? source.chain : [];
1483
+ const hasPublication = status === 'complete' || status === 'partial';
1484
+ const chain = hasPublication ? boundedWhoisChain(source.chain) : [];
342
1485
  const firstHop = recordOrNull(chain[0]);
343
1486
  return {
344
- status: parsed?.chainStatus || 'unknown',
345
- queriedAt: firstHop?.queriedAt || null,
346
- authoritativeHop: parsed?.authoritativeHop || null,
347
- failedHop: parsed?.failedHop || null,
348
- conflictingHop: parsed?.conflictingHop || null,
349
- parsed: cloneJson(parsed),
350
- chain: cloneJson(chain),
1487
+ status,
1488
+ queriedAt: boundedTimestamp(firstHop?.queriedAt ?? diagnostics?.queriedAt),
1489
+ authoritativeHop: boundedHostname(parsed?.authoritativeHop ?? diagnostics?.authoritativeHop),
1490
+ failedHop: boundedHostname(parsed?.failedHop ?? diagnostics?.failedHop),
1491
+ conflictingHop: boundedHostname(parsed?.conflictingHop ?? diagnostics?.conflictingHop),
1492
+ parsed: hasPublication ? projectLookupEvidenceWhoisPublication(parsed) : null,
1493
+ chain,
351
1494
  };
352
1495
  }
353
1496
  function registrarPublicationComparison(body, registryParsed) {
@@ -359,7 +1502,9 @@ function registrarPublicationComparison(body, registryParsed) {
359
1502
  if (!registrar && !registrarDiagnostics)
360
1503
  return null;
361
1504
  const reportedStatus = registrar?.status ?? registrarDiagnostics?.status;
362
- const parsed = recordOrNull(registrar?.parsed);
1505
+ const parsed = ['success', 'partial'].includes(String(reportedStatus))
1506
+ ? projectLookupEvidenceRdapPublication(registrar?.parsed)
1507
+ : null;
363
1508
  const registrarStatus = typeof reportedStatus === 'string' && REGISTRAR_RDAP_STATUSES.has(reportedStatus)
364
1509
  ? (reportedStatus === 'success' && !parsed ? 'partial' : reportedStatus)
365
1510
  : 'error';
@@ -369,17 +1514,35 @@ function registrarPublicationComparison(body, registryParsed) {
369
1514
  });
370
1515
  }
371
1516
  export function buildLookupEvidence(response, options = {}) {
372
- const { generatedAt = new Date().toISOString(), idnAnalysis = null, applicationVersion = null } = options;
1517
+ assertBoundedJsonStructure(response, 'Lookup response');
1518
+ const { generatedAt: generatedAtValue = new Date().toISOString(), idnAnalysis = null, applicationVersion = null } = options;
1519
+ const generatedAt = normalizeExplicitIsoTimestamp(generatedAtValue);
1520
+ if (!generatedAt) {
1521
+ throw new TypeError('Lookup evidence generation time must be valid and include an explicit timezone.');
1522
+ }
373
1523
  const body = recordOrNull(response) || {};
374
1524
  const rdap = recordOrNull(body.rdap);
375
1525
  const whois = recordOrNull(body.whois);
376
1526
  const diagnostics = recordOrNull(body.diagnostics);
377
- const rdapDiagnostics = recordOrNull(diagnostics?.rdap);
378
- const whoisDiagnostics = recordOrNull(diagnostics?.whois);
379
- const rdapParsed = rdap && !rdap.error ? recordOrNull(rdap.parsed) : null;
380
- const whoisParsed = whois && !whois.error ? recordOrNull(whois.parsed) : null;
381
- const registrarRdap = recordOrNull(rdap?.registrarRdap);
382
- const registrarRdapParsed = recordOrNull(registrarRdap?.parsed);
1527
+ const projectedDiagnostics = lookupDiagnostics(diagnostics);
1528
+ const rdapDiagnostics = recordOrNull(projectedDiagnostics.rdap);
1529
+ const whoisDiagnostics = recordOrNull(projectedDiagnostics.whois);
1530
+ const projectedRdap = projectLookupEvidenceRdapSourcePublication(rdapSource(rdap, rdapDiagnostics));
1531
+ const projectedWhois = projectLookupEvidenceWhoisSourcePublication(whoisSource(whois, whoisDiagnostics));
1532
+ if (!projectedRdap) {
1533
+ throw new TypeError('Lookup evidence cannot publish successful RDAP without normalized publication data.');
1534
+ }
1535
+ if (!projectedWhois) {
1536
+ throw new TypeError('Lookup evidence cannot publish complete WHOIS without normalized publication data.');
1537
+ }
1538
+ const projectedRdapRecord = recordOrNull(projectedRdap) || {};
1539
+ const projectedWhoisRecord = recordOrNull(projectedWhois) || {};
1540
+ const rdapParsed = ['success', 'partial'].includes(String(projectedRdapRecord.status))
1541
+ ? recordOrNull(projectedRdapRecord.parsed)
1542
+ : null;
1543
+ const whoisParsed = ['complete', 'partial'].includes(String(projectedWhoisRecord.status))
1544
+ ? recordOrNull(projectedWhoisRecord.parsed)
1545
+ : null;
383
1546
  // Rebuild the pure interpretation from bounded source fields instead of
384
1547
  // trusting a supplied derived object. This adds no collection or network
385
1548
  // work and lets saved or fast responses carry current source-health-aware
@@ -387,50 +1550,42 @@ export function buildLookupEvidence(response, options = {}) {
387
1550
  const registryInsights = buildRegistryInsights({
388
1551
  rdapParsed,
389
1552
  rdapStatus: rdapDiagnostics?.status,
390
- rdapFetchedAt: rdap?.fetchedAt,
1553
+ rdapFetchedAt: projectedRdapRecord.fetchedAt,
391
1554
  whoisParsed,
392
1555
  whoisStatus: whoisDiagnostics?.status,
393
- whoisQueriedAt: recordOrNull(Array.isArray(whois?.chain) ? whois.chain[0] : null)?.queriedAt,
394
- registrarRdapParsed,
395
- registrarRdapStatus: registrarRdap?.status ?? recordOrNull(rdapDiagnostics?.registrar)?.status,
396
- registrarRdapFetchedAt: registrarRdap?.fetchedAt,
1556
+ whoisQueriedAt: projectedWhoisRecord.queriedAt,
397
1557
  });
398
- return {
1558
+ const query = projectLookupEvidenceQuery(body);
1559
+ const availabilityAnalysis = projectLookupEvidenceAvailability(body.availability);
1560
+ const idn = projectedKnownRecord(idnAnalysis, LOOKUP_IDN_ANALYSIS_KEYS);
1561
+ const evidence = {
399
1562
  schema: LOOKUP_EVIDENCE_SCHEMA,
400
1563
  schemaVersion: LOOKUP_EVIDENCE_SCHEMA_VERSION,
401
1564
  generatedAt,
402
- application: {
403
- name: 'WHOISleuth',
404
- version: boundedSemanticVersion(applicationVersion),
405
- projectUrl: WHOISLEUTH_SOURCE_REPOSITORY_URL,
406
- },
407
- query: {
408
- submitted: body.query || null,
409
- type: body.type || null,
410
- inputHostname: body.inputHostname || null,
411
- registrableDomain: body.registrableDomain || null,
412
- isSubdomain: body.isSubdomain === true,
413
- },
414
- diagnostics: cloneJson(body.diagnostics),
1565
+ application: buildPortableGeneratorMetadata(applicationVersion),
1566
+ query,
1567
+ diagnostics: projectedDiagnostics,
415
1568
  sources: {
416
- rdap: rdapSource(rdap),
417
- whois: whoisSource(whois),
1569
+ rdap: projectedRdap,
1570
+ whois: projectedWhois,
418
1571
  reverseDns: reverseDnsSource(body.reverseDns),
419
1572
  network: networkSource(body.networkContext),
420
1573
  securityTxt: securityTxtSource(body.securityTxt),
421
1574
  sslbl: sslblSource(body.sslbl),
422
1575
  },
423
1576
  analysis: {
424
- availability: cloneJson(body.availability),
425
- idn: cloneJson(idnAnalysis),
426
- registryInsights,
427
- registryComparison: compareRegistrySources(rdapParsed, whoisParsed, {
1577
+ availability: availabilityAnalysis,
1578
+ idn,
1579
+ registryInsights: projectLookupEvidenceRegistryInsights(registryInsights),
1580
+ registryComparison: projectLookupEvidencePrivacySafeTree(compareRegistrySources(rdapParsed, whoisParsed, {
428
1581
  rdapStatus: rdapDiagnostics?.status,
429
1582
  whoisStatus: whoisDiagnostics?.status,
430
- }),
431
- registrarPublicationComparison: registrarPublicationComparison(body, rdapParsed),
1583
+ })),
1584
+ registrarPublicationComparison: projectLookupEvidencePrivacySafeTree(registrarPublicationComparison(body, rdapParsed)),
432
1585
  },
433
1586
  };
1587
+ assertLookupEvidencePortableTree(evidence);
1588
+ return evidence;
434
1589
  }
435
1590
  export function evidenceFilename(response, now = Date.now()) {
436
1591
  const record = recordOrNull(response);