@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,727 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { canonicalArtifactJson } from "../frontend/src/lib/analysis/artifact-integrity.js";
3
+ export const SOURCE_RELIABILITY_REPORT_SCHEMA = 'whoisleuth.source-reliability-report';
4
+ export const SOURCE_RELIABILITY_REPORT_VERSION = 1;
5
+ export const MAX_SOURCE_RELIABILITY_INPUT_BYTES = 12 * 1024 * 1024;
6
+ export const MAX_SOURCE_RELIABILITY_DOCUMENTS = 100;
7
+ export const MAX_SOURCE_RELIABILITY_SOURCES = 64;
8
+ export const MAX_SOURCE_RELIABILITY_TRAVERSAL_NODES = 25_000;
9
+ const SOURCE_RE = /^[a-z][a-z0-9_-]{0,39}$/u;
10
+ const STATES = new Set([
11
+ 'success',
12
+ 'partial',
13
+ 'not_found',
14
+ 'skipped',
15
+ 'error',
16
+ 'unsupported',
17
+ 'not_applicable',
18
+ 'unavailable',
19
+ 'rate_limited',
20
+ 'complete',
21
+ 'disabled',
22
+ 'stale',
23
+ ]);
24
+ const LOOKUP_SCHEMAS = new Set([
25
+ 'whoisleuth.cli.lookup',
26
+ 'whoisleuth.cli.bulk',
27
+ 'whoisleuth.cli.bulk.item',
28
+ ]);
29
+ const REPORT_KEYS = new Set([
30
+ 'schema',
31
+ 'version',
32
+ 'generatedAt',
33
+ 'mode',
34
+ 'reportsMerged',
35
+ 'documentsReviewed',
36
+ 'sampleWindow',
37
+ 'cohorts',
38
+ 'sources',
39
+ 'totals',
40
+ 'privacy',
41
+ 'limitations',
42
+ ]);
43
+ const SAMPLE_WINDOW_KEYS = new Set(['earliestGeneratedAt', 'latestGeneratedAt']);
44
+ const COHORT_KEYS = new Set(['lookupModes']);
45
+ const LOOKUP_MODE_KEYS = new Set(['fast', 'deep', 'unknown']);
46
+ const PRIVACY_KEYS = new Set(['targetsRetained', 'queriesRetained', 'rawEvidenceRetained']);
47
+ const SOURCE_ENTRY_KEYS = new Set([
48
+ 'source',
49
+ 'samples',
50
+ 'states',
51
+ 'truncationCount',
52
+ 'rateLimitedCount',
53
+ 'rates',
54
+ 'durationMs',
55
+ 'durationTrend',
56
+ 'durationTimeline',
57
+ ]);
58
+ const SAMPLE_KEYS = new Set(['states', 'observations', 'timing']);
59
+ const RATE_KEYS = new Set(['failure', 'partial', 'truncated', 'rateLimited']);
60
+ const DURATION_KEYS = new Set(['observation', 'lookupTiming']);
61
+ const DURATION_TREND_KEYS = new Set([
62
+ 'reportObservationMedian',
63
+ 'reportObservationP95',
64
+ 'reportTimingMedian',
65
+ 'reportTimingP95',
66
+ ]);
67
+ const TOTAL_KEYS = new Set([
68
+ 'stateSamples',
69
+ 'observationSamples',
70
+ 'timingSamples',
71
+ 'truncations',
72
+ 'rateLimits',
73
+ ]);
74
+ const TIMELINE_KEYS = new Set([
75
+ 'generatedAt',
76
+ 'observationMedian',
77
+ 'observationP95',
78
+ 'timingMedian',
79
+ 'timingP95',
80
+ ]);
81
+ const TIMING_SOURCES = new Set([
82
+ 'rdap',
83
+ 'whois',
84
+ 'domain_evidence',
85
+ 'reverse_dns',
86
+ 'registrar_rdap',
87
+ 'network_context',
88
+ 'security_txt',
89
+ 'external_intelligence',
90
+ 'malware_host_intelligence',
91
+ 'malware_ioc_intelligence',
92
+ ]);
93
+ const BASE_REPORT_LIMITATIONS = Object.freeze([
94
+ 'This local report summarizes only source-health metadata present in the supplied files and does not contact or rank providers.',
95
+ 'Timing measurements from concurrent Lookup sources overlap and must not be added together as total wall-clock time.',
96
+ 'Provider result states and their observation-health states are counted separately because they describe different contracts.',
97
+ 'Missing timing or observation envelopes remain unmeasured rather than being treated as fast, complete, or reliable.',
98
+ 'Failure rates count explicit error and unavailable states. Partial, truncated, and rate-limited rates remain separate so different source-health conditions are not collapsed.',
99
+ ]);
100
+ const MERGED_REPORT_LIMITATION = 'Merged duration timelines preserve only report generation times and source-level medians and p95 values; they are not reconstructed per-target timing samples.';
101
+ function record(value) {
102
+ return value && typeof value === 'object' && !Array.isArray(value)
103
+ ? value
104
+ : null;
105
+ }
106
+ function assertExactKeys(value, expected, label) {
107
+ if (Object.keys(value).length !== expected.size
108
+ || Object.keys(value).some((key) => !expected.has(key))) {
109
+ throw new TypeError(`${label} must use only the documented fields.`);
110
+ }
111
+ }
112
+ function safeSource(value) {
113
+ return typeof value === 'string' && SOURCE_RE.test(value) ? value : null;
114
+ }
115
+ function safeState(value) {
116
+ return typeof value === 'string' && STATES.has(value)
117
+ ? value
118
+ : null;
119
+ }
120
+ function boundedDuration(value) {
121
+ return typeof value === 'number'
122
+ && Number.isFinite(value)
123
+ && value >= 0
124
+ && value <= 120_000
125
+ ? Math.round(value)
126
+ : null;
127
+ }
128
+ function parseValues(raw) {
129
+ if (typeof raw !== 'string')
130
+ throw new TypeError('Source reliability input must be UTF-8 JSON text.');
131
+ const bytes = Buffer.byteLength(raw, 'utf8');
132
+ if (bytes === 0 || bytes > MAX_SOURCE_RELIABILITY_INPUT_BYTES) {
133
+ throw new TypeError(`Source reliability input must be between 1 byte and ${MAX_SOURCE_RELIABILITY_INPUT_BYTES} bytes.`);
134
+ }
135
+ let parsed;
136
+ try {
137
+ parsed = JSON.parse(raw);
138
+ }
139
+ catch {
140
+ throw new TypeError('Source reliability input must be valid JSON.');
141
+ }
142
+ const values = Array.isArray(parsed) ? parsed : [parsed];
143
+ if (values.length === 0 || values.length > MAX_SOURCE_RELIABILITY_DOCUMENTS) {
144
+ throw new TypeError(`Source reliability input supports 1 to ${MAX_SOURCE_RELIABILITY_DOCUMENTS} documents.`);
145
+ }
146
+ return values.map((value) => {
147
+ const document = record(value);
148
+ if (!document)
149
+ throw new TypeError('Source reliability input must contain JSON objects.');
150
+ return document;
151
+ });
152
+ }
153
+ function parseLookupDocuments(values) {
154
+ return values.map((document) => {
155
+ if (!LOOKUP_SCHEMAS.has(String(document.schema)) || document.version !== 1) {
156
+ throw new TypeError('Source reliability input requires only version 1 CLI lookup, Bulk, or Bulk-item documents, or only source reliability reports.');
157
+ }
158
+ return document;
159
+ });
160
+ }
161
+ function accumulator(store, source) {
162
+ const existing = store.get(source);
163
+ if (existing)
164
+ return existing;
165
+ if (store.size >= MAX_SOURCE_RELIABILITY_SOURCES)
166
+ return null;
167
+ const created = {
168
+ states: new Map(),
169
+ observationDurations: [],
170
+ timingDurations: [],
171
+ truncated: 0,
172
+ rateLimited: 0,
173
+ observationSamples: 0,
174
+ timingSamples: 0,
175
+ reportedObservationMedians: [],
176
+ reportedObservationP95s: [],
177
+ reportedTimingMedians: [],
178
+ reportedTimingP95s: [],
179
+ reportTimeline: [],
180
+ };
181
+ store.set(source, created);
182
+ return created;
183
+ }
184
+ function addState(store, source, state) {
185
+ const item = accumulator(store, source);
186
+ if (!item)
187
+ return;
188
+ item.states.set(state, (item.states.get(state) ?? 0) + 1);
189
+ if (state === 'rate_limited')
190
+ item.rateLimited += 1;
191
+ }
192
+ function collectTiming(document, store) {
193
+ const diagnostics = record(document.diagnostics);
194
+ const timing = record(diagnostics?.timing);
195
+ if (timing?.version !== 1 || !Array.isArray(timing.sources))
196
+ return;
197
+ for (const rawEntry of timing.sources.slice(0, 16)) {
198
+ const entry = record(rawEntry);
199
+ const source = safeSource(entry?.source);
200
+ const duration = boundedDuration(entry?.durationMs);
201
+ if (!source || !TIMING_SOURCES.has(source) || duration === null)
202
+ continue;
203
+ const item = accumulator(store, source);
204
+ if (!item)
205
+ continue;
206
+ item.timingDurations.push(duration);
207
+ item.timingSamples += 1;
208
+ }
209
+ }
210
+ function collectDiagnostics(document, store) {
211
+ const diagnostics = record(document.diagnostics);
212
+ if (!diagnostics)
213
+ return;
214
+ const fixed = [
215
+ ['rdap', record(diagnostics.rdap)?.status],
216
+ ['registrar_rdap', record(record(diagnostics.rdap)?.registrar)?.status],
217
+ ['whois', record(diagnostics.whois)?.status],
218
+ ['availability', record(diagnostics.availability)?.status],
219
+ ['reverse_dns', record(diagnostics.reverseDns)?.status],
220
+ ['network_context', record(diagnostics.network)?.status],
221
+ ['security_txt', record(diagnostics.securityTxt)?.status],
222
+ ['sslbl', record(diagnostics.sslbl)?.status],
223
+ ];
224
+ for (const [source, value] of fixed) {
225
+ const state = safeState(value);
226
+ if (state)
227
+ addState(store, source, state);
228
+ }
229
+ }
230
+ function collectObservation(value, store) {
231
+ if (value.version !== 1
232
+ || typeof value.observedAt !== 'string'
233
+ || typeof value.complete !== 'boolean'
234
+ || typeof value.truncated !== 'boolean'
235
+ || !Array.isArray(value.limitations))
236
+ return;
237
+ const source = safeSource(value.source);
238
+ const state = safeState(value.status);
239
+ if (!source || !state)
240
+ return;
241
+ const item = accumulator(store, source);
242
+ if (!item)
243
+ return;
244
+ item.observationSamples += 1;
245
+ addState(store, source, state);
246
+ const duration = boundedDuration(value.durationMs);
247
+ if (duration !== null)
248
+ item.observationDurations.push(duration);
249
+ if (value.truncated === true)
250
+ item.truncated += 1;
251
+ }
252
+ function traverse(document, store) {
253
+ let visited = 0;
254
+ const stack = [{ value: document, depth: 0 }];
255
+ while (stack.length) {
256
+ const current = stack.pop();
257
+ if (!current || current.depth > 12 || visited >= MAX_SOURCE_RELIABILITY_TRAVERSAL_NODES)
258
+ continue;
259
+ visited += 1;
260
+ const object = record(current.value);
261
+ if (object) {
262
+ collectDiagnostics(object, store);
263
+ collectTiming(object, store);
264
+ collectObservation(object, store);
265
+ if (object.state === 'rate_limited') {
266
+ const source = safeSource(record(object.observation)?.source);
267
+ if (source)
268
+ addState(store, source, 'rate_limited');
269
+ }
270
+ for (const child of Object.values(object).slice(0, 200)) {
271
+ if (child && typeof child === 'object')
272
+ stack.push({ value: child, depth: current.depth + 1 });
273
+ }
274
+ }
275
+ else if (Array.isArray(current.value)) {
276
+ for (const child of current.value.slice(0, 1_000)) {
277
+ if (child && typeof child === 'object')
278
+ stack.push({ value: child, depth: current.depth + 1 });
279
+ }
280
+ }
281
+ }
282
+ }
283
+ function percentile(sorted, ratio) {
284
+ return sorted[Math.max(0, Math.ceil(sorted.length * ratio) - 1)] ?? 0;
285
+ }
286
+ function durationSummary(values) {
287
+ if (!values.length)
288
+ return null;
289
+ const sorted = [...values].sort((left, right) => left - right);
290
+ return Object.freeze({
291
+ minimum: sorted[0] ?? 0,
292
+ median: percentile(sorted, 0.5),
293
+ p95: percentile(sorted, 0.95),
294
+ maximum: sorted.at(-1) ?? 0,
295
+ });
296
+ }
297
+ function ratio(numerator, denominator) {
298
+ return denominator > 0 ? Number((numerator / denominator).toFixed(4)) : null;
299
+ }
300
+ function reportTimestamp(value) {
301
+ if (typeof value !== 'string' || value.length > 64) {
302
+ throw new TypeError('Source reliability report has an invalid generation time.');
303
+ }
304
+ const parsed = Date.parse(value);
305
+ if (!Number.isFinite(parsed))
306
+ throw new TypeError('Source reliability report has an invalid generation time.');
307
+ return new Date(parsed).toISOString();
308
+ }
309
+ function optionalReportTimestamp(value, label) {
310
+ if (value === null)
311
+ return null;
312
+ try {
313
+ return reportTimestamp(value);
314
+ }
315
+ catch {
316
+ throw new TypeError(`Source reliability report has an invalid ${label}.`);
317
+ }
318
+ }
319
+ function safeCount(value, label) {
320
+ if (!Number.isSafeInteger(value) || Number(value) < 0 || Number(value) > 1_000_000_000) {
321
+ throw new TypeError(`Source reliability report has an invalid ${label}.`);
322
+ }
323
+ return Number(value);
324
+ }
325
+ function safeRate(value, label) {
326
+ if (value === null)
327
+ return null;
328
+ if (typeof value !== 'number'
329
+ || !Number.isFinite(value)
330
+ || value < 0
331
+ || value > 1
332
+ || Number(value.toFixed(4)) !== value) {
333
+ throw new TypeError(`Source reliability report has an invalid ${label}.`);
334
+ }
335
+ return value;
336
+ }
337
+ function collectReportedDuration(value, medians, p95s, label) {
338
+ if (value === null)
339
+ return;
340
+ const summary = record(value);
341
+ const minimum = boundedDuration(summary?.minimum);
342
+ const median = boundedDuration(summary?.median);
343
+ const p95 = boundedDuration(summary?.p95);
344
+ const maximum = boundedDuration(summary?.maximum);
345
+ if (minimum === null || median === null || p95 === null || maximum === null
346
+ || minimum > median || median > p95 || p95 > maximum) {
347
+ throw new TypeError(`Source reliability report has an invalid ${label} duration summary.`);
348
+ }
349
+ medians.push(median);
350
+ p95s.push(p95);
351
+ }
352
+ function reportedDurationPoint(value) {
353
+ if (value === null || value === undefined)
354
+ return Object.freeze({ median: null, p95: null });
355
+ const summary = record(value);
356
+ const minimum = boundedDuration(summary?.minimum);
357
+ const median = boundedDuration(summary?.median);
358
+ const p95 = boundedDuration(summary?.p95);
359
+ const maximum = boundedDuration(summary?.maximum);
360
+ if (minimum === null || median === null || p95 === null || maximum === null
361
+ || minimum > median || median > p95 || p95 > maximum) {
362
+ throw new TypeError('Source reliability report has an invalid duration summary.');
363
+ }
364
+ return Object.freeze({ median, p95 });
365
+ }
366
+ function nullableTimelineDuration(value, label) {
367
+ if (value === null)
368
+ return null;
369
+ const duration = boundedDuration(value);
370
+ if (duration === null) {
371
+ throw new TypeError(`Source reliability report has an invalid ${label}.`);
372
+ }
373
+ return duration;
374
+ }
375
+ function timelineDurationPair(medianValue, p95Value, label) {
376
+ const median = nullableTimelineDuration(medianValue, `${label} median`);
377
+ const p95 = nullableTimelineDuration(p95Value, `${label} p95`);
378
+ if ((median === null) !== (p95 === null)
379
+ || (median !== null && p95 !== null && median > p95)) {
380
+ throw new TypeError(`Source reliability report has an invalid ${label} duration pair.`);
381
+ }
382
+ return Object.freeze({ median, p95 });
383
+ }
384
+ function collectReport(report, store) {
385
+ if (report.schema !== SOURCE_RELIABILITY_REPORT_SCHEMA
386
+ || report.version !== SOURCE_RELIABILITY_REPORT_VERSION
387
+ || report.mode !== 'offline_local'
388
+ || !Array.isArray(report.sources)
389
+ || report.sources.length > MAX_SOURCE_RELIABILITY_SOURCES) {
390
+ throw new TypeError('Source reliability report input has an unsupported schema or shape.');
391
+ }
392
+ assertExactKeys(report, REPORT_KEYS, 'Source reliability report');
393
+ const privacy = record(report.privacy);
394
+ if (!privacy) {
395
+ throw new TypeError('Source reliability report has an invalid privacy declaration.');
396
+ }
397
+ assertExactKeys(privacy, PRIVACY_KEYS, 'Source reliability report privacy declaration');
398
+ if (privacy.targetsRetained !== 0
399
+ || privacy.queriesRetained !== 0
400
+ || privacy.rawEvidenceRetained !== 0) {
401
+ throw new TypeError('Source reliability reports must retain zero targets, queries, and raw evidence.');
402
+ }
403
+ const generatedAt = reportTimestamp(report.generatedAt);
404
+ const reportsMerged = safeCount(report.reportsMerged, 'merged-report count');
405
+ const documentsReviewed = safeCount(report.documentsReviewed, 'document count');
406
+ const sampleWindow = record(report.sampleWindow);
407
+ if (!sampleWindow) {
408
+ throw new TypeError('Source reliability report has an invalid sample window.');
409
+ }
410
+ assertExactKeys(sampleWindow, SAMPLE_WINDOW_KEYS, 'Source reliability report sample window');
411
+ const earliestGeneratedAt = optionalReportTimestamp(sampleWindow.earliestGeneratedAt, 'sample-window start');
412
+ const latestGeneratedAt = optionalReportTimestamp(sampleWindow.latestGeneratedAt, 'sample-window end');
413
+ if ((earliestGeneratedAt === null) !== (latestGeneratedAt === null)
414
+ || (earliestGeneratedAt !== null
415
+ && latestGeneratedAt !== null
416
+ && earliestGeneratedAt > latestGeneratedAt)) {
417
+ throw new TypeError('Source reliability report has an inconsistent sample window.');
418
+ }
419
+ const cohorts = record(report.cohorts);
420
+ if (!cohorts)
421
+ throw new TypeError('Source reliability report has invalid lookup cohorts.');
422
+ assertExactKeys(cohorts, COHORT_KEYS, 'Source reliability report cohorts');
423
+ const rawModes = record(cohorts?.lookupModes);
424
+ if (!rawModes)
425
+ throw new TypeError('Source reliability report has invalid lookup cohorts.');
426
+ assertExactKeys(rawModes, LOOKUP_MODE_KEYS, 'Source reliability report lookup cohorts');
427
+ const lookupModes = Object.freeze({
428
+ fast: safeCount(rawModes?.fast ?? 0, 'fast lookup cohort count'),
429
+ deep: safeCount(rawModes?.deep ?? 0, 'deep lookup cohort count'),
430
+ unknown: safeCount(rawModes?.unknown ?? 0, 'unknown lookup cohort count'),
431
+ });
432
+ if (lookupModes.fast + lookupModes.deep + lookupModes.unknown !== documentsReviewed) {
433
+ throw new TypeError('Source reliability report has inconsistent lookup cohort counts.');
434
+ }
435
+ const totals = record(report.totals);
436
+ if (!totals)
437
+ throw new TypeError('Source reliability report has invalid totals.');
438
+ assertExactKeys(totals, TOTAL_KEYS, 'Source reliability report totals');
439
+ const expectedTotals = {
440
+ stateSamples: 0,
441
+ observationSamples: 0,
442
+ timingSamples: 0,
443
+ truncations: 0,
444
+ rateLimits: 0,
445
+ };
446
+ if (!Array.isArray(report.limitations)
447
+ || report.limitations.length !== BASE_REPORT_LIMITATIONS.length + (reportsMerged > 0 ? 1 : 0)
448
+ || report.limitations.some((value, index) => (value !== [...BASE_REPORT_LIMITATIONS, ...(reportsMerged > 0 ? [MERGED_REPORT_LIMITATION] : [])][index]))) {
449
+ throw new TypeError('Source reliability report has an invalid limitations declaration.');
450
+ }
451
+ const seenSources = new Set();
452
+ for (const rawSource of report.sources) {
453
+ const sourceRecord = record(rawSource);
454
+ const source = safeSource(sourceRecord?.source);
455
+ const samples = record(sourceRecord?.samples);
456
+ const states = record(sourceRecord?.states);
457
+ const durationMs = record(sourceRecord?.durationMs);
458
+ if (!sourceRecord || !source || !samples || !states || !durationMs) {
459
+ throw new TypeError('Source reliability report contains an invalid source entry.');
460
+ }
461
+ if (seenSources.has(source)) {
462
+ throw new TypeError(`Source reliability report repeats the ${source} source.`);
463
+ }
464
+ seenSources.add(source);
465
+ assertExactKeys(sourceRecord, SOURCE_ENTRY_KEYS, `Source reliability report ${source} entry`);
466
+ assertExactKeys(samples, SAMPLE_KEYS, `Source reliability report ${source} samples`);
467
+ assertExactKeys(durationMs, DURATION_KEYS, `Source reliability report ${source} durations`);
468
+ const rates = record(sourceRecord.rates);
469
+ const durationTrend = record(sourceRecord.durationTrend);
470
+ if (!rates || !durationTrend) {
471
+ throw new TypeError(`Source reliability report contains invalid ${source} summaries.`);
472
+ }
473
+ assertExactKeys(rates, RATE_KEYS, `Source reliability report ${source} rates`);
474
+ assertExactKeys(durationTrend, DURATION_TREND_KEYS, `Source reliability report ${source} duration trend`);
475
+ for (const value of Object.values(durationTrend))
476
+ reportedDurationPoint(value);
477
+ const item = accumulator(store, source);
478
+ if (!item)
479
+ throw new TypeError('Source reliability report exceeds the source limit.');
480
+ const observationSamples = safeCount(samples.observations, `${source} observation sample count`);
481
+ const timingSamples = safeCount(samples.timing, `${source} timing sample count`);
482
+ const truncationCount = safeCount(sourceRecord.truncationCount, `${source} truncation count`);
483
+ const rateLimitedCount = safeCount(sourceRecord.rateLimitedCount, `${source} rate-limit count`);
484
+ item.observationSamples += observationSamples;
485
+ item.timingSamples += timingSamples;
486
+ item.truncated += truncationCount;
487
+ item.rateLimited += rateLimitedCount;
488
+ let stateSamples = 0;
489
+ const normalizedStates = {};
490
+ for (const [rawState, rawCount] of Object.entries(states)) {
491
+ const state = safeState(rawState);
492
+ if (!state)
493
+ throw new TypeError(`Source reliability report contains an invalid ${source} state.`);
494
+ const count = safeCount(rawCount, `${source} ${state} state count`);
495
+ stateSamples += count;
496
+ normalizedStates[state] = count;
497
+ item.states.set(state, (item.states.get(state) ?? 0) + count);
498
+ }
499
+ if (stateSamples !== safeCount(samples.states, `${source} state sample count`)) {
500
+ throw new TypeError(`Source reliability report has inconsistent ${source} state counts.`);
501
+ }
502
+ if (truncationCount > observationSamples) {
503
+ throw new TypeError(`Source reliability report has inconsistent ${source} truncation counts.`);
504
+ }
505
+ if (rateLimitedCount !== (normalizedStates.rate_limited ?? 0)) {
506
+ throw new TypeError(`Source reliability report has inconsistent ${source} rate-limit counts.`);
507
+ }
508
+ const observationPoint = reportedDurationPoint(durationMs.observation);
509
+ const timingPoint = reportedDurationPoint(durationMs.lookupTiming);
510
+ const expectedRates = {
511
+ failure: ratio((normalizedStates.error ?? 0) + (normalizedStates.unavailable ?? 0), stateSamples),
512
+ partial: ratio(normalizedStates.partial ?? 0, stateSamples),
513
+ truncated: ratio(truncationCount, observationSamples),
514
+ rateLimited: ratio(rateLimitedCount, stateSamples),
515
+ };
516
+ for (const [name, expected] of Object.entries(expectedRates)) {
517
+ const observed = safeRate(rates[name], `${source} ${name} rate`);
518
+ if (observed !== expected) {
519
+ throw new TypeError(`Source reliability report has an inconsistent ${source} ${name} rate.`);
520
+ }
521
+ }
522
+ if (!Array.isArray(sourceRecord.durationTimeline)
523
+ || sourceRecord.durationTimeline.length > MAX_SOURCE_RELIABILITY_DOCUMENTS) {
524
+ throw new TypeError(`Source reliability report contains an invalid ${source} duration timeline.`);
525
+ }
526
+ const rawTimeline = sourceRecord.durationTimeline;
527
+ if (item.reportTimeline.length + rawTimeline.length > MAX_SOURCE_RELIABILITY_DOCUMENTS) {
528
+ throw new TypeError(`Merged source reliability reports exceed the ${source} duration-timeline limit.`);
529
+ }
530
+ if (rawTimeline.length) {
531
+ for (const rawPoint of rawTimeline) {
532
+ const point = record(rawPoint);
533
+ if (!point)
534
+ throw new TypeError(`Source reliability report contains an invalid ${source} duration timeline.`);
535
+ assertExactKeys(point, TIMELINE_KEYS, `Source reliability report ${source} duration point`);
536
+ const observation = timelineDurationPair(point.observationMedian, point.observationP95, `${source} observation`);
537
+ const timing = timelineDurationPair(point.timingMedian, point.timingP95, `${source} lookup timing`);
538
+ item.reportTimeline.push(Object.freeze({
539
+ generatedAt: reportTimestamp(point.generatedAt),
540
+ observationMedian: observation.median,
541
+ observationP95: observation.p95,
542
+ timingMedian: timing.median,
543
+ timingP95: timing.p95,
544
+ }));
545
+ if (observation.median !== null && observation.p95 !== null) {
546
+ item.reportedObservationMedians.push(observation.median);
547
+ item.reportedObservationP95s.push(observation.p95);
548
+ }
549
+ if (timing.median !== null && timing.p95 !== null) {
550
+ item.reportedTimingMedians.push(timing.median);
551
+ item.reportedTimingP95s.push(timing.p95);
552
+ }
553
+ }
554
+ }
555
+ else if (observationPoint.median !== null || timingPoint.median !== null) {
556
+ collectReportedDuration(durationMs.observation, item.reportedObservationMedians, item.reportedObservationP95s, `${source} observation`);
557
+ collectReportedDuration(durationMs.lookupTiming, item.reportedTimingMedians, item.reportedTimingP95s, `${source} lookup timing`);
558
+ item.reportTimeline.push(Object.freeze({
559
+ generatedAt,
560
+ observationMedian: observationPoint.median,
561
+ observationP95: observationPoint.p95,
562
+ timingMedian: timingPoint.median,
563
+ timingP95: timingPoint.p95,
564
+ }));
565
+ }
566
+ expectedTotals.stateSamples += stateSamples;
567
+ expectedTotals.observationSamples += observationSamples;
568
+ expectedTotals.timingSamples += timingSamples;
569
+ expectedTotals.truncations += truncationCount;
570
+ expectedTotals.rateLimits += rateLimitedCount;
571
+ }
572
+ for (const [name, expected] of Object.entries(expectedTotals)) {
573
+ if (safeCount(totals[name], `${name} total`) !== expected) {
574
+ throw new TypeError(`Source reliability report has an inconsistent ${name} total.`);
575
+ }
576
+ }
577
+ return {
578
+ documentsReviewed,
579
+ lookupModes,
580
+ sampleDates: earliestGeneratedAt && latestGeneratedAt
581
+ ? Object.freeze([earliestGeneratedAt, latestGeneratedAt])
582
+ : Object.freeze([generatedAt]),
583
+ };
584
+ }
585
+ export function buildSourceReliabilityReport(raw, generatedAt = new Date().toISOString()) {
586
+ const timestamp = Date.parse(generatedAt);
587
+ if (!Number.isFinite(timestamp))
588
+ throw new TypeError('Source reliability report time must be valid.');
589
+ const values = parseValues(raw);
590
+ const store = new Map();
591
+ const reportInputs = values.every((value) => value.schema === SOURCE_RELIABILITY_REPORT_SCHEMA);
592
+ let documentsReviewed = 0;
593
+ let reportsMerged = 0;
594
+ const sampleDates = [];
595
+ const lookupModes = { fast: 0, deep: 0, unknown: 0 };
596
+ if (reportInputs) {
597
+ if (new Set(values.map((value) => canonicalArtifactJson(value))).size !== values.length) {
598
+ throw new TypeError('Source reliability input contains a duplicate report.');
599
+ }
600
+ reportsMerged = values.length;
601
+ for (const report of values) {
602
+ const collected = collectReport(report, store);
603
+ documentsReviewed += collected.documentsReviewed;
604
+ sampleDates.push(...collected.sampleDates);
605
+ lookupModes.fast += collected.lookupModes.fast;
606
+ lookupModes.deep += collected.lookupModes.deep;
607
+ lookupModes.unknown += collected.lookupModes.unknown;
608
+ if (!Number.isSafeInteger(documentsReviewed) || documentsReviewed > 1_000_000_000) {
609
+ throw new TypeError('Merged source reliability report document count is too large.');
610
+ }
611
+ }
612
+ }
613
+ else {
614
+ const documents = parseLookupDocuments(values);
615
+ documentsReviewed = documents.length;
616
+ for (const document of documents) {
617
+ const mode = document.mode === 'fast' || document.mode === 'deep' ? document.mode : 'unknown';
618
+ lookupModes[mode] += 1;
619
+ if (typeof document.generatedAt === 'string') {
620
+ try {
621
+ sampleDates.push(reportTimestamp(document.generatedAt));
622
+ }
623
+ catch {
624
+ // Invalid or missing document dates remain unmeasured.
625
+ }
626
+ }
627
+ traverse(document, store);
628
+ }
629
+ }
630
+ sampleDates.sort();
631
+ const sources = Object.freeze([...store.entries()]
632
+ .sort(([left], [right]) => left.localeCompare(right))
633
+ .map(([source, item]) => {
634
+ const states = Object.freeze(Object.fromEntries([...item.states.entries()].sort(([left], [right]) => left.localeCompare(right))));
635
+ const stateSamples = [...item.states.values()].reduce((sum, count) => sum + count, 0);
636
+ const failureCount = (item.states.get('error') ?? 0) + (item.states.get('unavailable') ?? 0);
637
+ const partialCount = item.states.get('partial') ?? 0;
638
+ return Object.freeze({
639
+ source,
640
+ samples: Object.freeze({
641
+ states: stateSamples,
642
+ observations: item.observationSamples,
643
+ timing: item.timingSamples,
644
+ }),
645
+ states,
646
+ truncationCount: item.truncated,
647
+ rateLimitedCount: item.rateLimited,
648
+ rates: Object.freeze({
649
+ failure: ratio(failureCount, stateSamples),
650
+ partial: ratio(partialCount, stateSamples),
651
+ truncated: ratio(item.truncated, item.observationSamples),
652
+ rateLimited: ratio(item.rateLimited, stateSamples),
653
+ }),
654
+ durationMs: Object.freeze({
655
+ observation: durationSummary(item.observationDurations),
656
+ lookupTiming: durationSummary(item.timingDurations),
657
+ }),
658
+ durationTrend: Object.freeze({
659
+ reportObservationMedian: durationSummary(item.reportedObservationMedians),
660
+ reportObservationP95: durationSummary(item.reportedObservationP95s),
661
+ reportTimingMedian: durationSummary(item.reportedTimingMedians),
662
+ reportTimingP95: durationSummary(item.reportedTimingP95s),
663
+ }),
664
+ durationTimeline: Object.freeze([...item.reportTimeline]
665
+ .sort((left, right) => left.generatedAt.localeCompare(right.generatedAt))
666
+ .slice(-MAX_SOURCE_RELIABILITY_DOCUMENTS)),
667
+ });
668
+ }));
669
+ return Object.freeze({
670
+ schema: SOURCE_RELIABILITY_REPORT_SCHEMA,
671
+ version: SOURCE_RELIABILITY_REPORT_VERSION,
672
+ generatedAt: new Date(timestamp).toISOString(),
673
+ mode: 'offline_local',
674
+ reportsMerged,
675
+ documentsReviewed,
676
+ sampleWindow: Object.freeze({
677
+ earliestGeneratedAt: sampleDates[0] ?? null,
678
+ latestGeneratedAt: sampleDates.at(-1) ?? null,
679
+ }),
680
+ cohorts: Object.freeze({
681
+ lookupModes: Object.freeze({ ...lookupModes }),
682
+ }),
683
+ sources,
684
+ totals: Object.freeze({
685
+ stateSamples: sources.reduce((sum, source) => sum + source.samples.states, 0),
686
+ observationSamples: sources.reduce((sum, source) => sum + source.samples.observations, 0),
687
+ timingSamples: sources.reduce((sum, source) => sum + source.samples.timing, 0),
688
+ truncations: sources.reduce((sum, source) => sum + source.truncationCount, 0),
689
+ rateLimits: sources.reduce((sum, source) => sum + source.rateLimitedCount, 0),
690
+ }),
691
+ privacy: Object.freeze({
692
+ targetsRetained: 0,
693
+ queriesRetained: 0,
694
+ rawEvidenceRetained: 0,
695
+ }),
696
+ limitations: Object.freeze([
697
+ ...BASE_REPORT_LIMITATIONS,
698
+ ...(reportsMerged ? [MERGED_REPORT_LIMITATION] : []),
699
+ ]),
700
+ });
701
+ }
702
+ export function formatSourceReliabilityReport(report) {
703
+ const lines = [
704
+ 'WHOISleuth source reliability report',
705
+ `Documents: ${report.documentsReviewed}`,
706
+ `Reports merged: ${report.reportsMerged}`,
707
+ `Sample window: ${report.sampleWindow.earliestGeneratedAt ?? 'unmeasured'} to ${report.sampleWindow.latestGeneratedAt ?? 'unmeasured'}`,
708
+ `Sources: ${report.sources.length}`,
709
+ `Lookup cohorts: fast ${report.cohorts.lookupModes.fast} · deep ${report.cohorts.lookupModes.deep} · unknown ${report.cohorts.lookupModes.unknown}`,
710
+ `Samples: ${report.totals.stateSamples} states · ${report.totals.observationSamples} observations · ${report.totals.timingSamples} timings`,
711
+ `Exceptions: ${report.totals.truncations} truncated · ${report.totals.rateLimits} rate-limited`,
712
+ ];
713
+ for (const source of report.sources) {
714
+ const states = Object.entries(source.states).map(([state, count]) => `${state} ${count}`).join(', ') || 'none';
715
+ const timing = source.durationMs.lookupTiming;
716
+ const timingTrend = source.durationTrend.reportTimingMedian;
717
+ lines.push(`${source.source}: ${states}; truncated ${source.truncationCount}; rate-limited ${source.rateLimitedCount}`
718
+ + `; failure rate ${source.rates.failure ?? 'unmeasured'}; partial rate ${source.rates.partial ?? 'unmeasured'}`
719
+ + (timing ? `; timing median ${timing.median} ms / p95 ${timing.p95} ms` : '')
720
+ + (timingTrend ? `; report-median distribution ${timingTrend.minimum}-${timingTrend.maximum} ms` : '')
721
+ + (source.durationTimeline.length ? `; timeline points ${source.durationTimeline.length}` : ''));
722
+ }
723
+ lines.push('Privacy: targets 0 · queries 0 · raw evidence 0');
724
+ for (const limitation of report.limitations)
725
+ lines.push(`Limitation: ${limitation}`);
726
+ return `${lines.join('\n')}\n`;
727
+ }