@slicedearth/whoisleuth-cli 1.45.0 → 1.47.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +59 -6
  4. package/bin/whoisleuth.mjs +5 -3
  5. package/cli/arguments.mjs +256 -9
  6. package/cli/artifact-structure.mjs +1833 -0
  7. package/cli/artifact-verify.mjs +242 -49
  8. package/cli/bounded-json.mjs +1 -0
  9. package/cli/bulk-checkpoint.mjs +2 -3
  10. package/cli/bulk-command-runner.mjs +2 -5
  11. package/cli/case-pack.mjs +819 -34
  12. package/cli/command-reference.mjs +48 -24
  13. package/cli/completion.mjs +16 -8
  14. package/cli/ct-event-intake.mjs +32 -2
  15. package/cli/discovery-scan-command-runner.mjs +4 -5
  16. package/cli/discovery-workflow.mjs +2 -3
  17. package/cli/doctor.mjs +2 -2
  18. package/cli/domain-control-monitor.mjs +21 -15
  19. package/cli/domain-control-observations.mjs +25 -2
  20. package/cli/evidence-command-runner.mjs +17 -11
  21. package/cli/evidence-signing.mjs +73 -45
  22. package/cli/export-evidence.mjs +11 -4
  23. package/cli/formatters/html.mjs +3 -3
  24. package/cli/formatters/terminal.mjs +3 -0
  25. package/cli/input.mjs +57 -0
  26. package/cli/interchange-report.mjs +226 -0
  27. package/cli/investigation-manifest.mjs +16 -17
  28. package/cli/investigation-run.mjs +71 -23
  29. package/cli/local-mmdb-review.mjs +45 -14
  30. package/cli/lookup-command-runner.mjs +2 -2
  31. package/cli/lookup-diff.mjs +9 -0
  32. package/cli/network-command-runner.mjs +78 -0
  33. package/cli/offline-evidence-review.mjs +28 -8
  34. package/cli/page-compare.mjs +8 -5
  35. package/cli/registry-cohort.mjs +385 -61
  36. package/cli/registry-support.mjs +2 -1
  37. package/cli/retained-artifact-diff.mjs +376 -0
  38. package/cli/risk-calibration.mjs +7 -5
  39. package/cli/runner.mjs +147 -185
  40. package/cli/saved-lookup.mjs +26 -3
  41. package/cli/sharing-review.mjs +12 -6
  42. package/docs/cli-reference.md +444 -70
  43. package/docs/cli.md +24 -5
  44. package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
  45. package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
  46. package/frontend/src/lib/analysis/brand-profile-model.js +29 -5
  47. package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
  48. package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
  49. package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
  50. package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
  51. package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
  52. package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
  53. package/frontend/src/lib/analysis/bulk-sort.js +4 -0
  54. package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
  55. package/frontend/src/lib/analysis/case-evidence-model.js +8 -0
  56. package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
  57. package/frontend/src/lib/analysis/case-migration-model.js +83 -31
  58. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  59. package/frontend/src/lib/analysis/case-record-model.js +2 -0
  60. package/frontend/src/lib/analysis/case-record-operations.js +42 -12
  61. package/frontend/src/lib/analysis/case-report.js +39 -6
  62. package/frontend/src/lib/analysis/case-response-model.js +66 -7
  63. package/frontend/src/lib/analysis/case-response-packet.js +15 -5
  64. package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
  65. package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
  66. package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
  67. package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
  68. package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
  69. package/frontend/src/lib/analysis/domain-control-records.js +88 -0
  70. package/frontend/src/lib/analysis/external-findings-import.js +70 -6
  71. package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
  72. package/frontend/src/lib/analysis/investigation-guide.js +52 -10
  73. package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
  74. package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
  75. package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
  76. package/frontend/src/lib/analysis/lookup-decision-support.js +61 -6
  77. package/frontend/src/lib/analysis/lookup-presentation.js +62 -0
  78. package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
  79. package/frontend/src/lib/analysis/utils.js +26 -3
  80. package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
  81. package/frontend/src/lib/analysis/visualization-models.js +63 -94
  82. package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
  83. package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
  84. package/frontend/src/lib/analysis/workspace-archive.js +104 -14
  85. package/lib/application-version.mjs +3 -0
  86. package/lib/availability.mjs +14 -12
  87. package/lib/bounded-file.mjs +13 -0
  88. package/lib/bounded-json-response.mjs +17 -4
  89. package/lib/bounded-json.mjs +249 -0
  90. package/lib/certificate-extension-profile.mjs +2 -1
  91. package/lib/cryptographic-assurance.mjs +155 -0
  92. package/lib/ct-search.mjs +87 -47
  93. package/lib/dns-intelligence.mjs +3 -2
  94. package/lib/dns-resolver-selection.mjs +6 -4
  95. package/lib/dnssec-chain-validation.mjs +1053 -0
  96. package/lib/dnssec-evidence-validation.mjs +10 -3
  97. package/lib/domain-change-packet.mjs +7 -5
  98. package/lib/domain-control-manifest.mjs +17 -129
  99. package/lib/domain-posture.mjs +7 -4
  100. package/lib/evidence-export.mjs +751 -78
  101. package/lib/evidence-report-markdown.mjs +3 -0
  102. package/lib/evidence-report.mjs +5 -0
  103. package/lib/favicon.mjs +8 -5
  104. package/lib/http-evidence-bounds.mjs +3 -0
  105. package/lib/http-intelligence.mjs +2 -5
  106. package/lib/interchange-fidelity-registry.mjs +122 -0
  107. package/lib/local-geoip-evidence.mjs +2 -1
  108. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  109. package/lib/lookup-response-contract.mjs +691 -3
  110. package/lib/lookup.mjs +2 -2
  111. package/lib/observation.mjs +7 -6
  112. package/lib/outbound-identity.mjs +2 -3
  113. package/lib/perceptual-hash.mjs +12 -4
  114. package/lib/portable-generator.mjs +21 -0
  115. package/lib/provider-policy-admission.mjs +9 -4
  116. package/lib/rdap-normalization.mjs +18 -5
  117. package/lib/rdap-registrar.mjs +5 -1
  118. package/lib/registry-capabilities.mjs +3 -0
  119. package/lib/registry-capability-catalogue.mjs +12 -2
  120. package/lib/registry-comparison.mjs +28 -11
  121. package/lib/risk-calibration-summary.mjs +338 -0
  122. package/lib/rpki-evidence.mjs +9 -8
  123. package/lib/safe-fetch.mjs +59 -10
  124. package/lib/security-txt.mjs +26 -3
  125. package/lib/service-binding-dns.mjs +6 -1
  126. package/lib/smtp-transport-review.mjs +784 -0
  127. package/lib/sslbl-certificates.generated.mjs +136 -134
  128. package/lib/threat-intelligence-runtime.mjs +3 -11
  129. package/lib/threat-intelligence-types.mjs +18 -1
  130. package/lib/tls-intelligence.mjs +11 -5
  131. package/lib/tlsa-evidence.mjs +37 -7
  132. package/package.json +1 -1
  133. package/third-party-notices.txt +95 -5817
@@ -1,25 +1,441 @@
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 } from "./portable-generator.mjs";
4
+ import { assertBoundedJsonStructure, isSafeJsonObjectKey } from "./bounded-json.mjs";
5
5
  export const LOOKUP_EVIDENCE_SCHEMA = 'whoisleuth.lookup-evidence';
6
- export const LOOKUP_EVIDENCE_SCHEMA_VERSION = 25;
6
+ export const LEGACY_LOOKUP_EVIDENCE_SCHEMA_VERSION = 25;
7
+ export const LOOKUP_EVIDENCE_SCHEMA_VERSION = 26;
8
+ export const SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS = Object.freeze([
9
+ LEGACY_LOOKUP_EVIDENCE_SCHEMA_VERSION,
10
+ LOOKUP_EVIDENCE_SCHEMA_VERSION,
11
+ ]);
12
+ export const LOOKUP_EVIDENCE_PORTABLE_MAX_BYTES = 5 * 1024 * 1024;
13
+ export const LOOKUP_EVIDENCE_PORTABLE_MAX_ENTRIES = 20_000;
14
+ export const LOOKUP_EVIDENCE_PORTABLE_MAX_DEPTH = 24;
15
+ export const LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS = 10_000;
16
+ export const LOOKUP_EVIDENCE_PORTABLE_MAX_KEY_LENGTH = 256;
17
+ export const LOOKUP_EVIDENCE_PORTABLE_MAX_STRING_LENGTH = 1024 * 1024;
7
18
  const REGISTRAR_RDAP_STATUSES = new Set([
8
19
  'success', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled',
9
20
  ]);
21
+ const LOOKUP_QUERY_TYPES = new Set(['domain', 'ipv4', 'ipv6', 'asn']);
22
+ const RDAP_SOURCE_STATUSES = new Set([
23
+ 'success', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled',
24
+ ]);
25
+ const WHOIS_SOURCE_STATUSES = new Set([
26
+ 'complete', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled',
27
+ ]);
28
+ const AVAILABILITY_DIAGNOSTIC_STATUSES = new Set(['complete', 'error', 'disabled', 'not_applicable']);
10
29
  const NETWORK_CONTEXT_STATUSES = new Set(['success', 'partial', 'not_found', 'unsupported', 'error']);
11
30
  const NETWORK_ADDRESS_SOURCES = new Set(['tls_connection', 'dns_a', 'dns_aaaa']);
12
31
  const REVERSE_DNS_STATUSES = new Set(['success', 'partial', 'not_found', 'unsupported', 'skipped', 'error']);
13
32
  const SECURITY_TXT_STATES = new Set(['present', 'stale', 'partial', 'absent', 'malformed', 'unsupported', 'unavailable']);
14
33
  const SSLBL_STATES = new Set(['success', 'stale', 'unavailable']);
15
34
  const SSLBL_VERDICTS = new Set(['listed', 'not_listed', 'inconclusive']);
35
+ const RDAP_ATTEMPT_OUTCOMES = new Set([
36
+ 'success', 'not_found', 'no_results', 'unsupported', 'rate_limited',
37
+ 'server_error', 'client_error', 'invalid_json', 'invalid_response',
38
+ 'timeout', 'network_error',
39
+ ]);
40
+ const WHOIS_QUERY_PROFILES = new Set([
41
+ 'plain-domain', 'denic-domain-ace', 'jprs-domain-english',
42
+ 'registry-domain-unicode', 'not-issued',
43
+ ]);
44
+ const LOOKUP_TIMING_SOURCES = new Set([
45
+ 'rdap', 'whois', 'domain_evidence', 'reverse_dns', 'registrar_rdap',
46
+ 'network_context', 'security_txt', 'external_intelligence',
47
+ 'malware_host_intelligence', 'malware_ioc_intelligence',
48
+ ]);
49
+ const LOOKUP_AVAILABILITY_ANALYSIS_KEYS = new Set([
50
+ 'applicable', 'type', 'domain', 'state', 'confidence', 'detail', 'source',
51
+ 'rdapServer', 'nameservers', 'statuses', 'registrar', 'registrant', 'abuse',
52
+ 'createdDate', 'expiryDate', 'createdDateIso', 'expiryDateIso', 'domainAgeDays',
53
+ 'expiresInDays', 'privacyProtected', 'dnssec', 'activityStatus',
54
+ 'websiteProbeStatus', 'websiteProbeDetail', 'http', 'deepScanComplete',
55
+ 'faviconHash', 'faviconPHash', 'pageTitle', 'hasPasswordField',
56
+ 'phishingLanguageMatch', 'hasExternalFormAction', 'externalAssetHosts',
57
+ 'pageIdentity', 'credentialSurfaceProfile', 'structuredDataIdentity',
58
+ 'technologyProfile', 'pageRoleProfile', 'clientBehaviorProfile',
59
+ 'securityPosture', 'dns', 'tls', 'hasMx', 'hasNullMx', 'mxHosts', 'hasSpf',
60
+ 'hasDmarc', 'bulkComparison', 'limitations',
61
+ ]);
62
+ const LOOKUP_IDN_ANALYSIS_KEYS = new Set([
63
+ 'version', 'mappingVersion', 'asciiDomain', 'unicodeDomain', 'hasIdn',
64
+ 'scripts', 'labels', 'mixedScript', 'skeleton', 'referenceMatches', 'findings',
65
+ 'truncated', 'limitations',
66
+ ]);
67
+ const PRIVATE_EVIDENCE_KEYS = new Set([
68
+ 'authorization', 'proxyauthorization', 'cookie', 'cookies', 'setcookie',
69
+ 'session', 'sessionid', 'sessiontoken', 'token', 'accesstoken', 'refreshtoken',
70
+ 'apikey', 'password', 'secret', 'credentials', 'requestheaders', 'responseheaders',
71
+ 'headers', 'rawbody', 'responsebody',
72
+ ]);
73
+ const SAFE_EVIDENCE_KEYS = new Set([
74
+ 'credentialsurfaceprofile', 'credentialsurfaceversion', 'haspasswordfield', 'tokencount',
75
+ ]);
76
+ const PORTABLE_URL_KEYS = new Set([
77
+ 'endpoint', 'url', 'uri', 'href', 'rdapserver', 'finalurl', 'requestedurl',
78
+ 'requesturl', 'officiallookupurl', 'declaredorigin', 'actionurl', 'from', 'to',
79
+ ]);
80
+ const PORTABLE_ORIGIN_COLLECTION_KEYS = new Set([
81
+ 'embeddedorigins', 'externalactionorigins', 'externalorigins',
82
+ ]);
83
+ // Availability is assembled from a finite set of versioned, bounded analysis
84
+ // models. Portable exports retain only their declared output vocabulary rather
85
+ // than recursively copying arbitrary browser-local extensions. This set is
86
+ // intentionally exact and case-sensitive; new model fields must be reviewed at
87
+ // this privacy boundary before they can enter a portable artefact.
88
+ const LOOKUP_AVAILABILITY_PORTABLE_NESTED_KEYS = new Set([
89
+ // Shared observation and summary fields.
90
+ 'version', 'profileVersion', 'status', 'state', 'source', 'observedAt',
91
+ 'scanMode', 'durationMs', 'complete', 'truncated', 'limitations',
92
+ 'diagnostics', 'detail', 'error', 'count', 'total', 'discarded', 'summary',
93
+ 'findings', 'id', 'label', 'tone', 'category', 'confidence', 'evidence',
94
+ 'value', 'values', 'name', 'type', 'types', 'mode', 'owner', 'target',
95
+ 'url', 'uri', 'href',
96
+ 'priority', 'ttl', 'records',
97
+ // HTTP observation and retained response metadata.
98
+ 'requestUrl', 'finalUrl', 'transportSecurity', 'redirectCount',
99
+ 'redirectLimitReached', 'redirects', 'crossOriginRedirect', 'httpsDowngrade',
100
+ 'attempts', 'response', 'contentType', 'contentLanguage', 'server',
101
+ 'declaredContentLength', 'capturedBodyBytes', 'bodyInspected', 'bodyTruncated',
102
+ 'bodyHash', 'securityHeaders', 'strictTransportSecurity',
103
+ 'contentSecurityPolicy', 'xFrameOptions', 'xContentTypeOptions',
104
+ 'referrerPolicy', 'algorithm', 'scope', 'bytes', 'from', 'to',
105
+ 'queryOmitted', 'pathTruncated', 'outcome', 'httpStatus', 'attemptCount',
106
+ // DNS observation, effective CAA, and HTTPS/SVCB parameter summaries.
107
+ 'a', 'aaaa', 'caa', 'cname', 'dmarc', 'https', 'mx', 'ns', 'ptr', 'soa',
108
+ 'spf', 'delegation', 'caaPolicy', 'policyVersion', 'queryLimit',
109
+ 'queriedOwners', 'effectiveOwner', 'inherited', 'tree', 'registryEvidence',
110
+ 'exchange', 'nsname', 'hostmaster', 'serial', 'refresh', 'retry', 'expire', 'minttl',
111
+ 'tag', 'critical', 'parameters', 'parametersIgnored', 'mandatory', 'alpn',
112
+ 'noDefaultAlpn', 'port', 'ipv4hint', 'ipv6hint', 'opaque', 'key', 'length',
113
+ 'unknownKeys', 'unsupportedMandatoryKeys', 'targetIsOwner',
114
+ 'serviceUnavailable', 'compatible', 'effectiveOwner', 'queriedOwners',
115
+ // TLS profile, certificate, extension, and connection summaries. Runtime
116
+ // authorisation objects are deliberately absent from this portable set.
117
+ 'connectedAddress', 'connectedFamily', 'sniHost', 'protocol', 'alpnProtocol',
118
+ 'cipher', 'ephemeralKey', 'hostname', 'matches', 'validity', 'certificate',
119
+ 'chain', 'chainTruncated', 'standardName', 'size', 'subject', 'issuer',
120
+ 'serialNumber', 'validFrom', 'validTo', 'fingerprintSha1',
121
+ 'fingerprintSha256', 'isCertificateAuthority', 'subjectAltNames',
122
+ 'dnsNames', 'ipAddresses', 'otherNames', 'publicKey', 'bits', 'curve',
123
+ 'signature', 'oid', 'extendedKeyUsage', 'authorityInformationAccess',
124
+ 'ocsp', 'caIssuers', 'unknownMethods', 'extensionProfile', 'parsed', 'partial',
125
+ 'certificatePolicies', 'crlDistributionPoints', 'commonNames',
126
+ 'organizations', 'organizationalUnits', 'countries', 'localities', 'states',
127
+ 'connectionAttempts', 'resolvedAddressCount', 'discardedFields',
128
+ // Page identity, relationships, and bounded fingerprints.
129
+ 'identityVersion', 'documentLanguage', 'canonical', 'metaRefresh',
130
+ 'openGraph', 'title', 'siteName', 'generator', 'forms', 'postCount',
131
+ 'insecureActionCount', 'externalActionOrigins', 'resources', 'byType',
132
+ 'image', 'script', 'stylesheet', 'link', 'frame', 'media', 'object',
133
+ 'externalOrigins', 'embeddedOrigins', 'contactDomains', 'downloads',
134
+ 'explicitCount', 'riskyCount', 'riskyFileTypes', 'trackingIdentifiers',
135
+ 'fingerprints', 'fingerprintVersion', 'exact', 'normalizedHtml',
136
+ 'visibleText', 'domStructure', 'formStructure', 'resourceHosts',
137
+ 'identifiers', 'featureCount', 'nodeCount', 'formCount', 'controlCount',
138
+ 'parser', 'tokenCount', 'tagsExamined', 'discardedUrls', 'formsObserved',
139
+ 'relationshipTagsExamined', 'relationshipUrlsDiscarded',
140
+ 'trackingIdentifiersTruncated',
141
+ // Credential, structured identity, role, behaviour, technology, and posture
142
+ // projections. Only fixed categories/counts and bounded descriptions enter.
143
+ 'credentialSurfaceVersion', 'methods', 'actions', 'missing', 'get', 'post',
144
+ 'dialog', 'other', 'sameOrigin', 'external', 'cleartext', 'unclassified',
145
+ 'inputs', 'classifiedCount', 'categories', 'password', 'email', 'username',
146
+ 'one_time_code', 'payment', 'inputsObserved', 'classifiedInputs',
147
+ 'unclassifiedActions', 'structuredDataVersion', 'scriptsObserved',
148
+ 'scriptsExamined', 'charactersExamined', 'documentsParsed', 'malformedScripts',
149
+ 'externalScriptsSkipped', 'objectsExamined', 'arrayItemsExamined',
150
+ 'discardedProperties', 'entities', 'declaredOrigin', 'sameAsHosts',
151
+ 'pageRoleProfileVersion', 'primaryRole', 'role', 'rolesObserved',
152
+ 'clientBehaviorProfileVersion', 'scriptSummary', 'elementsObserved',
153
+ 'referencedScripts', 'inlineScripts', 'moduleScripts', 'indicators',
154
+ 'evidenceClass', 'occurrences', 'explanation', 'indicatorsObserved',
155
+ 'scriptElementsExamined', 'inlineCharactersExamined', 'technologyProfile',
156
+ 'browserLibraryProfile', 'catalog', 'knownExploitedCatalog', 'releasedAt',
157
+ 'sourceRevision', 'apparentVersion', 'detectionMethods', 'advisoryCount',
158
+ 'highestSeverity', 'advisoryIdentifiers', 'knownExploitedCount',
159
+ 'knownExploitedIdentifiers', 'weaknessClasses', 'htmlEvaluated',
160
+ 'generatorEvaluated', 'serverEvaluated', 'resourceOriginsEvaluated',
161
+ 'passiveHeadersEvaluated', 'tagLimitReached', 'referencesExamined',
162
+ 'inlineScriptsExamined', 'inlineSignatureCharactersExamined',
163
+ 'inlineSignatureTimedOut', 'inlineSignatureUnavailable', 'catalogComponents',
164
+ 'advisoryMatches', 'knownExploitedMatches', 'postureVersion',
165
+ 'potentialExposure', 'observedAbsence', 'unavailable',
166
+ // Bounded cross-row comparison evidence.
167
+ 'bulkComparison', 'technology', 'tls', 'ids', 'issuerLabel', 'spkiSha256',
168
+ ]);
16
169
  function recordOrNull(value) {
17
170
  return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
18
171
  }
19
- function cloneJson(value) {
20
- if (value === undefined)
172
+ function normalizedEvidenceKey(value) {
173
+ return value.toLowerCase().replace(/[^a-z0-9]/gu, '');
174
+ }
175
+ function privateEvidenceKey(value, item) {
176
+ const normalized = normalizedEvidenceKey(value);
177
+ if (SAFE_EVIDENCE_KEYS.has(normalized))
178
+ return false;
179
+ if (normalized === 'password' && (typeof item === 'number' || typeof item === 'boolean'))
180
+ return false;
181
+ return PRIVATE_EVIDENCE_KEYS.has(normalized)
182
+ || normalized.includes('authorization')
183
+ || normalized.includes('authheader')
184
+ || normalized.includes('cookie')
185
+ || normalized.includes('apikey')
186
+ || normalized.includes('secret')
187
+ || normalized.includes('sessionkey')
188
+ || normalized.includes('passwordvalue')
189
+ || normalized.endsWith('password')
190
+ || /(?:access|auth|bearer|csrf|refresh|session)token$/u.test(normalized)
191
+ || /(?:request|response|authorization|proxy)headers?$/u.test(normalized)
192
+ || (normalized.includes('credential') && !normalized.startsWith('credentialsurface'));
193
+ }
194
+ function portableUri(value) {
195
+ const text = boundedString(value, 2048);
196
+ if (!text)
197
+ return null;
198
+ try {
199
+ const url = new URL(text);
200
+ if (!['http:', 'https:', 'mailto:', 'tel:', 'dns:', 'openpgp4fpr:'].includes(url.protocol)
201
+ || url.username
202
+ || url.password)
203
+ return null;
204
+ if (url.protocol === 'http:' || url.protocol === 'https:') {
205
+ url.search = '';
206
+ url.hash = '';
207
+ }
208
+ return url.toString().slice(0, 2048);
209
+ }
210
+ catch {
211
+ return null;
212
+ }
213
+ }
214
+ function portableOrigin(value) {
215
+ const text = boundedString(value, 2048);
216
+ if (!text)
217
+ return null;
218
+ try {
219
+ const url = new URL(text);
220
+ return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password
221
+ ? url.origin
222
+ : null;
223
+ }
224
+ catch {
21
225
  return null;
22
- return JSON.parse(JSON.stringify(value));
226
+ }
227
+ }
228
+ function consumePortableProjectionEntry(state, depth) {
229
+ state.entries += 1;
230
+ if (state.entries > LOOKUP_EVIDENCE_PORTABLE_MAX_ENTRIES) {
231
+ throw new TypeError(`Lookup evidence projection exceeds the ${LOOKUP_EVIDENCE_PORTABLE_MAX_ENTRIES.toLocaleString('en')}-entry portable limit.`);
232
+ }
233
+ if (depth > LOOKUP_EVIDENCE_PORTABLE_MAX_DEPTH) {
234
+ throw new TypeError(`Lookup evidence projection exceeds the ${LOOKUP_EVIDENCE_PORTABLE_MAX_DEPTH}-level portable nesting limit.`);
235
+ }
236
+ }
237
+ function projectPortableString(value) {
238
+ const sanitized = value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, ' ');
239
+ return sanitized.length <= LOOKUP_EVIDENCE_PORTABLE_MAX_STRING_LENGTH
240
+ ? sanitized
241
+ : sanitized.slice(0, LOOKUP_EVIDENCE_PORTABLE_MAX_STRING_LENGTH);
242
+ }
243
+ function projectLookupEvidencePrivacySafeTreeValue(value, state, depth) {
244
+ consumePortableProjectionEntry(state, depth);
245
+ if (value === null || typeof value === 'boolean' || typeof value === 'number')
246
+ return value;
247
+ if (typeof value === 'string')
248
+ return projectPortableString(value);
249
+ if (Array.isArray(value)) {
250
+ return value.slice(0, LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS)
251
+ .map((item) => projectLookupEvidencePrivacySafeTreeValue(item, state, depth + 1));
252
+ }
253
+ const source = recordOrNull(value);
254
+ if (!source)
255
+ return null;
256
+ const output = {};
257
+ for (const [key, item] of Object.entries(source).slice(0, LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS)) {
258
+ if (!isSafeJsonObjectKey(key))
259
+ throw new TypeError('Lookup evidence projection contains an unsafe object key.');
260
+ const normalized = normalizedEvidenceKey(key);
261
+ if (privateEvidenceKey(normalized, item))
262
+ continue;
263
+ if (typeof item === 'string' && PORTABLE_URL_KEYS.has(normalized)) {
264
+ consumePortableProjectionEntry(state, depth + 1);
265
+ output[key] = portableUri(item);
266
+ continue;
267
+ }
268
+ output[key] = projectLookupEvidencePrivacySafeTreeValue(item, state, depth + 1);
269
+ }
270
+ return output;
271
+ }
272
+ export function projectLookupEvidencePrivacySafeTree(value) {
273
+ return projectLookupEvidencePrivacySafeTreeValue(value, { entries: 0 }, 0);
274
+ }
275
+ function projectLookupEvidenceAvailabilityValue(value, parentKey, state, depth) {
276
+ consumePortableProjectionEntry(state, depth);
277
+ if (value === null || typeof value === 'boolean' || typeof value === 'number')
278
+ return value;
279
+ if (typeof value === 'string') {
280
+ const normalizedParent = normalizedEvidenceKey(parentKey || '');
281
+ if (PORTABLE_URL_KEYS.has(normalizedParent))
282
+ return portableUri(value);
283
+ if (PORTABLE_ORIGIN_COLLECTION_KEYS.has(normalizedParent))
284
+ return portableOrigin(value);
285
+ return projectPortableString(value);
286
+ }
287
+ if (Array.isArray(value)) {
288
+ return value.slice(0, LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS)
289
+ .map((item) => projectLookupEvidenceAvailabilityValue(item, parentKey, state, depth + 1));
290
+ }
291
+ const source = recordOrNull(value);
292
+ if (!source)
293
+ return null;
294
+ const output = {};
295
+ for (const [key, item] of Object.entries(source).slice(0, LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS)) {
296
+ if (!LOOKUP_AVAILABILITY_PORTABLE_NESTED_KEYS.has(key)
297
+ || privateEvidenceKey(key, item))
298
+ continue;
299
+ output[key] = projectLookupEvidenceAvailabilityValue(item, key, state, depth + 1);
300
+ }
301
+ return output;
302
+ }
303
+ /**
304
+ * Canonical portable projection for the bounded Lookup availability models.
305
+ * The root and every nested record are positive allowlists, so an imported
306
+ * extension cannot become authenticated evidence merely by avoiding a
307
+ * credential-key denylist.
308
+ */
309
+ export function projectLookupEvidenceAvailability(value) {
310
+ const source = recordOrNull(value);
311
+ if (!source)
312
+ return null;
313
+ const state = { entries: 0 };
314
+ consumePortableProjectionEntry(state, 0);
315
+ const output = {};
316
+ for (const key of LOOKUP_AVAILABILITY_ANALYSIS_KEYS) {
317
+ if (!Object.hasOwn(source, key))
318
+ continue;
319
+ const item = source[key];
320
+ if (privateEvidenceKey(key, item))
321
+ continue;
322
+ output[key] = projectLookupEvidenceAvailabilityValue(item, key, state, 1);
323
+ }
324
+ return output;
325
+ }
326
+ export function assertLookupEvidencePrivacySafeTree(value) {
327
+ assertLookupEvidencePortableTree(value);
328
+ const pending = [value];
329
+ while (pending.length) {
330
+ const current = pending.pop();
331
+ if (current === null || typeof current === 'boolean' || typeof current === 'number')
332
+ continue;
333
+ if (typeof current === 'string') {
334
+ if (projectLookupEvidencePrivacySafeTree(current) !== current) {
335
+ throw new TypeError('Lookup evidence contains a non-portable string value.');
336
+ }
337
+ continue;
338
+ }
339
+ if (Array.isArray(current)) {
340
+ for (const item of current)
341
+ pending.push(item);
342
+ continue;
343
+ }
344
+ const source = recordOrNull(current);
345
+ if (!source)
346
+ throw new TypeError('Lookup evidence contains a non-JSON value.');
347
+ for (const [key, item] of Object.entries(source)) {
348
+ const normalized = normalizedEvidenceKey(key);
349
+ if (privateEvidenceKey(normalized, item)) {
350
+ throw new TypeError('Lookup evidence contains excluded private request or session material.');
351
+ }
352
+ if (typeof item === 'string' && PORTABLE_URL_KEYS.has(normalized) && portableUri(item) !== item) {
353
+ throw new TypeError('Lookup evidence contains a credential-bearing or non-portable URL.');
354
+ }
355
+ pending.push(item);
356
+ }
357
+ }
358
+ }
359
+ function projectedKnownRecord(value, keys) {
360
+ const source = recordOrNull(value);
361
+ if (!source)
362
+ return null;
363
+ const output = {};
364
+ for (const key of keys) {
365
+ if (Object.hasOwn(source, key))
366
+ output[key] = projectLookupEvidencePrivacySafeTree(source[key]);
367
+ }
368
+ return output;
369
+ }
370
+ export function assertLookupEvidencePortableTree(value) {
371
+ const pending = [{ value, depth: 0 }];
372
+ let entries = 0;
373
+ while (pending.length) {
374
+ const current = pending.pop();
375
+ if (!current)
376
+ break;
377
+ entries += 1;
378
+ if (entries > LOOKUP_EVIDENCE_PORTABLE_MAX_ENTRIES) {
379
+ throw new TypeError(`Lookup evidence exceeds the ${LOOKUP_EVIDENCE_PORTABLE_MAX_ENTRIES.toLocaleString('en')}-entry portable limit.`);
380
+ }
381
+ if (current.depth > LOOKUP_EVIDENCE_PORTABLE_MAX_DEPTH) {
382
+ throw new TypeError(`Lookup evidence exceeds the ${LOOKUP_EVIDENCE_PORTABLE_MAX_DEPTH}-level portable nesting limit.`);
383
+ }
384
+ if (typeof current.value === 'string') {
385
+ if (current.value.length > LOOKUP_EVIDENCE_PORTABLE_MAX_STRING_LENGTH) {
386
+ throw new TypeError('Lookup evidence contains an over-bound string.');
387
+ }
388
+ continue;
389
+ }
390
+ if (current.value === null || typeof current.value === 'boolean')
391
+ continue;
392
+ if (typeof current.value === 'number') {
393
+ if (!Number.isFinite(current.value)) {
394
+ throw new TypeError('Lookup evidence contains a non-finite number.');
395
+ }
396
+ continue;
397
+ }
398
+ if (Array.isArray(current.value)) {
399
+ if (current.value.length > LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS) {
400
+ throw new TypeError('Lookup evidence contains an over-bound array.');
401
+ }
402
+ for (const item of current.value)
403
+ pending.push({ value: item, depth: current.depth + 1 });
404
+ continue;
405
+ }
406
+ if (current.value && typeof current.value === 'object') {
407
+ const source = current.value;
408
+ const prototype = Object.getPrototypeOf(source);
409
+ if (prototype !== Object.prototype && prototype !== null) {
410
+ throw new TypeError('Lookup evidence contains a non-JSON object.');
411
+ }
412
+ const keys = Object.keys(source);
413
+ if (keys.length > LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS
414
+ || keys.some((key) => key.length > LOOKUP_EVIDENCE_PORTABLE_MAX_KEY_LENGTH
415
+ || /[\u0000-\u001f\u007f]/u.test(key)
416
+ || !isSafeJsonObjectKey(key))) {
417
+ throw new TypeError('Lookup evidence contains an over-bound object.');
418
+ }
419
+ for (const item of Object.values(source))
420
+ pending.push({ value: item, depth: current.depth + 1 });
421
+ continue;
422
+ }
423
+ throw new TypeError('Lookup evidence contains a non-JSON value.');
424
+ }
425
+ }
426
+ export function serializeLookupEvidence(value, pretty = false) {
427
+ assertLookupEvidencePortableTree(value);
428
+ let output;
429
+ try {
430
+ output = JSON.stringify(value, null, pretty ? 2 : 0);
431
+ }
432
+ catch {
433
+ throw new TypeError('Lookup evidence could not be serialized as JSON.');
434
+ }
435
+ if (new TextEncoder().encode(output).byteLength > LOOKUP_EVIDENCE_PORTABLE_MAX_BYTES) {
436
+ throw new TypeError('Lookup evidence exceeds the 5 MiB portable file limit.');
437
+ }
438
+ return output;
23
439
  }
24
440
  function boundedString(value, maximum) {
25
441
  if (typeof value !== 'string' || value.length > maximum || /[\u0000-\u001f\u007f]/.test(value))
@@ -39,14 +455,6 @@ function boundedTimestamp(value) {
39
455
  const time = Date.parse(text);
40
456
  return Number.isFinite(time) ? new Date(time).toISOString() : null;
41
457
  }
42
- function boundedSemanticVersion(value) {
43
- try {
44
- return normalizeBoundedSemanticVersion(value, 'Evidence application');
45
- }
46
- catch {
47
- return null;
48
- }
49
- }
50
458
  function boundedEndpoint(value) {
51
459
  const text = boundedString(value, 2048);
52
460
  if (!text)
@@ -76,6 +484,74 @@ function boundedHostname(value) {
76
484
  }
77
485
  return text;
78
486
  }
487
+ function canonicalIpv4(value) {
488
+ const text = boundedString(value, 64);
489
+ if (!text || !/^\d{1,3}(?:\.\d{1,3}){3}$/u.test(text))
490
+ return null;
491
+ const octets = text.split('.').map(Number);
492
+ if (octets.some((octet) => octet > 255))
493
+ return null;
494
+ return octets.join('.');
495
+ }
496
+ function canonicalIpv6(value) {
497
+ const text = boundedString(value, 64)?.toLowerCase();
498
+ if (!text || !text.includes(':') || text.includes('%'))
499
+ return null;
500
+ try {
501
+ const hostname = new URL(`http://[${text}]/`).hostname;
502
+ return hostname.startsWith('[') && hostname.endsWith(']')
503
+ ? hostname.slice(1, -1).toLowerCase()
504
+ : null;
505
+ }
506
+ catch {
507
+ return null;
508
+ }
509
+ }
510
+ function canonicalAsn(value) {
511
+ const text = boundedString(value, 32);
512
+ const match = text?.match(/^(?:AS)?(\d+)$/iu);
513
+ if (!match)
514
+ return null;
515
+ const number = Number(match[1]);
516
+ return Number.isInteger(number) && number >= 0 && number <= 4_294_967_295
517
+ ? `AS${number}`
518
+ : null;
519
+ }
520
+ export function projectLookupEvidenceQuery(value) {
521
+ const source = recordOrNull(value) || {};
522
+ const type = typeof source.type === 'string' && LOOKUP_QUERY_TYPES.has(source.type)
523
+ ? source.type
524
+ : 'domain';
525
+ if (type === 'domain') {
526
+ const inputHostname = [source.inputHostname, source.registrableDomain, source.submitted, source.query]
527
+ .map(boundedHostname)
528
+ .find((item) => Boolean(item)) || 'unknown.invalid';
529
+ const suppliedRegistrableDomain = boundedHostname(source.registrableDomain);
530
+ const registrableDomain = suppliedRegistrableDomain
531
+ && (inputHostname === suppliedRegistrableDomain || inputHostname.endsWith(`.${suppliedRegistrableDomain}`))
532
+ ? suppliedRegistrableDomain
533
+ : inputHostname;
534
+ return {
535
+ submitted: inputHostname,
536
+ type,
537
+ inputHostname,
538
+ registrableDomain,
539
+ isSubdomain: inputHostname !== registrableDomain,
540
+ };
541
+ }
542
+ const candidate = source.submitted ?? source.query;
543
+ const submitted = type === 'ipv4'
544
+ ? canonicalIpv4(candidate)
545
+ : type === 'ipv6'
546
+ ? canonicalIpv6(candidate)
547
+ : canonicalAsn(candidate);
548
+ if (!submitted)
549
+ throw new TypeError(`Lookup evidence contains an invalid ${type} query.`);
550
+ return { submitted, type, inputHostname: null, registrableDomain: null, isSubdomain: false };
551
+ }
552
+ function completeTerminalObservation(status, truncated) {
553
+ return truncated !== true && (status === 'success' || status === 'not_found');
554
+ }
79
555
  function boundedPublishedUri(value, protocols) {
80
556
  const text = boundedString(value, 2048);
81
557
  if (!text)
@@ -204,18 +680,189 @@ function sslblSource(value) {
204
680
  limitations: boundedStringList(source.limitations, 10, 300),
205
681
  };
206
682
  }
207
- function networkAttempt(value) {
683
+ function rdapAttempt(value) {
208
684
  const attempt = recordOrNull(value) || {};
209
685
  const endpoint = boundedEndpoint(attempt.endpoint);
210
686
  return {
211
687
  endpoint,
212
688
  transportSecurity: endpoint ? endpoint.startsWith('https:') ? 'https' : 'http' : null,
213
689
  status: boundedHttpStatus(attempt.status),
214
- outcome: boundedString(attempt.outcome, 40),
690
+ outcome: typeof attempt.outcome === 'string' && RDAP_ATTEMPT_OUTCOMES.has(attempt.outcome)
691
+ ? attempt.outcome
692
+ : 'unknown',
215
693
  detail: boundedString(attempt.detail, 240),
216
694
  selected: attempt.selected === true,
217
695
  };
218
696
  }
697
+ function boundedRdapAttempts(value, maximum) {
698
+ return (Array.isArray(value) ? value : [])
699
+ .slice(0, maximum)
700
+ .map(rdapAttempt);
701
+ }
702
+ function whoisChainHop(value) {
703
+ const hop = recordOrNull(value) || {};
704
+ const queryProfile = typeof hop.queryProfile === 'string' && WHOIS_QUERY_PROFILES.has(hop.queryProfile)
705
+ ? hop.queryProfile
706
+ : null;
707
+ const error = boundedString(hop.error, 240);
708
+ return {
709
+ server: boundedHostname(hop.server),
710
+ address: boundedString(hop.address, 64),
711
+ queriedAt: boundedTimestamp(hop.queriedAt),
712
+ queryProfile,
713
+ responseEncoding: hop.responseEncoding === 'utf-8' ? 'utf-8' : null,
714
+ status: queryProfile === 'not-issued'
715
+ ? 'not_issued'
716
+ : error
717
+ ? 'error'
718
+ : typeof hop.response === 'string'
719
+ ? 'success'
720
+ : 'unknown',
721
+ detail: error,
722
+ };
723
+ }
724
+ function boundedWhoisChain(value) {
725
+ return (Array.isArray(value) ? value : [])
726
+ .slice(0, 16)
727
+ .map(whoisChainHop);
728
+ }
729
+ function lookupTimingDiagnostics(value) {
730
+ const source = recordOrNull(value);
731
+ if (source?.version !== 1)
732
+ return null;
733
+ return {
734
+ version: 1,
735
+ totalMs: boundedInteger(source.totalMs, 120_000),
736
+ sources: (Array.isArray(source.sources) ? source.sources : [])
737
+ .slice(0, LOOKUP_TIMING_SOURCES.size)
738
+ .flatMap((item) => {
739
+ const entry = recordOrNull(item);
740
+ if (!entry
741
+ || typeof entry.source !== 'string'
742
+ || !LOOKUP_TIMING_SOURCES.has(entry.source)
743
+ || (entry.outcome !== 'fulfilled' && entry.outcome !== 'rejected'))
744
+ return [];
745
+ return [{
746
+ source: entry.source,
747
+ outcome: entry.outcome,
748
+ durationMs: boundedInteger(entry.durationMs, 120_000),
749
+ completedAfterMs: boundedInteger(entry.completedAfterMs, 120_000),
750
+ }];
751
+ }),
752
+ };
753
+ }
754
+ function registryAccessDiagnostics(value) {
755
+ const source = recordOrNull(value);
756
+ if (!source)
757
+ return null;
758
+ const officialLookupUrl = portableUri(source.officialLookupUrl);
759
+ return {
760
+ suffix: boundedHostname(source.suffix),
761
+ coverageState: boundedString(source.coverageState, 40),
762
+ whoisAccessProfile: boundedString(source.whoisAccessProfile, 80),
763
+ rdapAccessProfile: boundedString(source.rdapAccessProfile, 80),
764
+ ...(officialLookupUrl ? { officialLookupUrl } : {}),
765
+ limitation: boundedString(source.limitation, 500),
766
+ authority: source.authority === 'context_only' ? 'context_only' : null,
767
+ };
768
+ }
769
+ function registrarRdapDiagnostics(value) {
770
+ const source = recordOrNull(value);
771
+ if (!source)
772
+ return null;
773
+ const endpoint = boundedEndpoint(source.endpoint);
774
+ return {
775
+ status: sourceStatus(source.status, REGISTRAR_RDAP_STATUSES, 'error'),
776
+ endpoint,
777
+ transportSecurity: endpoint ? endpoint.startsWith('https:') ? 'https' : 'http' : null,
778
+ httpStatus: boundedHttpStatus(source.httpStatus),
779
+ fetchedAt: boundedTimestamp(source.fetchedAt),
780
+ attempt: source.attempt === null || source.attempt === undefined ? null : rdapAttempt(source.attempt),
781
+ };
782
+ }
783
+ function lookupDiagnostics(value) {
784
+ const source = recordOrNull(value) || {};
785
+ const rdap = recordOrNull(source.rdap) || {};
786
+ const whois = recordOrNull(source.whois) || {};
787
+ const availability = recordOrNull(source.availability) || {};
788
+ const reverseDns = recordOrNull(source.reverseDns);
789
+ const network = recordOrNull(source.network);
790
+ const securityTxt = recordOrNull(source.securityTxt);
791
+ const sslbl = recordOrNull(source.sslbl);
792
+ const rdapEndpoint = boundedEndpoint(rdap.endpoint);
793
+ const networkEndpoint = boundedEndpoint(network?.endpoint);
794
+ const networkPublicationAvailable = network?.status === 'success' || network?.status === 'partial';
795
+ const securityTxtEndpoint = boundedPublishedUri(securityTxt?.endpoint, ['https:']);
796
+ const version = boundedInteger(source.version, 10);
797
+ const timing = lookupTimingDiagnostics(source.timing);
798
+ const registryAccess = registryAccessDiagnostics(source.registryAccess);
799
+ const registrar = registrarRdapDiagnostics(rdap.registrar);
800
+ return {
801
+ ...(version === null ? {} : { version }),
802
+ ...(timing ? { timing } : {}),
803
+ ...(registryAccess ? { registryAccess } : {}),
804
+ rdap: {
805
+ status: sourceStatus(rdap.status, RDAP_SOURCE_STATUSES, 'error'),
806
+ errorCode: boundedString(rdap.errorCode, 80),
807
+ endpoint: rdapEndpoint,
808
+ transportSecurity: rdapEndpoint ? rdapEndpoint.startsWith('https:') ? 'https' : 'http' : null,
809
+ httpStatus: boundedHttpStatus(rdap.httpStatus),
810
+ fetchedAt: boundedTimestamp(rdap.fetchedAt),
811
+ attempts: boundedRdapAttempts(rdap.attempts, 16),
812
+ ...(registrar ? { registrar } : {}),
813
+ },
814
+ whois: {
815
+ status: sourceStatus(whois.status, WHOIS_SOURCE_STATUSES, 'error'),
816
+ errorCode: boundedString(whois.errorCode, 80),
817
+ queriedAt: boundedTimestamp(whois.queriedAt),
818
+ authoritativeHop: boundedHostname(whois.authoritativeHop),
819
+ failedHop: boundedHostname(whois.failedHop),
820
+ conflictingHop: boundedHostname(whois.conflictingHop),
821
+ },
822
+ ...(recordOrNull(source.availability) ? { availability: {
823
+ status: sourceStatus(availability.status, AVAILABILITY_DIAGNOSTIC_STATUSES, 'error'),
824
+ errorCode: boundedString(availability.errorCode, 80),
825
+ resultState: boundedString(availability.resultState, 40),
826
+ } } : {}),
827
+ ...(reverseDns ? { reverseDns: {
828
+ status: sourceStatus(reverseDns.status, REVERSE_DNS_STATUSES, 'error'),
829
+ observedAt: boundedTimestamp(reverseDns.observedAt),
830
+ complete: completeTerminalObservation(sourceStatus(reverseDns.status, REVERSE_DNS_STATUSES, 'error'), reverseDns.truncated),
831
+ truncated: reverseDns.truncated === true,
832
+ } } : {}),
833
+ ...(network ? { network: {
834
+ status: sourceStatus(network.status, NETWORK_CONTEXT_STATUSES, 'error'),
835
+ address: networkPublicationAvailable ? boundedString(network.address, 64) : null,
836
+ family: networkPublicationAvailable && (network.family === 4 || network.family === 6) ? network.family : null,
837
+ addressSource: networkPublicationAvailable && typeof network.addressSource === 'string' && NETWORK_ADDRESS_SOURCES.has(network.addressSource)
838
+ ? network.addressSource
839
+ : null,
840
+ endpoint: networkPublicationAvailable ? networkEndpoint : null,
841
+ transportSecurity: networkPublicationAvailable && networkEndpoint ? networkEndpoint.startsWith('https:') ? 'https' : 'http' : null,
842
+ httpStatus: networkPublicationAvailable ? boundedHttpStatus(network.httpStatus) : null,
843
+ fetchedAt: networkPublicationAvailable ? boundedTimestamp(network.fetchedAt) : null,
844
+ attempts: networkPublicationAvailable ? boundedRdapAttempts(network.attempts, 3) : [],
845
+ } } : {}),
846
+ ...(securityTxt ? { securityTxt: {
847
+ status: boundedString(securityTxt.status, 40),
848
+ state: typeof securityTxt.state === 'string' && SECURITY_TXT_STATES.has(securityTxt.state)
849
+ ? securityTxt.state
850
+ : null,
851
+ endpoint: securityTxtEndpoint,
852
+ httpStatus: boundedHttpStatus(securityTxt.httpStatus),
853
+ observedAt: boundedTimestamp(securityTxt.observedAt),
854
+ complete: securityTxt.complete === true,
855
+ truncated: securityTxt.truncated === true,
856
+ } } : {}),
857
+ ...(sslbl ? { sslbl: {
858
+ status: typeof sslbl.status === 'string' && SSLBL_STATES.has(sslbl.status) ? sslbl.status : null,
859
+ verdict: typeof sslbl.verdict === 'string' && SSLBL_VERDICTS.has(sslbl.verdict) ? sslbl.verdict : null,
860
+ observedAt: boundedTimestamp(sslbl.observedAt),
861
+ complete: sslbl.complete === true,
862
+ snapshotUpdatedAt: boundedTimestamp(sslbl.snapshotUpdatedAt),
863
+ } } : {}),
864
+ };
865
+ }
219
866
  function networkSource(value) {
220
867
  const source = recordOrNull(value);
221
868
  if (!source
@@ -228,6 +875,7 @@ function networkSource(value) {
228
875
  const network = recordOrNull(source.network);
229
876
  const diagnostics = recordOrNull(source.diagnostics);
230
877
  const rdapEndpoint = boundedEndpoint(rdap?.endpoint);
878
+ const publicationAvailable = source.status === 'success' || source.status === 'partial';
231
879
  return {
232
880
  contextVersion: 1,
233
881
  version: source.version === 1 ? 1 : null,
@@ -236,7 +884,7 @@ function networkSource(value) {
236
884
  scanMode: source.scanMode === 'deep' ? 'deep' : null,
237
885
  source: source.source === 'ip_rdap' ? 'ip_rdap' : null,
238
886
  durationMs: boundedInteger(source.durationMs, 120_000),
239
- complete: source.complete === true,
887
+ complete: completeTerminalObservation(source.status, source.truncated),
240
888
  truncated: source.truncated === true,
241
889
  limitations: boundedStringList(source.limitations, 10, 300),
242
890
  diagnostics: diagnostics ? {
@@ -249,7 +897,7 @@ function networkSource(value) {
249
897
  cidrCount: boundedInteger(diagnostics.cidrCount, 16),
250
898
  } : null,
251
899
  detail: boundedString(source.detail, 300),
252
- endpoint: endpoint ? {
900
+ endpoint: publicationAvailable && endpoint ? {
253
901
  address: boundedString(endpoint.address, 64),
254
902
  family: endpoint.family === 4 || endpoint.family === 6 ? endpoint.family : null,
255
903
  selectedFrom: typeof endpoint.selectedFrom === 'string'
@@ -257,14 +905,14 @@ function networkSource(value) {
257
905
  ? endpoint.selectedFrom
258
906
  : null,
259
907
  } : null,
260
- rdap: rdap ? {
908
+ rdap: publicationAvailable && rdap ? {
261
909
  endpoint: rdapEndpoint,
262
910
  transportSecurity: rdapEndpoint ? rdapEndpoint.startsWith('https:') ? 'https' : 'http' : null,
263
911
  httpStatus: boundedHttpStatus(rdap.httpStatus),
264
912
  fetchedAt: boundedTimestamp(rdap.fetchedAt),
265
- attempts: (Array.isArray(rdap.attempts) ? rdap.attempts : []).slice(0, 3).map(networkAttempt),
913
+ attempts: boundedRdapAttempts(rdap.attempts, 3),
266
914
  } : null,
267
- network: network ? {
915
+ network: publicationAvailable && network ? {
268
916
  handle: boundedString(network.handle, 300),
269
917
  name: boundedString(network.name, 300),
270
918
  holder: boundedString(network.holder, 300),
@@ -289,7 +937,8 @@ function reverseDnsSource(value) {
289
937
  const records = recordOrNull(source.records);
290
938
  const diagnostics = recordOrNull(source.diagnostics);
291
939
  const ptrDiagnostics = recordOrNull(diagnostics?.ptr);
292
- const ptr = [...new Set((Array.isArray(records?.ptr) ? records.ptr : [])
940
+ const publicationAvailable = source.status === 'success' || source.status === 'partial';
941
+ const ptr = [...new Set((publicationAvailable && Array.isArray(records?.ptr) ? records.ptr : [])
293
942
  .slice(0, 8)
294
943
  .map(boundedHostname)
295
944
  .filter((item) => item !== null))].sort();
@@ -300,7 +949,7 @@ function reverseDnsSource(value) {
300
949
  scanMode: source.scanMode === 'deep' ? 'deep' : null,
301
950
  source: 'reverse_dns',
302
951
  durationMs: boundedInteger(source.durationMs, 120_000),
303
- complete: source.complete === true,
952
+ complete: completeTerminalObservation(source.status, source.truncated),
304
953
  truncated: source.truncated === true,
305
954
  limitations: boundedStringList(source.limitations, 10, 300),
306
955
  diagnostics: ptrDiagnostics ? {
@@ -314,40 +963,62 @@ function reverseDnsSource(value) {
314
963
  records: { ptr },
315
964
  };
316
965
  }
317
- function rdapSource(rdap) {
966
+ function boundedSourceError(value, fallback) {
967
+ const normalized = String(value ?? '')
968
+ .replace(/[\u0000-\u001f\u007f]+/gu, ' ')
969
+ .replace(/\s+/gu, ' ')
970
+ .trim();
971
+ return normalized.slice(0, 10_000) || fallback;
972
+ }
973
+ function sourceStatus(value, allowed, fallback) {
974
+ return typeof value === 'string' && allowed.has(value) ? value : fallback;
975
+ }
976
+ function rdapSource(rdap, diagnostics) {
318
977
  const source = recordOrNull(rdap) || {};
319
- if (source.error)
978
+ const status = sourceStatus(diagnostics?.status, RDAP_SOURCE_STATUSES, 'error');
979
+ const attempts = boundedRdapAttempts(source.attempts ?? diagnostics?.attempts, 16);
980
+ if (status === 'error')
320
981
  return {
321
982
  status: 'error',
322
- error: String(source.error),
323
- attempts: cloneJson(source.attempts || []),
983
+ error: boundedSourceError(source.error ?? diagnostics?.errorCode, 'RDAP source reported an error.'),
984
+ attempts,
324
985
  };
986
+ const hasPublication = status === 'success' || status === 'partial';
325
987
  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),
988
+ status,
989
+ endpoint: boundedEndpoint(source.rdapServer ?? diagnostics?.endpoint),
990
+ transportSecurity: source.transportSecurity === 'http' || source.transportSecurity === 'https'
991
+ ? source.transportSecurity
992
+ : diagnostics?.transportSecurity === 'http' || diagnostics?.transportSecurity === 'https'
993
+ ? diagnostics.transportSecurity
994
+ : null,
995
+ httpStatus: boundedHttpStatus(source.upstreamStatus ?? diagnostics?.httpStatus),
996
+ fetchedAt: boundedTimestamp(source.fetchedAt ?? diagnostics?.fetchedAt),
997
+ attempts,
998
+ parsed: hasPublication ? projectLookupEvidencePrivacySafeTree(source.parsed) : null,
999
+ raw: hasPublication ? projectLookupEvidencePrivacySafeTree(source.data) : null,
334
1000
  };
335
1001
  }
336
- function whoisSource(whois) {
1002
+ function whoisSource(whois, diagnostics) {
337
1003
  const source = recordOrNull(whois) || {};
338
- if (source.error)
339
- return { status: 'error', error: String(source.error) };
1004
+ const status = sourceStatus(diagnostics?.status, WHOIS_SOURCE_STATUSES, 'error');
1005
+ if (status === 'error')
1006
+ return {
1007
+ status: 'error',
1008
+ error: boundedSourceError(source.error ?? diagnostics?.errorCode, 'WHOIS source reported an error.'),
1009
+ };
340
1010
  const parsed = recordOrNull(source.parsed);
341
- const chain = Array.isArray(source.chain) ? source.chain : [];
1011
+ const hasPublication = status === 'complete' || status === 'partial';
1012
+ const chain = hasPublication ? boundedWhoisChain(source.chain) : [];
342
1013
  const firstHop = recordOrNull(chain[0]);
343
1014
  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),
1015
+ status,
1016
+ queriedAt: boundedTimestamp(firstHop?.queriedAt ?? diagnostics?.queriedAt),
1017
+ authoritativeHop: boundedHostname(parsed?.authoritativeHop ?? diagnostics?.authoritativeHop),
1018
+ failedHop: boundedHostname(parsed?.failedHop ?? diagnostics?.failedHop),
1019
+ conflictingHop: boundedHostname(parsed?.conflictingHop ?? diagnostics?.conflictingHop),
1020
+ parsed: hasPublication ? projectLookupEvidencePrivacySafeTree(parsed) : null,
1021
+ chain,
351
1022
  };
352
1023
  }
353
1024
  function registrarPublicationComparison(body, registryParsed) {
@@ -359,7 +1030,9 @@ function registrarPublicationComparison(body, registryParsed) {
359
1030
  if (!registrar && !registrarDiagnostics)
360
1031
  return null;
361
1032
  const reportedStatus = registrar?.status ?? registrarDiagnostics?.status;
362
- const parsed = recordOrNull(registrar?.parsed);
1033
+ const parsed = ['success', 'partial'].includes(String(reportedStatus))
1034
+ ? recordOrNull(projectLookupEvidencePrivacySafeTree(registrar?.parsed))
1035
+ : null;
363
1036
  const registrarStatus = typeof reportedStatus === 'string' && REGISTRAR_RDAP_STATUSES.has(reportedStatus)
364
1037
  ? (reportedStatus === 'success' && !parsed ? 'partial' : reportedStatus)
365
1038
  : 'error';
@@ -369,17 +1042,25 @@ function registrarPublicationComparison(body, registryParsed) {
369
1042
  });
370
1043
  }
371
1044
  export function buildLookupEvidence(response, options = {}) {
1045
+ assertBoundedJsonStructure(response, 'Lookup response');
372
1046
  const { generatedAt = new Date().toISOString(), idnAnalysis = null, applicationVersion = null } = options;
373
1047
  const body = recordOrNull(response) || {};
374
1048
  const rdap = recordOrNull(body.rdap);
375
1049
  const whois = recordOrNull(body.whois);
376
1050
  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);
1051
+ const projectedDiagnostics = lookupDiagnostics(diagnostics);
1052
+ const rdapDiagnostics = recordOrNull(projectedDiagnostics.rdap);
1053
+ const whoisDiagnostics = recordOrNull(projectedDiagnostics.whois);
1054
+ const projectedRdap = rdapSource(rdap, rdapDiagnostics);
1055
+ const projectedWhois = whoisSource(whois, whoisDiagnostics);
1056
+ const projectedRdapRecord = recordOrNull(projectedRdap) || {};
1057
+ const projectedWhoisRecord = recordOrNull(projectedWhois) || {};
1058
+ const rdapParsed = ['success', 'partial'].includes(String(projectedRdapRecord.status))
1059
+ ? recordOrNull(projectedRdapRecord.parsed)
1060
+ : null;
1061
+ const whoisParsed = ['complete', 'partial'].includes(String(projectedWhoisRecord.status))
1062
+ ? recordOrNull(projectedWhoisRecord.parsed)
1063
+ : null;
383
1064
  // Rebuild the pure interpretation from bounded source fields instead of
384
1065
  // trusting a supplied derived object. This adds no collection or network
385
1066
  // work and lets saved or fast responses carry current source-health-aware
@@ -391,46 +1072,38 @@ export function buildLookupEvidence(response, options = {}) {
391
1072
  whoisParsed,
392
1073
  whoisStatus: whoisDiagnostics?.status,
393
1074
  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,
397
1075
  });
398
- return {
1076
+ const query = projectLookupEvidenceQuery(body);
1077
+ const availabilityAnalysis = projectLookupEvidenceAvailability(body.availability);
1078
+ const idn = projectedKnownRecord(idnAnalysis, LOOKUP_IDN_ANALYSIS_KEYS);
1079
+ const evidence = {
399
1080
  schema: LOOKUP_EVIDENCE_SCHEMA,
400
1081
  schemaVersion: LOOKUP_EVIDENCE_SCHEMA_VERSION,
401
1082
  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),
1083
+ application: buildPortableGeneratorMetadata(applicationVersion),
1084
+ query,
1085
+ diagnostics: projectedDiagnostics,
415
1086
  sources: {
416
- rdap: rdapSource(rdap),
417
- whois: whoisSource(whois),
1087
+ rdap: projectedRdap,
1088
+ whois: projectedWhois,
418
1089
  reverseDns: reverseDnsSource(body.reverseDns),
419
1090
  network: networkSource(body.networkContext),
420
1091
  securityTxt: securityTxtSource(body.securityTxt),
421
1092
  sslbl: sslblSource(body.sslbl),
422
1093
  },
423
1094
  analysis: {
424
- availability: cloneJson(body.availability),
425
- idn: cloneJson(idnAnalysis),
426
- registryInsights,
427
- registryComparison: compareRegistrySources(rdapParsed, whoisParsed, {
1095
+ availability: availabilityAnalysis,
1096
+ idn,
1097
+ registryInsights: projectLookupEvidencePrivacySafeTree(registryInsights),
1098
+ registryComparison: projectLookupEvidencePrivacySafeTree(compareRegistrySources(rdapParsed, whoisParsed, {
428
1099
  rdapStatus: rdapDiagnostics?.status,
429
1100
  whoisStatus: whoisDiagnostics?.status,
430
- }),
431
- registrarPublicationComparison: registrarPublicationComparison(body, rdapParsed),
1101
+ })),
1102
+ registrarPublicationComparison: projectLookupEvidencePrivacySafeTree(registrarPublicationComparison(body, rdapParsed)),
432
1103
  },
433
1104
  };
1105
+ assertLookupEvidencePortableTree(evidence);
1106
+ return evidence;
434
1107
  }
435
1108
  export function evidenceFilename(response, now = Date.now()) {
436
1109
  const record = recordOrNull(response);