@slicedearth/whoisleuth-cli 1.34.0 → 1.39.1

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 (131) hide show
  1. package/README.md +19 -2
  2. package/bin/whoisleuth.mjs +41 -24
  3. package/cli/arguments.mjs +326 -3
  4. package/cli/artifact-verify.mjs +60 -14
  5. package/cli/bulk.mjs +1 -2
  6. package/cli/compare.mjs +1 -1
  7. package/cli/completion.mjs +36 -2
  8. package/cli/doctor.mjs +11 -5
  9. package/cli/evidence-command-arguments.mjs +1 -1
  10. package/cli/evidence-command-runner.mjs +2 -2
  11. package/cli/evidence-signing.mjs +4 -4
  12. package/cli/export-evidence.mjs +9 -2
  13. package/cli/formatters/html.mjs +1 -1
  14. package/cli/formatters/json.mjs +13 -1
  15. package/cli/formatters/terminal.mjs +14 -4
  16. package/cli/investigation-plan.mjs +112 -0
  17. package/cli/local-mmdb-review.mjs +76 -0
  18. package/cli/lookalike-calibration.mjs +121 -0
  19. package/cli/lookup-command-runner.mjs +4 -1
  20. package/cli/lookup-plan.mjs +3 -3
  21. package/cli/lookup-reconcile.mjs +141 -0
  22. package/cli/lookup-timeline.mjs +1 -1
  23. package/cli/mail-review.mjs +48 -3
  24. package/cli/offline-evidence-review.mjs +159 -0
  25. package/cli/registry-doctor.mjs +125 -0
  26. package/cli/registry-support.mjs +1 -4
  27. package/cli/risk-calibration.mjs +117 -9
  28. package/cli/runner.mjs +342 -15
  29. package/cli/saved-lookup.mjs +2 -2
  30. package/cli/sharing-review.mjs +202 -0
  31. package/cli/source-reliability.mjs +1 -1
  32. package/docs/cli-reference.md +1110 -0
  33. package/docs/cli.md +224 -847
  34. package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
  35. package/frontend/src/lib/analysis/acquisition-due-diligence.js +6 -6
  36. package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
  37. package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
  38. package/frontend/src/lib/analysis/bulk-retry-plan.js +3 -10
  39. package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
  40. package/frontend/src/lib/analysis/bulk-source-coverage.js +76 -0
  41. package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
  42. package/frontend/src/lib/analysis/case-migration-model.js +6 -1
  43. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  44. package/frontend/src/lib/analysis/case-record-core.js +5 -1
  45. package/frontend/src/lib/analysis/case-record-operations.js +6 -1
  46. package/frontend/src/lib/analysis/case-response-packet.js +7 -7
  47. package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
  48. package/frontend/src/lib/analysis/http-summary.js +2 -1
  49. package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
  50. package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
  51. package/frontend/src/lib/analysis/lookup-decision-support.js +480 -0
  52. package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
  53. package/frontend/src/lib/analysis/lookup-presentation.js +74 -0
  54. package/frontend/src/lib/analysis/lookup-response.js +2 -0
  55. package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
  56. package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
  57. package/frontend/src/lib/analysis/page-similarity.js +1 -1
  58. package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
  59. package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
  60. package/frontend/src/lib/analysis/scoring.js +8 -88
  61. package/frontend/src/lib/analysis/shortlist-model.js +8 -4
  62. package/frontend/src/lib/analysis/utils.js +7 -40
  63. package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
  64. package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
  65. package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
  66. package/frontend/src/lib/analysis/visualization-models.js +370 -0
  67. package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
  68. package/frontend/src/lib/analysis/website-snapshot-model.js +57 -3
  69. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
  70. package/frontend/src/lib/analysis/workspace-archive.js +2 -2
  71. package/lib/analyst-taxonomy.mjs +40 -0
  72. package/lib/availability.mjs +30 -10
  73. package/lib/browser-library-profile.mjs +15 -2
  74. package/lib/bulk-limits.mjs +13 -0
  75. package/lib/certificate-extension-profile.mjs +71 -0
  76. package/lib/credential-surface-profile.mjs +1 -1
  77. package/lib/ct-response-bounds.mjs +9 -0
  78. package/lib/ct-search.mjs +66 -12
  79. package/lib/dns-delegation-health.mjs +56 -5
  80. package/lib/dnssec-evidence-validation.mjs +235 -0
  81. package/lib/domain-assurance.mjs +368 -0
  82. package/lib/domain-control-manifest.mjs +382 -0
  83. package/lib/domain-posture.mjs +11 -10
  84. package/lib/encrypted-dns-contract.mjs +81 -0
  85. package/lib/evidence-export.mjs +17 -3
  86. package/lib/evidence-report-markdown.mjs +1 -1
  87. package/lib/evidence-report.mjs +4 -1
  88. package/lib/favicon.mjs +3 -8
  89. package/lib/generated/cisa-kev-catalog.mjs +1668 -0
  90. package/lib/html-signals.mjs +4 -0
  91. package/lib/http-evidence-bounds.mjs +4 -0
  92. package/lib/http-intelligence.mjs +2 -1
  93. package/lib/ip-prefix.mjs +87 -0
  94. package/lib/local-geoip-evidence.mjs +89 -0
  95. package/lib/lookup-response-contract.mjs +346 -0
  96. package/lib/observed-network-context.mjs +1 -1
  97. package/lib/opportunity-scoring.mjs +107 -0
  98. package/lib/outbound-identity.mjs +9 -0
  99. package/lib/outbound-request-bounds.mjs +9 -0
  100. package/lib/page-fingerprints.mjs +1 -1
  101. package/lib/perceptual-hash-comparison.mjs +34 -0
  102. package/lib/perceptual-hash.mjs +3 -31
  103. package/lib/project-metadata.mjs +8 -0
  104. package/lib/provider-policy-admission.mjs +49 -0
  105. package/lib/rdap-capabilities.mjs +6 -6
  106. package/lib/rdap-search-workbench.mjs +247 -0
  107. package/lib/rdap.mjs +7 -2
  108. package/lib/registry-access.mjs +1 -1
  109. package/lib/registry-capabilities.mjs +31 -1
  110. package/lib/registry-capability-catalogue.mjs +15 -14
  111. package/lib/registry-insights.mjs +6 -6
  112. package/lib/response-policy.mjs +81 -6
  113. package/lib/risk-scoring.mjs +206 -83
  114. package/lib/rpki-evidence.mjs +105 -0
  115. package/lib/safe-fetch.mjs +2 -1
  116. package/lib/scoring-evidence-quality.mjs +107 -0
  117. package/lib/security-txt.mjs +3 -3
  118. package/lib/semantic-version.mjs +54 -0
  119. package/lib/static-html-analysis.mjs +27 -0
  120. package/lib/threat-intelligence-manifests.mjs +1 -1
  121. package/lib/threatfox-intelligence.mjs +11 -6
  122. package/lib/tls-intelligence.mjs +13 -3
  123. package/lib/tlsa-evidence.mjs +194 -0
  124. package/lib/urlhaus-intelligence.mjs +11 -6
  125. package/lib/urlscan-intelligence.mjs +10 -5
  126. package/lib/website-security-posture.mjs +16 -9
  127. package/lib/website-technology.mjs +1 -1
  128. package/lib/whois-chain.mjs +14 -152
  129. package/lib/whois.mjs +2 -2
  130. package/package.json +6 -2
  131. package/third-party-notices.txt +911 -19
@@ -0,0 +1,480 @@
1
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/gu;
2
+ const MAX_TEXT = 320;
3
+ const MAX_ENTRIES = 24;
4
+ const MAX_ACTIONS = 6;
5
+ const TASK_GUIDANCE = Object.freeze({
6
+ general: Object.freeze({
7
+ label: 'General investigation',
8
+ summary: 'Establish what was observed, which sources agree, and what remains unknown before drawing a conclusion.',
9
+ questions: Object.freeze([
10
+ 'Which source observations are complete enough to rely on?',
11
+ 'Do registration, infrastructure, certificate, and page identity evidence agree?',
12
+ 'What manual pivot would reduce the most important uncertainty?',
13
+ ]),
14
+ prioritySections: Object.freeze(['overview', 'web-evidence', 'registry', 'case-response']),
15
+ }),
16
+ acquisition: Object.freeze({
17
+ label: 'Acquisition review',
18
+ summary: 'Prioritise registration lifecycle, authority, transfer dependencies, mail, DNS, and services that would need a controlled transition.',
19
+ questions: Object.freeze([
20
+ 'Is the registration conclusion authoritative and internally consistent?',
21
+ 'Which DNS, mail, certificate, and website dependencies require transition planning?',
22
+ 'Which lifecycle dates are observations rather than guaranteed transfer or deletion dates?',
23
+ ]),
24
+ prioritySections: Object.freeze(['overview', 'registry', 'web-evidence', 'case-response']),
25
+ }),
26
+ brand: Object.freeze({
27
+ label: 'Brand review',
28
+ summary: 'Prioritise declared identity, page similarity, credential surfaces, external destinations, and infrastructure relationships.',
29
+ questions: Object.freeze([
30
+ 'Does the page claim or resemble a reviewed identity?',
31
+ 'Where do redirects, forms, resources, and trackers cross trust boundaries?',
32
+ 'Which similarities are verified facts and which are only review leads?',
33
+ ]),
34
+ prioritySections: Object.freeze(['overview', 'web-evidence', 'external-intelligence', 'case-response']),
35
+ }),
36
+ incident: Object.freeze({
37
+ label: 'Incident response',
38
+ summary: 'Prioritise current reachability, redirects, certificate state, credential surfaces, warning data, and evidence that can support a reviewed response.',
39
+ questions: Object.freeze([
40
+ 'What behaviour was observed at the recorded time?',
41
+ 'Which source limitations could change the response decision?',
42
+ 'What evidence and recipient route are ready for a reviewed handoff?',
43
+ ]),
44
+ prioritySections: Object.freeze(['overview', 'external-intelligence', 'web-evidence', 'case-response']),
45
+ }),
46
+ owned: Object.freeze({
47
+ label: 'Owned-domain posture',
48
+ summary: 'Prioritise delegation, mail, certificate, security-policy, lifecycle, and change evidence for a domain under review.',
49
+ questions: Object.freeze([
50
+ 'Do registry publication and directly observed delegation agree?',
51
+ 'Are mail, certificate, and website controls in the expected state?',
52
+ 'Which incomplete or stale source should be reviewed before recording posture?',
53
+ ]),
54
+ prioritySections: Object.freeze(['overview', 'registry', 'web-evidence', 'case-response']),
55
+ }),
56
+ });
57
+ const TIMING_TO_EVIDENCE = Object.freeze({
58
+ rdap: Object.freeze(['rdap']),
59
+ whois: Object.freeze(['whois']),
60
+ domain_evidence: Object.freeze([
61
+ 'availability',
62
+ 'client-behavior',
63
+ 'dns',
64
+ 'http',
65
+ 'page-identity',
66
+ 'page-role',
67
+ 'security-posture',
68
+ 'sslbl',
69
+ 'technology',
70
+ 'tls',
71
+ ]),
72
+ reverse_dns: Object.freeze(['reverse-dns']),
73
+ registrar_rdap: Object.freeze(['registrar-rdap']),
74
+ network_context: Object.freeze(['network-context']),
75
+ security_txt: Object.freeze(['security-txt']),
76
+ external_intelligence: Object.freeze(['external-intelligence']),
77
+ malware_host_intelligence: Object.freeze(['malware-host-intelligence']),
78
+ malware_ioc_intelligence: Object.freeze(['malware-ioc-intelligence']),
79
+ });
80
+ const SUPPORTS = Object.freeze({
81
+ rdap: Object.freeze(['Registration summary', 'Lifecycle', 'Availability']),
82
+ whois: Object.freeze(['Registration comparison', 'Contacts', 'Lifecycle']),
83
+ 'registrar-rdap': Object.freeze(['Registration publication comparison']),
84
+ availability: Object.freeze(['Availability decision', 'Lookup assessment']),
85
+ dns: Object.freeze(['Delegation', 'Mail posture', 'Service dependencies']),
86
+ 'reverse-dns': Object.freeze(['Observed endpoint context']),
87
+ 'network-context': Object.freeze(['Delivery and routing context']),
88
+ http: Object.freeze(['Redirects', 'Page collection', 'Website state']),
89
+ tls: Object.freeze(['Hostname authorisation', 'Certificate scope']),
90
+ 'page-identity': Object.freeze(['Declared page identity', 'Form and resource review']),
91
+ technology: Object.freeze(['Technology profile']),
92
+ 'security-posture': Object.freeze(['Passive security posture']),
93
+ 'security-txt': Object.freeze(['Security contact route']),
94
+ sslbl: Object.freeze(['Certificate warning-data comparison']),
95
+ 'external-intelligence': Object.freeze(['Optional external context']),
96
+ });
97
+ const ENDPOINT_CLASS = Object.freeze({
98
+ rdap: 'Authoritative registry endpoint',
99
+ whois: 'WHOIS transport',
100
+ 'registrar-rdap': 'Registrar publication endpoint',
101
+ availability: 'Authority-aware analysis',
102
+ dns: 'DNS resolver and authorities',
103
+ 'reverse-dns': 'Reverse DNS resolver',
104
+ 'network-context': 'IP RDAP and routing',
105
+ http: 'Bounded HTTP collection',
106
+ tls: 'TLS endpoint',
107
+ 'page-identity': 'Static page analysis',
108
+ technology: 'Derived analysis',
109
+ 'security-posture': 'Derived analysis',
110
+ 'security-txt': 'Selected well-known resource',
111
+ sslbl: 'Local warning-data snapshot',
112
+ 'external-intelligence': 'Optional external provider',
113
+ });
114
+ const SOURCE_DESCRIPTION = Object.freeze({
115
+ rdap: 'Structured registration data selected through the registry bootstrap.',
116
+ whois: 'Referral-aware text registration data collected through the WHOIS service path.',
117
+ 'registrar-rdap': 'A separately attributed registrar publication discovered from registry RDAP.',
118
+ availability: 'An authority-aware conclusion derived from the enabled registration sources.',
119
+ dns: 'Point-in-time delegation, address, mail, and service records from bounded DNS collection.',
120
+ 'reverse-dns': 'Operator-published PTR context for an observed public address.',
121
+ 'network-context': 'Point-in-time IP registration and routing context for an observed endpoint.',
122
+ http: 'A bounded homepage request covering redirects, response metadata, and a capped body prefix.',
123
+ tls: 'A point-in-time TLS handshake to one validated public address for the requested hostname.',
124
+ 'page-identity': 'Static identity, form, resource, and page-component signals from retained HTML.',
125
+ technology: 'Curated technology signatures evaluated against already observed page evidence.',
126
+ 'security-posture': 'Passive security controls derived from collected DNS, HTTP, TLS, and page evidence.',
127
+ 'security-txt': 'A bounded request for the selected well-known security contact resource.',
128
+ sslbl: 'A local comparison against a versioned certificate warning-data snapshot.',
129
+ 'external-intelligence': 'Optional provider context retained with its own source state and limitations.',
130
+ });
131
+ function record(value) {
132
+ return value && typeof value === 'object' && !Array.isArray(value)
133
+ ? value
134
+ : {};
135
+ }
136
+ function text(value, maximum = MAX_TEXT) {
137
+ return String(value ?? '')
138
+ .replace(CONTROL_CHARACTERS, ' ')
139
+ .replace(/\s+/gu, ' ')
140
+ .trim()
141
+ .slice(0, maximum);
142
+ }
143
+ function idPart(value) {
144
+ return text(value, 80).toLowerCase().replace(/[^a-z0-9]+/gu, '-').replace(/^-|-$/gu, '');
145
+ }
146
+ function display(value) {
147
+ const normalized = text(value, 180);
148
+ return normalized || 'not published';
149
+ }
150
+ function isoDate(value) {
151
+ if (typeof value !== 'string')
152
+ return null;
153
+ const timestamp = Date.parse(value);
154
+ return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
155
+ }
156
+ function ageDays(value, now) {
157
+ const current = isoDate(now);
158
+ if (!value || !current)
159
+ return null;
160
+ return Math.max(0, Math.floor((Date.parse(current) - Date.parse(value)) / 86_400_000));
161
+ }
162
+ function taskGuidance(task) {
163
+ return { task, ...TASK_GUIDANCE[task] };
164
+ }
165
+ function comparisonEntries(comparison, kind) {
166
+ const output = [];
167
+ const source = record(comparison);
168
+ const sourceHealth = record(source.sourceHealth);
169
+ const fields = Array.isArray(source.fields) ? source.fields.slice(0, 24) : [];
170
+ for (const item of fields) {
171
+ const field = record(item);
172
+ const status = text(field.status, 64);
173
+ const label = text(field.label, 120) || 'Registration field';
174
+ const suffix = idPart(label);
175
+ if (status === 'conflict') {
176
+ const left = kind === 'registry-whois' ? field.rdapDisplay : field.registryDisplay;
177
+ const right = kind === 'registry-whois' ? field.whoisDisplay : field.registrarDisplay;
178
+ output.push({
179
+ id: `${kind}-${suffix || output.length}`,
180
+ state: 'conflict',
181
+ importance: ['Domain', 'Registrar', 'Name servers', 'Statuses'].includes(label) ? 'high' : 'medium',
182
+ title: `${label} differs between registration sources`,
183
+ detail: `${display(left)} compared with ${display(right)}.`,
184
+ sources: kind === 'registry-whois'
185
+ ? ['Registry RDAP', 'WHOIS']
186
+ : ['Registry RDAP', 'Registrar RDAP'],
187
+ href: '#registry',
188
+ });
189
+ continue;
190
+ }
191
+ const unavailableSource = status.startsWith('rdap_')
192
+ ? 'rdap'
193
+ : status.startsWith('whois_')
194
+ ? 'whois'
195
+ : status.startsWith('registry_')
196
+ ? 'registry'
197
+ : status.startsWith('registrar_')
198
+ ? 'registrar'
199
+ : null;
200
+ const unavailableSourceStatus = unavailableSource
201
+ ? text(record(sourceHealth[unavailableSource]).status, 64)
202
+ : null;
203
+ if ((status.includes('unavailable') || status.includes('incomplete'))
204
+ && unavailableSourceStatus !== 'unsupported') {
205
+ output.push({
206
+ id: `${kind}-${suffix || output.length}-${status}`,
207
+ state: 'uncertain',
208
+ importance: 'low',
209
+ title: `${label} comparison is incomplete`,
210
+ detail: 'At least one registration source did not provide complete evidence, so no disagreement or equivalence conclusion is available.',
211
+ sources: kind === 'registry-whois'
212
+ ? ['Registry RDAP', 'WHOIS']
213
+ : ['Registry RDAP', 'Registrar RDAP'],
214
+ href: '#registry',
215
+ });
216
+ }
217
+ }
218
+ return output;
219
+ }
220
+ function hostname(value) {
221
+ const candidate = typeof value === 'string' ? value : text(record(value).url, 2_048);
222
+ if (!candidate)
223
+ return null;
224
+ try {
225
+ const url = new URL(/^[a-z][a-z\d+.-]*:\/\//iu.test(candidate) ? candidate : `https://${candidate}`);
226
+ return url.hostname.toLowerCase().replace(/\.$/u, '');
227
+ }
228
+ catch {
229
+ return null;
230
+ }
231
+ }
232
+ function sameRegistrableScope(left, right, registrableDomain) {
233
+ if (left === right)
234
+ return true;
235
+ if (!registrableDomain)
236
+ return false;
237
+ const belongsToDomain = (value) => (value === registrableDomain || value.endsWith(`.${registrableDomain}`));
238
+ return belongsToDomain(left) && belongsToDomain(right);
239
+ }
240
+ function identityEntries(input) {
241
+ const output = [];
242
+ const requestedHost = hostname(input.requestedHost);
243
+ const registrableDomain = hostname(input.registrableDomain);
244
+ const finalHost = hostname(input.finalUrl);
245
+ const canonicalHost = hostname(input.canonicalUrl);
246
+ const openGraphHost = hostname(input.openGraphUrl);
247
+ const tlsAuthorization = record(input.tlsAuthorization);
248
+ if (tlsAuthorization.authorized === false) {
249
+ output.push({
250
+ id: 'tls-hostname-authorization',
251
+ state: 'conflict',
252
+ importance: 'high',
253
+ title: 'The observed certificate did not authorise the requested hostname',
254
+ detail: text(tlsAuthorization.error, 240) || 'TLS hostname authorisation failed for the requested host.',
255
+ sources: ['TLS'],
256
+ href: '#evidence-tls',
257
+ });
258
+ }
259
+ if (requestedHost && finalHost && !sameRegistrableScope(requestedHost, finalHost, registrableDomain)) {
260
+ output.push({
261
+ id: 'http-cross-site-final-origin',
262
+ state: 'conflict',
263
+ importance: 'medium',
264
+ title: 'The final website origin is outside the requested domain',
265
+ detail: `${requestedHost} redirected to ${finalHost}. Review the redirect chain before interpreting identity or control.`,
266
+ sources: ['HTTP'],
267
+ href: '#evidence-http',
268
+ });
269
+ }
270
+ if (finalHost && canonicalHost && !sameRegistrableScope(finalHost, canonicalHost, registrableDomain)) {
271
+ output.push({
272
+ id: 'page-canonical-origin',
273
+ state: 'conflict',
274
+ importance: 'medium',
275
+ title: 'The declared canonical origin differs from the observed final origin',
276
+ detail: `${finalHost} served the page while the document declared ${canonicalHost} as canonical.`,
277
+ sources: ['HTTP', 'HTML'],
278
+ href: '#evidence-page-identity',
279
+ });
280
+ }
281
+ if (finalHost && openGraphHost && !sameRegistrableScope(finalHost, openGraphHost, registrableDomain)) {
282
+ output.push({
283
+ id: 'page-open-graph-origin',
284
+ state: 'conflict',
285
+ importance: 'low',
286
+ title: 'The Open Graph URL points outside the observed final origin',
287
+ detail: `${finalHost} served the page while Open Graph metadata declared ${openGraphHost}. Publisher metadata is a claim, not identity proof.`,
288
+ sources: ['HTTP', 'HTML'],
289
+ href: '#evidence-page-identity',
290
+ });
291
+ }
292
+ return output;
293
+ }
294
+ function certificatePolicyEntries(value) {
295
+ const review = record(value);
296
+ const findings = Array.isArray(review.findings) ? review.findings.slice(0, 8) : [];
297
+ const output = [];
298
+ for (const item of findings) {
299
+ const finding = record(item);
300
+ const id = text(finding.id, 80);
301
+ const state = text(finding.state, 80);
302
+ const label = text(finding.label, 120) || 'Certificate policy';
303
+ if (state === 'apparently_outside_current_policy' || state === 'changed') {
304
+ output.push({
305
+ id: `certificate-policy-${id || state}`,
306
+ state: 'conflict',
307
+ importance: id === 'expected_spki' ? 'high' : 'medium',
308
+ title: label,
309
+ detail: text(finding.detail, 300) || 'Observed certificate context differs from the current reviewed policy context.',
310
+ sources: Array.isArray(finding.sources) ? finding.sources.map((source) => text(source, 80)).filter(Boolean) : ['DNS', 'TLS'],
311
+ href: '#evidence-certificate-policy',
312
+ });
313
+ continue;
314
+ }
315
+ if (state === 'indeterminate' || state === 'no_target_policy_observed') {
316
+ output.push({
317
+ id: `certificate-policy-${id || state}`,
318
+ state: 'uncertain',
319
+ importance: 'low',
320
+ title: `${label} is indeterminate`,
321
+ detail: text(finding.detail, 300) || 'Current evidence does not support a policy comparison.',
322
+ sources: Array.isArray(finding.sources) ? finding.sources.map((source) => text(source, 80)).filter(Boolean) : ['DNS', 'TLS'],
323
+ href: '#evidence-certificate-policy',
324
+ });
325
+ }
326
+ }
327
+ return output;
328
+ }
329
+ function prioritizeEntries(entries, task) {
330
+ const taskBoost = {
331
+ general: [],
332
+ acquisition: ['registry'],
333
+ brand: ['page', 'http', 'tls'],
334
+ incident: ['http', 'tls', 'page'],
335
+ owned: ['registry', 'tls'],
336
+ };
337
+ const importance = { high: 0, medium: 1, low: 2 };
338
+ return [...entries].sort((left, right) => {
339
+ const leftBoost = taskBoost[task].some((prefix) => left.id.startsWith(prefix)) ? -1 : 0;
340
+ const rightBoost = taskBoost[task].some((prefix) => right.id.startsWith(prefix)) ? -1 : 0;
341
+ return leftBoost - rightBoost
342
+ || importance[left.importance] - importance[right.importance]
343
+ || left.title.localeCompare(right.title);
344
+ }).slice(0, 16);
345
+ }
346
+ export function buildLookupDecisionSupport(input) {
347
+ const entries = prioritizeEntries([
348
+ ...comparisonEntries(input.registryComparison, 'registry-whois'),
349
+ ...comparisonEntries(input.registrarPublicationComparison, 'registry-registrar'),
350
+ ...identityEntries(input),
351
+ ...certificatePolicyEntries(input.certificatePolicyReview),
352
+ ], input.task);
353
+ const actions = [];
354
+ const firstConflict = entries.find((entry) => entry.state === 'conflict');
355
+ if (firstConflict) {
356
+ actions.push({
357
+ id: 'review-priority-conflict',
358
+ label: 'Review the highest-priority disagreement',
359
+ reason: firstConflict.title,
360
+ href: firstConflict.href,
361
+ priority: 'high',
362
+ });
363
+ }
364
+ if (input.refreshPlan.items.length) {
365
+ actions.push({
366
+ id: 'review-refresh-options',
367
+ label: 'Review limited or stale sources',
368
+ reason: `${input.refreshPlan.items.length} deliberate source refresh option${input.refreshPlan.items.length === 1 ? ' is' : 's are'} available.`,
369
+ href: '#evidence-quality',
370
+ priority: 'medium',
371
+ });
372
+ }
373
+ const firstLimited = input.coverage.entries.find((entry) => entry.manualReviewSuggested);
374
+ if (firstLimited && !input.refreshPlan.items.some((item) => item.evidenceIds.includes(firstLimited.id))) {
375
+ actions.push({
376
+ id: 'inspect-limited-source',
377
+ label: `Inspect ${firstLimited.label}`,
378
+ reason: `${firstLimited.statusLabel} evidence may limit downstream conclusions.`,
379
+ href: '#evidence-quality',
380
+ priority: 'medium',
381
+ });
382
+ }
383
+ if (input.task === 'brand') {
384
+ actions.push({
385
+ id: 'review-page-identity',
386
+ label: 'Review identity and credential evidence',
387
+ reason: 'Compare declared identity, forms, redirects, and external destinations before making a brand assessment.',
388
+ href: '#web-evidence',
389
+ priority: 'medium',
390
+ });
391
+ }
392
+ else if (input.task === 'acquisition') {
393
+ actions.push({
394
+ id: 'review-acquisition-dependencies',
395
+ label: 'Review transfer dependencies',
396
+ reason: 'Registration dates alone do not describe the DNS, mail, certificate, and website services that require transition.',
397
+ href: '#web-evidence',
398
+ priority: 'medium',
399
+ });
400
+ }
401
+ else if (input.task === 'owned') {
402
+ actions.push({
403
+ id: 'review-owned-posture',
404
+ label: 'Review delegation and posture evidence',
405
+ reason: 'Confirm registry publication, authoritative responses, mail, and certificate state together.',
406
+ href: '#web-evidence',
407
+ priority: 'medium',
408
+ });
409
+ }
410
+ if (input.hasCaseSection) {
411
+ actions.push({
412
+ id: 'review-case-handoff',
413
+ label: 'Record or hand off reviewed evidence',
414
+ reason: 'Keep observed facts, hypotheses, unknowns, and analyst decisions separate in the case workflow.',
415
+ href: '#case-response',
416
+ priority: 'low',
417
+ });
418
+ }
419
+ const uniqueActions = [...new Map(actions.map((action) => [action.id, action])).values()].slice(0, MAX_ACTIONS);
420
+ return {
421
+ version: 1,
422
+ guidance: taskGuidance(input.task),
423
+ entries,
424
+ actions: uniqueActions,
425
+ counts: {
426
+ conflicts: entries.filter((entry) => entry.state === 'conflict').length,
427
+ uncertainties: entries.filter((entry) => entry.state === 'uncertain').length,
428
+ },
429
+ };
430
+ }
431
+ function timingByEvidence(timing) {
432
+ const output = new Map();
433
+ if (!timing)
434
+ return output;
435
+ for (const source of timing.sources) {
436
+ for (const evidenceId of TIMING_TO_EVIDENCE[source.source]) {
437
+ output.set(evidenceId, source);
438
+ }
439
+ }
440
+ return output;
441
+ }
442
+ export function buildLookupEvidenceQualityMatrix(input) {
443
+ const observedAt = isoDate(input.observedAt);
444
+ const currentAgeDays = ageDays(observedAt, input.now ?? new Date().toISOString());
445
+ const timings = timingByEvidence(input.timing);
446
+ const refreshIds = new Set(input.refreshPlan.items.flatMap((item) => item.evidenceIds));
447
+ const entries = input.coverage.entries.slice(0, MAX_ENTRIES).map((entry) => {
448
+ const timing = timings.get(entry.id);
449
+ const entryObservedAt = isoDate(input.observedAtByEvidence?.[entry.id]) ?? observedAt;
450
+ return {
451
+ id: entry.id,
452
+ label: entry.label,
453
+ category: entry.category,
454
+ endpointClass: ENDPOINT_CLASS[entry.id] ?? 'Source-specific collection',
455
+ description: SOURCE_DESCRIPTION[entry.id]
456
+ ?? 'Source-specific evidence retained with its original state and limitations.',
457
+ state: entry.state,
458
+ statusLabel: entry.statusLabel,
459
+ truncated: entry.truncated,
460
+ observedAt: entryObservedAt,
461
+ ageDays: ageDays(entryObservedAt, input.now ?? new Date().toISOString()),
462
+ durationMs: timing?.durationMs ?? null,
463
+ timingOutcome: timing?.outcome ?? null,
464
+ refreshAvailable: refreshIds.has(entry.id),
465
+ limitations: entry.limitations,
466
+ supports: SUPPORTS[entry.id] ?? Object.freeze([]),
467
+ };
468
+ });
469
+ return {
470
+ version: 1,
471
+ observedAt,
472
+ totalMs: input.timing?.totalMs ?? null,
473
+ entries,
474
+ completeCount: input.coverage.completeCount,
475
+ limitedCount: input.coverage.limitedCount,
476
+ stale: input.refreshPlan.stale,
477
+ ageDays: currentAgeDays,
478
+ freshnessPolicy: input.refreshPlan.freshnessPolicy,
479
+ };
480
+ }
@@ -0,0 +1,153 @@
1
+ export const LOOKUP_INVESTIGATION_BRIEF_SCHEMA = 'whoisleuth.investigation-brief';
2
+ export const LOOKUP_INVESTIGATION_BRIEF_VERSION = 1;
3
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/gu;
4
+ const MAX_FACTS = 12;
5
+ const MAX_LIMITATIONS = 20;
6
+ function text(value, maximum = 320) {
7
+ return String(value ?? '')
8
+ .replace(CONTROL_CHARACTERS, ' ')
9
+ .replace(/\s+/gu, ' ')
10
+ .trim()
11
+ .slice(0, maximum);
12
+ }
13
+ function timestamp(value) {
14
+ if (typeof value !== 'string')
15
+ return null;
16
+ const parsed = Date.parse(value);
17
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
18
+ }
19
+ function uniqueText(values, maximum = MAX_LIMITATIONS) {
20
+ const output = [];
21
+ const seen = new Set();
22
+ for (const value of values) {
23
+ const normalized = text(value);
24
+ if (!normalized || seen.has(normalized))
25
+ continue;
26
+ output.push(normalized);
27
+ seen.add(normalized);
28
+ if (output.length >= maximum)
29
+ break;
30
+ }
31
+ return output;
32
+ }
33
+ function taskQuestion(support) {
34
+ return support.guidance.questions[0]
35
+ ?? 'What does the collected evidence establish, and what remains uncertain?';
36
+ }
37
+ export function buildLookupInvestigationBrief(input) {
38
+ const generatedAt = timestamp(input.generatedAt) ?? new Date().toISOString();
39
+ const verifiedFacts = input.summary.facts
40
+ .filter((fact) => fact.value !== '—')
41
+ .slice(0, MAX_FACTS);
42
+ const contradictions = input.decisionSupport.entries
43
+ .filter((entry) => entry.state === 'conflict');
44
+ const unknowns = input.decisionSupport.entries
45
+ .filter((entry) => entry.state === 'uncertain');
46
+ const limitedSources = input.quality.entries
47
+ .filter((entry) => entry.state !== 'complete')
48
+ .map((entry) => `${entry.label}: ${entry.statusLabel}`);
49
+ const graphKinds = uniqueText(input.graph.edges.map((edge) => edge.label), 12);
50
+ return {
51
+ schema: LOOKUP_INVESTIGATION_BRIEF_SCHEMA,
52
+ schemaVersion: LOOKUP_INVESTIGATION_BRIEF_VERSION,
53
+ generatedAt,
54
+ target: text(input.target, 253) || 'Unknown target',
55
+ targetType: text(input.targetType, 40) || 'unknown',
56
+ task: input.task,
57
+ taskLabel: input.decisionSupport.guidance.label,
58
+ question: taskQuestion(input.decisionSupport),
59
+ summary: input.decisionSupport.guidance.summary,
60
+ observation: {
61
+ observedAt: input.quality.observedAt,
62
+ evidenceAgeDays: input.quality.ageDays,
63
+ completeSources: input.quality.completeCount,
64
+ limitedSources: input.quality.limitedCount,
65
+ freshnessPolicy: input.quality.freshnessPolicy,
66
+ },
67
+ verifiedFacts,
68
+ contradictions,
69
+ unknowns,
70
+ nextActions: input.decisionSupport.actions,
71
+ relationships: {
72
+ nodes: input.graph.nodes.length,
73
+ edges: input.graph.edges.length,
74
+ truncated: input.graph.truncated,
75
+ kinds: graphKinds,
76
+ },
77
+ limitations: uniqueText([
78
+ ...limitedSources,
79
+ ...input.graph.limitations,
80
+ 'This brief is a deterministic organisation of collected and derived evidence, not an attribution, ownership, safety, availability, or maliciousness conclusion.',
81
+ 'Analyst assertions, hypotheses, and decisions must remain separate from observed evidence.',
82
+ ]),
83
+ };
84
+ }
85
+ function markdown(value) {
86
+ return text(value, 2_000)
87
+ .replace(/&/gu, '&')
88
+ .replace(/</gu, '&lt;')
89
+ .replace(/>/gu, '&gt;')
90
+ .replace(/([\\`*_{}[\]()#+\-.!|=~])/gu, '\\$1');
91
+ }
92
+ function factLine(fact) {
93
+ const sources = fact.provenance.sources.length
94
+ ? ` Sources: ${fact.provenance.sources.map(markdown).join(', ')}.`
95
+ : '';
96
+ return `- **${markdown(fact.label)}:** ${markdown(fact.value)}.${sources}`;
97
+ }
98
+ function decisionLine(entry) {
99
+ return `- **${markdown(entry.title)}:** ${markdown(entry.detail)} Sources: ${entry.sources.map(markdown).join(', ') || 'not reported'}.`;
100
+ }
101
+ export function formatLookupInvestigationBriefMarkdown(brief) {
102
+ const lines = [
103
+ `# Investigation brief: ${markdown(brief.target)}`,
104
+ '',
105
+ `Generated ${markdown(brief.generatedAt)} from a ${markdown(brief.targetType)} Lookup.`,
106
+ '',
107
+ '## Decision context',
108
+ '',
109
+ `- **Task:** ${markdown(brief.taskLabel)}`,
110
+ `- **Question:** ${markdown(brief.question)}`,
111
+ `- **Summary:** ${markdown(brief.summary)}`,
112
+ `- **Evidence observed:** ${markdown(brief.observation.observedAt ?? 'not reported')}`,
113
+ `- **Source quality:** ${brief.observation.completeSources} complete; ${brief.observation.limitedSources} limited`,
114
+ `- **Freshness policy:** v${brief.observation.freshnessPolicy.version} ${markdown(brief.observation.freshnessPolicy.id)} for ${markdown(brief.observation.freshnessPolicy.task)}; registration ${brief.observation.freshnessPolicy.thresholdsDays.registration}d, network ${brief.observation.freshnessPolicy.thresholdsDays.network}d, web ${brief.observation.freshnessPolicy.thresholdsDays.web}d`,
115
+ '',
116
+ '## Verified normalised facts',
117
+ '',
118
+ ...(brief.verifiedFacts.length ? brief.verifiedFacts.map(factLine) : ['- No normalised fact was available for this brief.']),
119
+ '',
120
+ '## Contradictory evidence',
121
+ '',
122
+ ...(brief.contradictions.length ? brief.contradictions.map(decisionLine) : ['- No explicit contradiction was derived from the supported comparisons.']),
123
+ '',
124
+ '## Unknowns and incomplete comparisons',
125
+ '',
126
+ ...(brief.unknowns.length ? brief.unknowns.map(decisionLine) : ['- No explicit uncertainty entry was derived. Review source quality before treating this as complete.']),
127
+ '',
128
+ '## Recommended next manual steps',
129
+ '',
130
+ ...(brief.nextActions.length
131
+ ? brief.nextActions.map((action) => `- **${markdown(action.label)}:** ${markdown(action.reason)}`)
132
+ : ['- Review the underlying source evidence and its limitations.']),
133
+ '',
134
+ '## Observed relationship map',
135
+ '',
136
+ `- ${brief.relationships.nodes} nodes and ${brief.relationships.edges} edges${brief.relationships.truncated ? '; bounded output was truncated' : ''}.`,
137
+ `- Relationship types: ${brief.relationships.kinds.map(markdown).join(', ') || 'none observed'}.`,
138
+ '',
139
+ '## Limitations',
140
+ '',
141
+ ...brief.limitations.map((limitation) => `- ${markdown(limitation)}`),
142
+ '',
143
+ ];
144
+ return lines.join('\n');
145
+ }
146
+ export function lookupInvestigationBriefFilename(brief) {
147
+ const target = brief.target
148
+ .toLowerCase()
149
+ .replace(/[^a-z0-9.-]+/gu, '-')
150
+ .replace(/^-+|-+$/gu, '')
151
+ .slice(0, 80) || 'lookup';
152
+ return `whoisleuth-investigation-brief-${target}-${brief.generatedAt.slice(0, 10)}.md`;
153
+ }