@slicedearth/whoisleuth-cli 1.32.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 (189) hide show
  1. package/LICENSE +661 -0
  2. package/LICENSES/Retire.js-Apache-2.0.txt +199 -0
  3. package/NOTICE +76 -0
  4. package/README.md +84 -0
  5. package/TRADEMARKS.md +41 -0
  6. package/bin/whoisleuth.mjs +35 -0
  7. package/cli/archive-inspect.mjs +198 -0
  8. package/cli/arguments.mjs +1022 -0
  9. package/cli/artifact-verify.mjs +210 -0
  10. package/cli/bulk-checkpoint.mjs +218 -0
  11. package/cli/bulk-command-runner.mjs +109 -0
  12. package/cli/bulk-output.mjs +112 -0
  13. package/cli/bulk.mjs +133 -0
  14. package/cli/compare.mjs +238 -0
  15. package/cli/completion.mjs +175 -0
  16. package/cli/csv.mjs +1 -0
  17. package/cli/discover.mjs +50 -0
  18. package/cli/discovery-command-runner.mjs +36 -0
  19. package/cli/discovery-observation-snapshot.mjs +315 -0
  20. package/cli/discovery-scan-command-runner.mjs +141 -0
  21. package/cli/discovery-scan.mjs +325 -0
  22. package/cli/discovery-snapshot.mjs +133 -0
  23. package/cli/discovery-workflow.mjs +73 -0
  24. package/cli/doctor.mjs +137 -0
  25. package/cli/errors.mjs +21 -0
  26. package/cli/evidence-command-arguments.mjs +145 -0
  27. package/cli/evidence-command-runner.mjs +106 -0
  28. package/cli/evidence-signing.mjs +215 -0
  29. package/cli/exit-codes.mjs +9 -0
  30. package/cli/export-evidence.mjs +28 -0
  31. package/cli/formatters/evidence-report.mjs +3 -0
  32. package/cli/formatters/html.mjs +69 -0
  33. package/cli/formatters/json.mjs +135 -0
  34. package/cli/formatters/markdown.mjs +3 -0
  35. package/cli/formatters/terminal.mjs +658 -0
  36. package/cli/http.mjs +27 -0
  37. package/cli/lookup-command-runner.mjs +87 -0
  38. package/cli/lookup-diff.mjs +171 -0
  39. package/cli/mail-review.mjs +204 -0
  40. package/cli/manual.mjs +34 -0
  41. package/cli/network-command-runner.mjs +88 -0
  42. package/cli/output-file.mjs +97 -0
  43. package/cli/page-compare.mjs +123 -0
  44. package/cli/posture.mjs +20 -0
  45. package/cli/progress-events.mjs +53 -0
  46. package/cli/progress.mjs +56 -0
  47. package/cli/registry-access.mjs +3 -0
  48. package/cli/registry-support.mjs +120 -0
  49. package/cli/risk-calibration.mjs +294 -0
  50. package/cli/runner-types.mjs +1 -0
  51. package/cli/runner.mjs +726 -0
  52. package/cli/saved-lookup.mjs +87 -0
  53. package/cli/source-reliability.mjs +727 -0
  54. package/cli/strict-exit.mjs +55 -0
  55. package/cli/terminal-presentation.mjs +147 -0
  56. package/docs/cli.md +827 -0
  57. package/frontend/src/lib/analysis/acquisition-decision-packet.js +93 -0
  58. package/frontend/src/lib/analysis/acquisition-due-diligence.js +391 -0
  59. package/frontend/src/lib/analysis/artifact-integrity.js +21 -0
  60. package/frontend/src/lib/analysis/brand-profile-model.js +421 -0
  61. package/frontend/src/lib/analysis/bulk-domain-comparison.js +246 -0
  62. package/frontend/src/lib/analysis/bulk-mail-exposure.js +192 -0
  63. package/frontend/src/lib/analysis/bulk-retry-plan.js +86 -0
  64. package/frontend/src/lib/analysis/bulk-review-export.js +62 -0
  65. package/frontend/src/lib/analysis/bulk-review-model.js +266 -0
  66. package/frontend/src/lib/analysis/bulk-session-model.js +515 -0
  67. package/frontend/src/lib/analysis/bulk-sort.js +49 -0
  68. package/frontend/src/lib/analysis/campaign-model.js +265 -0
  69. package/frontend/src/lib/analysis/case-evidence-model.js +642 -0
  70. package/frontend/src/lib/analysis/case-migration-model.js +248 -0
  71. package/frontend/src/lib/analysis/case-model.js +5 -0
  72. package/frontend/src/lib/analysis/case-record-contracts.js +44 -0
  73. package/frontend/src/lib/analysis/case-record-core.js +232 -0
  74. package/frontend/src/lib/analysis/case-record-model.js +7 -0
  75. package/frontend/src/lib/analysis/case-record-operations.js +224 -0
  76. package/frontend/src/lib/analysis/case-response-model.js +640 -0
  77. package/frontend/src/lib/analysis/case-response-packet.js +573 -0
  78. package/frontend/src/lib/analysis/case-storage-model.js +93 -0
  79. package/frontend/src/lib/analysis/detection-rule-model.js +306 -0
  80. package/frontend/src/lib/analysis/dns-record-normalization.js +11 -0
  81. package/frontend/src/lib/analysis/http-summary.js +165 -0
  82. package/frontend/src/lib/analysis/investigation-guide.js +500 -0
  83. package/frontend/src/lib/analysis/investigation-template-model.js +144 -0
  84. package/frontend/src/lib/analysis/page-baseline.js +272 -0
  85. package/frontend/src/lib/analysis/page-similarity.js +179 -0
  86. package/frontend/src/lib/analysis/relationship-evidence.js +265 -0
  87. package/frontend/src/lib/analysis/relationship-observation-model.js +423 -0
  88. package/frontend/src/lib/analysis/scoring.js +135 -0
  89. package/frontend/src/lib/analysis/shortlist-model.js +209 -0
  90. package/frontend/src/lib/analysis/utils.js +265 -0
  91. package/frontend/src/lib/analysis/watchlist-history.js +643 -0
  92. package/frontend/src/lib/analysis/watchlist-store.js +127 -0
  93. package/frontend/src/lib/analysis/website-snapshot-model.js +302 -0
  94. package/frontend/src/lib/analysis/workspace-archive-crypto.js +282 -0
  95. package/frontend/src/lib/analysis/workspace-archive.js +472 -0
  96. package/lib/abort.mjs +26 -0
  97. package/lib/abusech-auth.mjs +19 -0
  98. package/lib/availability.mjs +716 -0
  99. package/lib/bounded-contract-normalizers.mjs +61 -0
  100. package/lib/browser-library-profile.mjs +310 -0
  101. package/lib/bulk-comparison-evidence.mjs +83 -0
  102. package/lib/classify.mjs +93 -0
  103. package/lib/client-behavior-profile.mjs +83 -0
  104. package/lib/credential-surface-profile.mjs +66 -0
  105. package/lib/csv.mjs +23 -0
  106. package/lib/ct-query.mjs +27 -0
  107. package/lib/ct-search.mjs +393 -0
  108. package/lib/distributed-operation-budget.mjs +461 -0
  109. package/lib/dns-delegation-health.mjs +344 -0
  110. package/lib/dns-intelligence.mjs +739 -0
  111. package/lib/dns-mx.mjs +24 -0
  112. package/lib/dns-resolver-selection.mjs +33 -0
  113. package/lib/domain-posture-analysis.mjs +254 -0
  114. package/lib/domain-posture-parsers.mjs +358 -0
  115. package/lib/domain-posture.mjs +745 -0
  116. package/lib/error-detail.mjs +10 -0
  117. package/lib/evidence-export.mjs +431 -0
  118. package/lib/evidence-report-markdown.mjs +71 -0
  119. package/lib/evidence-report.mjs +391 -0
  120. package/lib/external-intelligence-risk.mjs +108 -0
  121. package/lib/favicon.mjs +146 -0
  122. package/lib/feature-policy.mjs +70 -0
  123. package/lib/generated/retire-browser-catalog.mjs +8724 -0
  124. package/lib/generated/unicode-confusables-17.mjs +93 -0
  125. package/lib/hostname.mjs +38 -0
  126. package/lib/html-signals.mjs +680 -0
  127. package/lib/http-intelligence.mjs +232 -0
  128. package/lib/idn-confusable-policy.mjs +86 -0
  129. package/lib/idn-confusables.mjs +465 -0
  130. package/lib/lookup-cache.mjs +130 -0
  131. package/lib/lookup-progress.mjs +291 -0
  132. package/lib/lookup-source-progress.mjs +109 -0
  133. package/lib/lookup-threat-provider-inventory.mjs +13 -0
  134. package/lib/lookup.mjs +592 -0
  135. package/lib/observation.mjs +114 -0
  136. package/lib/observed-network-context.mjs +355 -0
  137. package/lib/operation-budget.mjs +489 -0
  138. package/lib/page-fingerprints.mjs +481 -0
  139. package/lib/page-role-profile.mjs +117 -0
  140. package/lib/perceptual-hash.mjs +451 -0
  141. package/lib/rdap-attempts.mjs +34 -0
  142. package/lib/rdap-bootstrap.mjs +173 -0
  143. package/lib/rdap-capabilities.mjs +292 -0
  144. package/lib/rdap-client.mjs +125 -0
  145. package/lib/rdap-normalization.mjs +691 -0
  146. package/lib/rdap-registrar.mjs +196 -0
  147. package/lib/rdap-transport.mjs +39 -0
  148. package/lib/rdap-types.mjs +1 -0
  149. package/lib/rdap-validation.mjs +94 -0
  150. package/lib/rdap.mjs +16 -0
  151. package/lib/registry-access.mjs +32 -0
  152. package/lib/registry-capabilities.mjs +117 -0
  153. package/lib/registry-capability-catalogue.mjs +1206 -0
  154. package/lib/registry-comparison.mjs +318 -0
  155. package/lib/registry-dates.mjs +190 -0
  156. package/lib/registry-insights.mjs +325 -0
  157. package/lib/response-policy.mjs +256 -0
  158. package/lib/risk-scoring.mjs +164 -0
  159. package/lib/safe-fetch.mjs +446 -0
  160. package/lib/security-txt.mjs +359 -0
  161. package/lib/service-binding-dns.mjs +608 -0
  162. package/lib/sslbl-certificates.generated.mjs +143 -0
  163. package/lib/sslbl-intelligence.mjs +241 -0
  164. package/lib/static-html-analysis.mjs +340 -0
  165. package/lib/structured-data-identity.mjs +323 -0
  166. package/lib/threat-intelligence-contract.mjs +5 -0
  167. package/lib/threat-intelligence-definition-registry.mjs +34 -0
  168. package/lib/threat-intelligence-fixtures.mjs +82 -0
  169. package/lib/threat-intelligence-manifests.mjs +512 -0
  170. package/lib/threat-intelligence-runtime.mjs +662 -0
  171. package/lib/threat-intelligence-targets.mjs +168 -0
  172. package/lib/threat-intelligence-types.mjs +3 -0
  173. package/lib/threatfox-intelligence.mjs +307 -0
  174. package/lib/tls-intelligence.mjs +814 -0
  175. package/lib/typosquat-generator.mjs +884 -0
  176. package/lib/urlhaus-intelligence.mjs +361 -0
  177. package/lib/urlscan-intelligence.mjs +349 -0
  178. package/lib/website-security-posture.mjs +325 -0
  179. package/lib/website-technology.mjs +437 -0
  180. package/lib/whois-authority.mjs +98 -0
  181. package/lib/whois-chain.mjs +261 -0
  182. package/lib/whois-contacts.mjs +163 -0
  183. package/lib/whois-contracts.mjs +3 -0
  184. package/lib/whois-normalization.mjs +27 -0
  185. package/lib/whois-transport.mjs +100 -0
  186. package/lib/whois-values.mjs +92 -0
  187. package/lib/whois.mjs +1187 -0
  188. package/package.json +55 -0
  189. package/third-party-notices.txt +5571 -0
@@ -0,0 +1,10 @@
1
+ function errorRecord(value) {
2
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
3
+ ? value
4
+ : null;
5
+ }
6
+ function nonEmptyErrorMessage(value, fallback) {
7
+ const message = errorRecord(value)?.message;
8
+ return typeof message === 'string' && message.trim() ? message : fallback;
9
+ }
10
+ export { nonEmptyErrorMessage };
@@ -0,0 +1,431 @@
1
+ import { compareRdapPublications, compareRegistrySources } from "./registry-comparison.mjs";
2
+ import { buildRegistryInsights } from "./registry-insights.mjs";
3
+ export const LOOKUP_EVIDENCE_SCHEMA = 'whoisleuth.lookup-evidence';
4
+ export const LOOKUP_EVIDENCE_SCHEMA_VERSION = 23;
5
+ const REGISTRAR_RDAP_STATUSES = new Set([
6
+ 'success', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled',
7
+ ]);
8
+ const NETWORK_CONTEXT_STATUSES = new Set(['success', 'partial', 'not_found', 'unsupported', 'error']);
9
+ const NETWORK_ADDRESS_SOURCES = new Set(['tls_connection', 'dns_a', 'dns_aaaa']);
10
+ const REVERSE_DNS_STATUSES = new Set(['success', 'partial', 'not_found', 'unsupported', 'skipped', 'error']);
11
+ const SECURITY_TXT_STATES = new Set(['present', 'stale', 'partial', 'absent', 'malformed', 'unsupported', 'unavailable']);
12
+ const SSLBL_STATES = new Set(['success', 'stale', 'unavailable']);
13
+ const SSLBL_VERDICTS = new Set(['listed', 'not_listed', 'inconclusive']);
14
+ function recordOrNull(value) {
15
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
16
+ }
17
+ function cloneJson(value) {
18
+ if (value === undefined)
19
+ return null;
20
+ return JSON.parse(JSON.stringify(value));
21
+ }
22
+ function boundedString(value, maximum) {
23
+ if (typeof value !== 'string' || value.length > maximum || /[\u0000-\u001f\u007f]/.test(value))
24
+ return null;
25
+ return value.replace(/\s+/g, ' ').trim() || null;
26
+ }
27
+ function boundedInteger(value, maximum) {
28
+ return Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) <= maximum ? Number(value) : null;
29
+ }
30
+ function boundedHttpStatus(value) {
31
+ return Number.isInteger(value) && Number(value) >= 100 && Number(value) <= 599 ? Number(value) : null;
32
+ }
33
+ function boundedTimestamp(value) {
34
+ const text = boundedString(value, 64);
35
+ if (!text)
36
+ return null;
37
+ const time = Date.parse(text);
38
+ return Number.isFinite(time) ? new Date(time).toISOString() : null;
39
+ }
40
+ function boundedEndpoint(value) {
41
+ const text = boundedString(value, 2048);
42
+ if (!text)
43
+ return null;
44
+ try {
45
+ const url = new URL(text);
46
+ if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password)
47
+ return null;
48
+ url.search = '';
49
+ url.hash = '';
50
+ return url.toString().slice(0, 2048);
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
56
+ function boundedStringList(value, count, length) {
57
+ return [...new Set((Array.isArray(value) ? value : [])
58
+ .slice(0, count)
59
+ .map((item) => boundedString(item, length))
60
+ .filter((item) => item !== null))];
61
+ }
62
+ function boundedHostname(value) {
63
+ const text = boundedString(value, 253)?.toLowerCase().replace(/\.+$/u, '') || null;
64
+ if (!text || !text.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/iu.test(label))) {
65
+ return null;
66
+ }
67
+ return text;
68
+ }
69
+ function boundedPublishedUri(value, protocols) {
70
+ const text = boundedString(value, 2048);
71
+ if (!text)
72
+ return null;
73
+ try {
74
+ const url = new URL(text);
75
+ if (!protocols.includes(url.protocol) || url.username || url.password)
76
+ return null;
77
+ url.search = '';
78
+ url.hash = '';
79
+ return url.toString().slice(0, 2048);
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ }
85
+ function boundedUriList(value, protocols) {
86
+ return [...new Set((Array.isArray(value) ? value : [])
87
+ .slice(0, 10)
88
+ .map((item) => boundedPublishedUri(item, protocols))
89
+ .filter((item) => item !== null))];
90
+ }
91
+ function boundedSslblReferenceUrl(value, fingerprint, verdict) {
92
+ if (!fingerprint || verdict !== 'listed')
93
+ return null;
94
+ const text = boundedString(value, 2048);
95
+ if (!text)
96
+ return null;
97
+ const expectedPath = `/ssl-certificates/sha1/${fingerprint}/`;
98
+ try {
99
+ const url = new URL(text);
100
+ if (url.protocol !== 'https:'
101
+ || url.hostname !== 'sslbl.abuse.ch'
102
+ || url.port
103
+ || url.username
104
+ || url.password
105
+ || url.pathname !== expectedPath) {
106
+ return null;
107
+ }
108
+ return `https://sslbl.abuse.ch${expectedPath}`;
109
+ }
110
+ catch {
111
+ return null;
112
+ }
113
+ }
114
+ function securityTxtSource(value) {
115
+ const source = recordOrNull(value);
116
+ if (!source
117
+ || source.securityTxtVersion !== 1
118
+ || typeof source.state !== 'string'
119
+ || !SECURITY_TXT_STATES.has(source.state))
120
+ return null;
121
+ return {
122
+ securityTxtVersion: 1,
123
+ version: source.version === 1 ? 1 : null,
124
+ state: source.state,
125
+ status: boundedString(source.status, 40),
126
+ observedAt: boundedTimestamp(source.observedAt),
127
+ scanMode: source.scanMode === 'deep' ? 'deep' : null,
128
+ source: source.source === 'security_txt' ? 'security_txt' : null,
129
+ durationMs: boundedInteger(source.durationMs, 120_000),
130
+ complete: source.complete === true,
131
+ truncated: source.truncated === true,
132
+ limitations: boundedStringList(source.limitations, 10, 300),
133
+ detail: boundedString(source.detail, 300),
134
+ requestedUrl: boundedPublishedUri(source.requestedUrl, ['https:']),
135
+ finalUrl: boundedPublishedUri(source.finalUrl, ['https:']),
136
+ httpStatus: boundedHttpStatus(source.httpStatus),
137
+ redirectCount: boundedInteger(source.redirectCount, 3),
138
+ expiresAt: boundedTimestamp(source.expiresAt),
139
+ signed: source.signed === true,
140
+ canonicalMatches: typeof source.canonicalMatches === 'boolean' ? source.canonicalMatches : null,
141
+ contacts: boundedUriList(source.contacts, ['https:', 'mailto:', 'tel:']),
142
+ policies: boundedUriList(source.policies, ['https:']),
143
+ encryption: boundedUriList(source.encryption, ['https:', 'dns:', 'openpgp4fpr:']),
144
+ canonical: boundedUriList(source.canonical, ['https:']),
145
+ preferredLanguages: boundedStringList(source.preferredLanguages, 10, 40),
146
+ };
147
+ }
148
+ function sslblSource(value) {
149
+ const source = recordOrNull(value);
150
+ if (!source
151
+ || source.sslblVersion !== 1
152
+ || source.source !== 'sslbl'
153
+ || typeof source.status !== 'string'
154
+ || !SSLBL_STATES.has(source.status)
155
+ || typeof source.verdict !== 'string'
156
+ || !SSLBL_VERDICTS.has(source.verdict)) {
157
+ return null;
158
+ }
159
+ const snapshot = recordOrNull(source.snapshot);
160
+ const rawFingerprint = boundedString(source.fingerprintSha1, 40);
161
+ const fingerprint = rawFingerprint && /^[a-f0-9]{40}$/u.test(rawFingerprint)
162
+ ? rawFingerprint
163
+ : null;
164
+ const validState = (source.verdict === 'not_listed'
165
+ && source.status === 'success'
166
+ && source.complete === true)
167
+ || (source.verdict === 'listed'
168
+ && (source.status === 'success' || source.status === 'stale')
169
+ && source.complete === (source.status === 'success'))
170
+ || (source.verdict === 'inconclusive'
171
+ && (source.status === 'stale' || source.status === 'unavailable')
172
+ && source.complete === false);
173
+ if (!validState)
174
+ return null;
175
+ return {
176
+ sslblVersion: 1,
177
+ source: 'sslbl',
178
+ status: source.status,
179
+ verdict: source.verdict,
180
+ complete: source.complete === true,
181
+ observedAt: boundedTimestamp(source.observedAt),
182
+ fingerprintSha1: fingerprint,
183
+ referenceUrl: boundedSslblReferenceUrl(source.referenceUrl, fingerprint, source.verdict),
184
+ snapshot: snapshot ? {
185
+ sourceUpdatedAt: boundedTimestamp(snapshot.sourceUpdatedAt),
186
+ generatedAt: boundedTimestamp(snapshot.generatedAt),
187
+ ageSeconds: boundedInteger(snapshot.ageSeconds, 31_536_000),
188
+ entryCount: boundedInteger(snapshot.entryCount, 50_000),
189
+ digestSha256: /^[a-f0-9]{64}$/u.test(String(snapshot.digestSha256 || ''))
190
+ ? snapshot.digestSha256
191
+ : null,
192
+ } : null,
193
+ detail: boundedString(source.detail, 300),
194
+ limitations: boundedStringList(source.limitations, 10, 300),
195
+ };
196
+ }
197
+ function networkAttempt(value) {
198
+ const attempt = recordOrNull(value) || {};
199
+ const endpoint = boundedEndpoint(attempt.endpoint);
200
+ return {
201
+ endpoint,
202
+ transportSecurity: endpoint ? endpoint.startsWith('https:') ? 'https' : 'http' : null,
203
+ status: boundedHttpStatus(attempt.status),
204
+ outcome: boundedString(attempt.outcome, 40),
205
+ detail: boundedString(attempt.detail, 240),
206
+ selected: attempt.selected === true,
207
+ };
208
+ }
209
+ function networkSource(value) {
210
+ const source = recordOrNull(value);
211
+ if (!source
212
+ || source.contextVersion !== 1
213
+ || typeof source.status !== 'string'
214
+ || !NETWORK_CONTEXT_STATUSES.has(source.status))
215
+ return null;
216
+ const endpoint = recordOrNull(source.endpoint);
217
+ const rdap = recordOrNull(source.rdap);
218
+ const network = recordOrNull(source.network);
219
+ const diagnostics = recordOrNull(source.diagnostics);
220
+ const rdapEndpoint = boundedEndpoint(rdap?.endpoint);
221
+ return {
222
+ contextVersion: 1,
223
+ version: source.version === 1 ? 1 : null,
224
+ status: source.status,
225
+ observedAt: boundedTimestamp(source.observedAt),
226
+ scanMode: source.scanMode === 'deep' ? 'deep' : null,
227
+ source: source.source === 'ip_rdap' ? 'ip_rdap' : null,
228
+ durationMs: boundedInteger(source.durationMs, 120_000),
229
+ complete: source.complete === true,
230
+ truncated: source.truncated === true,
231
+ limitations: boundedStringList(source.limitations, 10, 300),
232
+ diagnostics: diagnostics ? {
233
+ requestCount: boundedInteger(diagnostics.requestCount, 1),
234
+ addressSource: typeof diagnostics.addressSource === 'string'
235
+ && NETWORK_ADDRESS_SOURCES.has(diagnostics.addressSource)
236
+ ? diagnostics.addressSource
237
+ : null,
238
+ httpStatus: boundedHttpStatus(diagnostics.httpStatus),
239
+ cidrCount: boundedInteger(diagnostics.cidrCount, 16),
240
+ } : null,
241
+ detail: boundedString(source.detail, 300),
242
+ endpoint: endpoint ? {
243
+ address: boundedString(endpoint.address, 64),
244
+ family: endpoint.family === 4 || endpoint.family === 6 ? endpoint.family : null,
245
+ selectedFrom: typeof endpoint.selectedFrom === 'string'
246
+ && NETWORK_ADDRESS_SOURCES.has(endpoint.selectedFrom)
247
+ ? endpoint.selectedFrom
248
+ : null,
249
+ } : null,
250
+ rdap: rdap ? {
251
+ endpoint: rdapEndpoint,
252
+ transportSecurity: rdapEndpoint ? rdapEndpoint.startsWith('https:') ? 'https' : 'http' : null,
253
+ httpStatus: boundedHttpStatus(rdap.httpStatus),
254
+ fetchedAt: boundedTimestamp(rdap.fetchedAt),
255
+ attempts: (Array.isArray(rdap.attempts) ? rdap.attempts : []).slice(0, 3).map(networkAttempt),
256
+ } : null,
257
+ network: network ? {
258
+ handle: boundedString(network.handle, 300),
259
+ name: boundedString(network.name, 300),
260
+ holder: boundedString(network.holder, 300),
261
+ cidrs: boundedStringList(network.cidrs, 16, 96),
262
+ startAddress: boundedString(network.startAddress, 64),
263
+ endAddress: boundedString(network.endAddress, 64),
264
+ country: /^[a-z]{2}$/i.test(String(network.country || '')) ? String(network.country).toUpperCase() : null,
265
+ networkType: boundedString(network.networkType, 160),
266
+ databaseUpdatedAt: boundedTimestamp(network.databaseUpdatedAt),
267
+ } : null,
268
+ };
269
+ }
270
+ function reverseDnsSource(value) {
271
+ const source = recordOrNull(value);
272
+ if (!source
273
+ || source.version !== 1
274
+ || source.source !== 'reverse_dns'
275
+ || typeof source.status !== 'string'
276
+ || !REVERSE_DNS_STATUSES.has(source.status)) {
277
+ return null;
278
+ }
279
+ const records = recordOrNull(source.records);
280
+ const diagnostics = recordOrNull(source.diagnostics);
281
+ const ptrDiagnostics = recordOrNull(diagnostics?.ptr);
282
+ const ptr = [...new Set((Array.isArray(records?.ptr) ? records.ptr : [])
283
+ .slice(0, 8)
284
+ .map(boundedHostname)
285
+ .filter((item) => item !== null))].sort();
286
+ return {
287
+ version: 1,
288
+ status: source.status,
289
+ observedAt: boundedTimestamp(source.observedAt),
290
+ scanMode: source.scanMode === 'deep' ? 'deep' : null,
291
+ source: 'reverse_dns',
292
+ durationMs: boundedInteger(source.durationMs, 120_000),
293
+ complete: source.complete === true,
294
+ truncated: source.truncated === true,
295
+ limitations: boundedStringList(source.limitations, 10, 300),
296
+ diagnostics: ptrDiagnostics ? {
297
+ ptr: {
298
+ status: boundedString(ptrDiagnostics.status, 40),
299
+ error: boundedString(ptrDiagnostics.error, 180),
300
+ truncated: ptrDiagnostics.truncated === true,
301
+ discarded: boundedInteger(ptrDiagnostics.discarded, 1000),
302
+ },
303
+ } : null,
304
+ records: { ptr },
305
+ };
306
+ }
307
+ function rdapSource(rdap) {
308
+ const source = recordOrNull(rdap) || {};
309
+ if (source.error)
310
+ return {
311
+ status: 'error',
312
+ error: String(source.error),
313
+ attempts: cloneJson(source.attempts || []),
314
+ };
315
+ return {
316
+ status: source.upstreamStatus === 404 ? 'not_found' : 'success',
317
+ endpoint: source.rdapServer || null,
318
+ transportSecurity: source.transportSecurity || null,
319
+ httpStatus: source.upstreamStatus ?? null,
320
+ fetchedAt: source.fetchedAt || null,
321
+ attempts: cloneJson(source.attempts || []),
322
+ parsed: cloneJson(source.parsed),
323
+ raw: cloneJson(source.data),
324
+ };
325
+ }
326
+ function whoisSource(whois) {
327
+ const source = recordOrNull(whois) || {};
328
+ if (source.error)
329
+ return { status: 'error', error: String(source.error) };
330
+ const parsed = recordOrNull(source.parsed);
331
+ const chain = Array.isArray(source.chain) ? source.chain : [];
332
+ const firstHop = recordOrNull(chain[0]);
333
+ return {
334
+ status: parsed?.chainStatus || 'unknown',
335
+ queriedAt: firstHop?.queriedAt || null,
336
+ authoritativeHop: parsed?.authoritativeHop || null,
337
+ failedHop: parsed?.failedHop || null,
338
+ conflictingHop: parsed?.conflictingHop || null,
339
+ parsed: cloneJson(parsed),
340
+ chain: cloneJson(chain),
341
+ };
342
+ }
343
+ function registrarPublicationComparison(body, registryParsed) {
344
+ const rdap = recordOrNull(body.rdap);
345
+ const registrar = recordOrNull(rdap?.registrarRdap);
346
+ const diagnostics = recordOrNull(body.diagnostics);
347
+ const rdapDiagnostics = recordOrNull(diagnostics?.rdap);
348
+ const registrarDiagnostics = recordOrNull(rdapDiagnostics?.registrar);
349
+ if (!registrar && !registrarDiagnostics)
350
+ return null;
351
+ const reportedStatus = registrar?.status ?? registrarDiagnostics?.status;
352
+ const parsed = recordOrNull(registrar?.parsed);
353
+ const registrarStatus = typeof reportedStatus === 'string' && REGISTRAR_RDAP_STATUSES.has(reportedStatus)
354
+ ? (reportedStatus === 'success' && !parsed ? 'partial' : reportedStatus)
355
+ : 'error';
356
+ return compareRdapPublications(registryParsed, parsed, {
357
+ registryStatus: rdapDiagnostics?.status,
358
+ registrarStatus,
359
+ });
360
+ }
361
+ export function buildLookupEvidence(response, options = {}) {
362
+ const { generatedAt = new Date().toISOString(), idnAnalysis = null } = options;
363
+ const body = recordOrNull(response) || {};
364
+ const rdap = recordOrNull(body.rdap);
365
+ const whois = recordOrNull(body.whois);
366
+ const diagnostics = recordOrNull(body.diagnostics);
367
+ const rdapDiagnostics = recordOrNull(diagnostics?.rdap);
368
+ const whoisDiagnostics = recordOrNull(diagnostics?.whois);
369
+ const rdapParsed = rdap && !rdap.error ? recordOrNull(rdap.parsed) : null;
370
+ const whoisParsed = whois && !whois.error ? recordOrNull(whois.parsed) : null;
371
+ const registrarRdap = recordOrNull(rdap?.registrarRdap);
372
+ const registrarRdapParsed = recordOrNull(registrarRdap?.parsed);
373
+ // Rebuild the pure interpretation from bounded source fields instead of
374
+ // trusting a supplied derived object. This adds no collection or network
375
+ // work and lets saved or fast responses carry current source-health-aware
376
+ // interpretation in a deliberate analyst-created export.
377
+ const registryInsights = buildRegistryInsights({
378
+ rdapParsed,
379
+ rdapStatus: rdapDiagnostics?.status,
380
+ rdapFetchedAt: rdap?.fetchedAt,
381
+ whoisParsed,
382
+ whoisStatus: whoisDiagnostics?.status,
383
+ whoisQueriedAt: recordOrNull(Array.isArray(whois?.chain) ? whois.chain[0] : null)?.queriedAt,
384
+ registrarRdapParsed,
385
+ registrarRdapStatus: registrarRdap?.status ?? recordOrNull(rdapDiagnostics?.registrar)?.status,
386
+ registrarRdapFetchedAt: registrarRdap?.fetchedAt,
387
+ });
388
+ return {
389
+ schema: LOOKUP_EVIDENCE_SCHEMA,
390
+ schemaVersion: LOOKUP_EVIDENCE_SCHEMA_VERSION,
391
+ generatedAt,
392
+ application: { name: 'WHOISleuth' },
393
+ query: {
394
+ submitted: body.query || null,
395
+ type: body.type || null,
396
+ inputHostname: body.inputHostname || null,
397
+ registrableDomain: body.registrableDomain || null,
398
+ isSubdomain: body.isSubdomain === true,
399
+ },
400
+ diagnostics: cloneJson(body.diagnostics),
401
+ sources: {
402
+ rdap: rdapSource(rdap),
403
+ whois: whoisSource(whois),
404
+ reverseDns: reverseDnsSource(body.reverseDns),
405
+ network: networkSource(body.networkContext),
406
+ securityTxt: securityTxtSource(body.securityTxt),
407
+ sslbl: sslblSource(body.sslbl),
408
+ },
409
+ analysis: {
410
+ availability: cloneJson(body.availability),
411
+ idn: cloneJson(idnAnalysis),
412
+ registryInsights,
413
+ registryComparison: compareRegistrySources(rdapParsed, whoisParsed, {
414
+ rdapStatus: rdapDiagnostics?.status,
415
+ whoisStatus: whoisDiagnostics?.status,
416
+ }),
417
+ registrarPublicationComparison: registrarPublicationComparison(body, rdapParsed),
418
+ },
419
+ };
420
+ }
421
+ export function evidenceFilename(response, now = Date.now()) {
422
+ const record = recordOrNull(response);
423
+ const rawTarget = record?.registrableDomain || record?.inputHostname || record?.query || 'lookup';
424
+ const target = String(rawTarget)
425
+ .toLowerCase()
426
+ .replace(/[^a-z0-9.-]+/g, '-')
427
+ .replace(/^-+|-+$/g, '')
428
+ .slice(0, 80) || 'lookup';
429
+ const timestamp = new Date(now).toISOString().replace(/[:.]/g, '-');
430
+ return `whoisleuth-evidence-${target}-${timestamp}.json`;
431
+ }
@@ -0,0 +1,71 @@
1
+ import { MAX_REPORT_LIST_ITEMS, MAX_REPORT_VALUE_LENGTH, buildLookupEvidenceReport, cleanReportText, } from "./evidence-report.mjs";
2
+ function escapeMarkdownValue(value, fallback = 'Not reported') {
3
+ return cleanReportText(value, fallback)
4
+ .replace(/&/g, '&amp;')
5
+ .replace(/</g, '&lt;')
6
+ .replace(/>/g, '&gt;')
7
+ .replace(/([\\`*_{}\[\]()#+\-.!|=~])/g, '\\$1')
8
+ .replace(/:/g, '\\:')
9
+ .replace(/@/g, '\\@');
10
+ }
11
+ function appendFields(lines, fields) {
12
+ for (const field of fields)
13
+ lines.push(`- **${field.label}:** ${escapeMarkdownValue(field.value)}`);
14
+ }
15
+ function appendGroups(lines, groups) {
16
+ for (const group of groups) {
17
+ lines.push('', `### ${group.title}`);
18
+ appendFields(lines, group.fields);
19
+ }
20
+ }
21
+ function formatLookupEvidenceMarkdown(document, options = {}) {
22
+ const report = buildLookupEvidenceReport(document, options);
23
+ const lines = [
24
+ `# Lookup evidence report — ${escapeMarkdownValue(report.title)}`,
25
+ '', `> ${report.notice}`, '',
26
+ ];
27
+ appendFields(lines, report.metadata);
28
+ lines.push('', '## Query');
29
+ appendFields(lines, report.query);
30
+ lines.push('', '## Assessment');
31
+ appendFields(lines, report.assessment);
32
+ lines.push('', '## Registry sources');
33
+ appendGroups(lines, report.registryGroups);
34
+ if (report.registryInterpretation.length) {
35
+ lines.push('', '## Registry interpretation');
36
+ appendFields(lines, report.registryInterpretation);
37
+ }
38
+ lines.push('', '## Registry-source comparison');
39
+ appendFields(lines, report.comparison.health);
40
+ if (!report.comparison.fields.length) {
41
+ lines.push('- No comparable normalized fields were published.');
42
+ }
43
+ else {
44
+ for (const field of report.comparison.fields) {
45
+ lines.push(`- **${escapeMarkdownValue(field.label)} — ${escapeMarkdownValue(field.status)}:** RDAP ${escapeMarkdownValue(field.rdap)}; WHOIS ${escapeMarkdownValue(field.whois)}`);
46
+ }
47
+ if (report.comparison.omitted)
48
+ lines.push(`- ${report.comparison.omitted} additional comparison fields omitted.`);
49
+ }
50
+ if (report.registrarComparison.fields.length) {
51
+ lines.push('', '## Registry / registrar RDAP comparison');
52
+ appendFields(lines, report.registrarComparison.health);
53
+ for (const field of report.registrarComparison.fields) {
54
+ lines.push(`- **${escapeMarkdownValue(field.label)} — ${escapeMarkdownValue(field.status)}:** Registry RDAP ${escapeMarkdownValue(field.registry)}; registrar RDAP ${escapeMarkdownValue(field.registrar)}`);
55
+ }
56
+ if (report.registrarComparison.omitted)
57
+ lines.push(`- ${report.registrarComparison.omitted} additional comparison fields omitted.`);
58
+ }
59
+ lines.push('', '## Network evidence');
60
+ appendGroups(lines, report.networkGroups);
61
+ lines.push('', '## Collection diagnostics');
62
+ appendFields(lines, report.diagnostics);
63
+ lines.push('', '## Limitations', '');
64
+ for (const limitation of report.limitations)
65
+ lines.push(`- ${limitation}`);
66
+ lines.push('');
67
+ return lines.join('\n');
68
+ }
69
+ const MAX_MARKDOWN_LIST_ITEMS = MAX_REPORT_LIST_ITEMS;
70
+ const MAX_MARKDOWN_VALUE_LENGTH = MAX_REPORT_VALUE_LENGTH;
71
+ export { MAX_MARKDOWN_LIST_ITEMS, MAX_MARKDOWN_VALUE_LENGTH, escapeMarkdownValue, formatLookupEvidenceMarkdown, };