@slicedearth/whoisleuth-cli 1.39.1 → 1.45.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 (90) hide show
  1. package/README.md +23 -4
  2. package/bin/whoisleuth.mjs +31 -9
  3. package/cli/archive-inspect.mjs +37 -2
  4. package/cli/arguments.mjs +361 -256
  5. package/cli/artifact-verify.mjs +35 -1
  6. package/cli/bulk-checkpoint.mjs +72 -39
  7. package/cli/bulk-command-runner.mjs +34 -4
  8. package/cli/bulk.mjs +18 -7
  9. package/cli/case-pack.mjs +136 -0
  10. package/cli/ci-report.mjs +73 -0
  11. package/cli/collection-preflight.mjs +59 -0
  12. package/cli/command-reference.mjs +395 -0
  13. package/cli/completion.mjs +67 -13
  14. package/cli/config-profile.mjs +183 -0
  15. package/cli/ct-event-intake.mjs +159 -0
  16. package/cli/discovery-observation-snapshot.mjs +8 -14
  17. package/cli/discovery-scan-command-runner.mjs +29 -2
  18. package/cli/discovery-snapshot.mjs +8 -15
  19. package/cli/domain-control-monitor.mjs +104 -0
  20. package/cli/domain-control-observations.mjs +235 -0
  21. package/cli/errors.mjs +7 -1
  22. package/cli/evidence-command-arguments.mjs +11 -0
  23. package/cli/evidence-command-runner.mjs +1 -0
  24. package/cli/external-observation-mapping.mjs +162 -0
  25. package/cli/fail-policy.mjs +64 -0
  26. package/cli/formatters/html.mjs +1 -1
  27. package/cli/formatters/json.mjs +12 -4
  28. package/cli/formatters/terminal.mjs +1 -1
  29. package/cli/http.mjs +2 -1
  30. package/cli/investigation-manifest.mjs +113 -0
  31. package/cli/investigation-run.mjs +112 -0
  32. package/cli/lookup-brief.mjs +116 -0
  33. package/cli/lookup-command-runner.mjs +8 -1
  34. package/cli/lookup-diff.mjs +17 -19
  35. package/cli/lookup-reconcile.mjs +1 -1
  36. package/cli/mail-review.mjs +5 -5
  37. package/cli/manual.mjs +1 -1
  38. package/cli/network-command-runner.mjs +4 -1
  39. package/cli/offline-evidence-review.mjs +105 -8
  40. package/cli/open-asset-model-bridge.mjs +169 -0
  41. package/cli/registry-cohort.mjs +121 -0
  42. package/cli/registry-doctor.mjs +106 -11
  43. package/cli/registry-fixture-scaffold.mjs +74 -0
  44. package/cli/runner.mjs +388 -315
  45. package/docs/cli-reference.md +266 -21
  46. package/docs/cli.md +43 -7
  47. package/frontend/src/lib/analysis/acquisition-due-diligence.js +2 -2
  48. package/frontend/src/lib/analysis/brand-profile-model.js +50 -6
  49. package/frontend/src/lib/analysis/case-evidence-model.js +1 -1
  50. package/frontend/src/lib/analysis/case-record-core.js +1 -1
  51. package/frontend/src/lib/analysis/case-record-operations.js +1 -1
  52. package/frontend/src/lib/analysis/case-report.js +573 -0
  53. package/frontend/src/lib/analysis/case-response-model.js +30 -1
  54. package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
  55. package/frontend/src/lib/analysis/external-findings-import.js +320 -0
  56. package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
  57. package/frontend/src/lib/analysis/lookup-decision-support.js +9 -2
  58. package/frontend/src/lib/analysis/lookup-presentation.js +14 -30
  59. package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
  60. package/frontend/src/lib/analysis/scoring.js +1 -1
  61. package/frontend/src/lib/analysis/utils.js +1 -1
  62. package/frontend/src/lib/analysis/visualization-models.js +2 -3
  63. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
  64. package/frontend/src/lib/analysis/workspace-archive.js +5 -7
  65. package/lib/abusech-auth.mjs +1 -1
  66. package/lib/availability.mjs +1 -1
  67. package/lib/bounded-contract-normalizers.mjs +33 -0
  68. package/lib/bounded-file.mjs +69 -0
  69. package/lib/bounded-json-response.mjs +157 -0
  70. package/lib/browser-library-profile.mjs +167 -38
  71. package/lib/dns-convergence-review.mjs +183 -0
  72. package/lib/dns-delegation-health.mjs +196 -3
  73. package/lib/domain-change-packet.mjs +133 -0
  74. package/lib/domain-change-review.mjs +307 -0
  75. package/lib/domain-control-flight-recorder.mjs +268 -0
  76. package/lib/domain-portfolio-review.mjs +151 -0
  77. package/lib/evidence-export.mjs +1 -1
  78. package/lib/html-signals.mjs +5 -11
  79. package/lib/lookup-cache.mjs +5 -1
  80. package/lib/nameserver-preflight-review.mjs +159 -0
  81. package/lib/page-language-signals.mjs +124 -0
  82. package/lib/rdap-transport.mjs +6 -6
  83. package/lib/risk-scoring.mjs +42 -1
  84. package/lib/safe-fetch.mjs +37 -32
  85. package/lib/static-html-analysis.mjs +29 -1
  86. package/lib/threat-intelligence-runtime.mjs +1 -2
  87. package/lib/trust-store-comparison.mjs +146 -0
  88. package/lib/website-technology.mjs +22 -12
  89. package/lib/zone-intent-review.mjs +534 -0
  90. package/package.json +1 -1
@@ -0,0 +1,573 @@
1
+ // Pure, framework-neutral analyst-case report builder. Produces structured
2
+ // JSON and readable Markdown from a single CaseRecord, using the committed
3
+ // case-model contracts (CaseEvidenceSnapshot, compareCaseEvidence,
4
+ // latestCaseEvidence). No browser globals, no DOM access — Node-testable with
5
+ // node --test.
6
+ //
7
+ // The Lookup evidence export (evidence-export.ts) is a separate format that
8
+ // contains raw RDAP/WHOIS material. This module deliberately excludes all raw
9
+ // registry/web responses, contacts, cookies, screenshots, and authentication
10
+ // data. Reports contain only the normalized case record.
11
+ import { caseEvidenceIncomparableReasons, compareCaseEvidence, latestCaseEvidence } from "./case-model.js";
12
+ import { httpSecurityHeaderLabel } from "./http-summary.js";
13
+ import { analystInteroperabilityTags } from "../../../../lib/analyst-taxonomy.mjs";
14
+ import { CASE_EVIDENCE_RELATION_STANCES } from "./case-response-model.js";
15
+ // ---------------------------------------------------------------------------
16
+ // Constants
17
+ // ---------------------------------------------------------------------------
18
+ export const CASE_REPORT_SCHEMA = 'whoisleuth.case-report';
19
+ export const CASE_REPORT_SCHEMA_VERSION = 6;
20
+ const APPLICATION_NAME = 'WHOISleuth';
21
+ const LIMITATIONS_TEXT = [
22
+ 'This report contains normalised browser-local observations from WHOISleuth analyst cases.',
23
+ 'It is not a live lookup and does not contain raw WHOIS, RDAP, DNS, HTML, or responses collected during website checks.',
24
+ 'Absence of a signal (e.g. no MX record observed) does not prove nonexistence. It may not have been evaluated.',
25
+ 'Snapshot fingerprints are deduplication identifiers, not cryptographic evidence hashes.',
26
+ 'Scan-depth and scoring-model gates prevent misleading comparisons; "incomparable" means observations differ materially but one or more fields cannot be compared reliably.',
27
+ 'Generated locally in the browser. Review the package before sharing it.',
28
+ ].join(' ');
29
+ function escapeMarkdownInline(text) {
30
+ return String(text)
31
+ .replace(/[\r\n\u2028\u2029]+/g, ' ')
32
+ .replace(/([\\`*_{}\[\]<>()#+!|~])/g, '\\$1')
33
+ // GFM autolinks bare URLs independently of ordinary link syntax. Break
34
+ // the scheme or the first `www.` separator without making normal domains,
35
+ // dates, and prose noisy in the report source.
36
+ .replace(/\b([a-z][a-z0-9+.-]{1,31}):(?=\/\/)/gi, '$1\\:')
37
+ .replace(/\bwww\.(?=\S)/gi, 'www\\.');
38
+ }
39
+ /**
40
+ * Escapes a multiline note body for safe inclusion in a Markdown blockquote.
41
+ * Each line is individually escaped and prefixed with `> `.
42
+ * @param {string} text
43
+ * @returns {string}
44
+ */
45
+ function escapeMarkdownNote(text) {
46
+ return String(text)
47
+ .replace(/\r\n?/g, '\n')
48
+ .split('\n')
49
+ .map((line) => `> ${escapeMarkdownInline(line)}`)
50
+ .join('\n');
51
+ }
52
+ // ---------------------------------------------------------------------------
53
+ // Value formatting for reports
54
+ // ---------------------------------------------------------------------------
55
+ /**
56
+ * Formats a snapshot field value for report output. Never returns raw `null`,
57
+ * `undefined`, or `[object Object]`.
58
+ * @param {unknown} value
59
+ * @param {string} [field]
60
+ * @returns {string}
61
+ */
62
+ function formatReportValue(value, field) {
63
+ if (value === null || value === undefined)
64
+ return 'Not observed';
65
+ if (typeof value === 'boolean')
66
+ return value ? 'Detected' : 'Not detected';
67
+ if (Array.isArray(value)) {
68
+ if (value.length === 0)
69
+ return 'None';
70
+ if (field === 'riskFactors' || field === 'opportunityFactors') {
71
+ return value.map((f) => {
72
+ if (typeof f === 'object' && f !== null && 'label' in f && 'points' in f) {
73
+ return `${f.label} (${f.points > 0 ? '+' : ''}${f.points})`;
74
+ }
75
+ return String(f);
76
+ }).join(', ');
77
+ }
78
+ return value.join(', ');
79
+ }
80
+ if (typeof value === 'number')
81
+ return String(value);
82
+ return String(value);
83
+ }
84
+ /**
85
+ * Returns a shallow clone of a snapshot containing only known schema fields.
86
+ * Unknown/imported keys are never included.
87
+ * @param {import('./case-model.ts').CaseEvidenceSnapshot} snapshot
88
+ * @returns {object}
89
+ */
90
+ function pickKnownSnapshotFields(snapshot) {
91
+ const factors = (value) => Array.isArray(value)
92
+ ? value.map((factor) => {
93
+ const item = factor && typeof factor === 'object' ? factor : {};
94
+ return { label: String(item.label ?? ''), points: Number(item.points) || 0 };
95
+ })
96
+ : [];
97
+ return {
98
+ id: snapshot.id,
99
+ fingerprint: snapshot.fingerprint,
100
+ firstCapturedAt: snapshot.firstCapturedAt,
101
+ capturedAt: snapshot.capturedAt,
102
+ source: snapshot.source,
103
+ scanDepth: snapshot.scanDepth,
104
+ availability: snapshot.availability,
105
+ confidence: snapshot.confidence,
106
+ riskModelVersion: snapshot.riskModelVersion,
107
+ riskScore: snapshot.riskScore,
108
+ opportunityModelVersion: snapshot.opportunityModelVersion ?? null,
109
+ opportunityScore: snapshot.opportunityScore,
110
+ riskFactors: factors(snapshot.riskFactors),
111
+ opportunityFactors: factors(snapshot.opportunityFactors),
112
+ registrar: snapshot.registrar,
113
+ createdDate: snapshot.createdDate,
114
+ expiryDate: snapshot.expiryDate,
115
+ nameservers: Array.isArray(snapshot.nameservers) ? [...snapshot.nameservers] : [],
116
+ hasMx: snapshot.hasMx,
117
+ hasSpf: snapshot.hasSpf,
118
+ hasDmarc: snapshot.hasDmarc,
119
+ activityStatus: snapshot.activityStatus,
120
+ websiteProbeDetail: snapshot.websiteProbeDetail,
121
+ pageTitle: snapshot.pageTitle,
122
+ httpSummaryVersion: snapshot.httpSummaryVersion,
123
+ httpEvidenceStatus: snapshot.httpEvidenceStatus,
124
+ httpFinalOrigin: snapshot.httpFinalOrigin,
125
+ httpResponseStatus: snapshot.httpResponseStatus,
126
+ httpTransportSecurity: snapshot.httpTransportSecurity,
127
+ httpRedirectCount: snapshot.httpRedirectCount,
128
+ httpCrossOriginRedirect: snapshot.httpCrossOriginRedirect,
129
+ httpHttpsDowngrade: snapshot.httpHttpsDowngrade,
130
+ httpContentType: snapshot.httpContentType,
131
+ httpSecurityHeaders: Array.isArray(snapshot.httpSecurityHeaders) ? [...snapshot.httpSecurityHeaders] : null,
132
+ faviconMatch: snapshot.faviconMatch,
133
+ faviconNearMatch: snapshot.faviconNearMatch,
134
+ reusesOfficialAssets: snapshot.reusesOfficialAssets,
135
+ hasPasswordField: snapshot.hasPasswordField,
136
+ hasExternalFormAction: snapshot.hasExternalFormAction,
137
+ phishingLanguageMatch: snapshot.phishingLanguageMatch,
138
+ privacyProtected: snapshot.privacyProtected ?? null,
139
+ idnReferenceMatch: snapshot.idnReferenceMatch ?? null,
140
+ pageBaselineMatch: snapshot.pageBaselineMatch ?? null,
141
+ hasActiveBrandProfile: snapshot.hasActiveBrandProfile ?? null,
142
+ mutationTypes: Array.isArray(snapshot.mutationTypes) ? [...snapshot.mutationTypes] : [],
143
+ };
144
+ }
145
+ // ---------------------------------------------------------------------------
146
+ // Report schema construction
147
+ // ---------------------------------------------------------------------------
148
+ /**
149
+ * Builds a case report object and its Markdown representation from a single
150
+ * CaseRecord. The returned object is a plain JSON-safe value; the Markdown is
151
+ * a single string. Does not mutate the source record.
152
+ *
153
+ * @param {CaseRecord} caseRecord
154
+ * @param {ReportOptions} [options]
155
+ * @returns {{ json: object, markdown: string }}
156
+ */
157
+ export function buildCaseReport(caseRecord, options = {}) {
158
+ const { generatedAt } = options;
159
+ const includeNotes = options.includeNotes === true;
160
+ const now = generatedAt || new Date().toISOString();
161
+ // --- Build JSON report ---
162
+ const timelineEntries = [];
163
+ if (Array.isArray(caseRecord.evidenceHistory) && caseRecord.evidenceHistory.length > 0) {
164
+ const chronological = [...caseRecord.evidenceHistory];
165
+ for (let i = 0; i < chronological.length; i++) {
166
+ const snapshot = chronological[i];
167
+ if (!snapshot)
168
+ continue;
169
+ const isBaseline = i === 0;
170
+ const hasRepeatedObservation = snapshot.firstCapturedAt !== snapshot.capturedAt;
171
+ let changes = null;
172
+ let hasIncomparableChange = false;
173
+ let incomparableReasons = [];
174
+ if (!isBaseline) {
175
+ const previous = chronological[i - 1];
176
+ if (!previous)
177
+ continue;
178
+ const rawChanges = compareCaseEvidence(previous, snapshot);
179
+ incomparableReasons = caseEvidenceIncomparableReasons(previous, snapshot);
180
+ if (rawChanges.length > 0) {
181
+ changes = rawChanges.map((change) => ({
182
+ field: change.field,
183
+ label: change.label,
184
+ before: change.before,
185
+ after: change.after,
186
+ tone: change.tone,
187
+ }));
188
+ }
189
+ else if (snapshot.fingerprint !== previous.fingerprint && incomparableReasons.length === 0) {
190
+ incomparableReasons = ['other'];
191
+ }
192
+ hasIncomparableChange = incomparableReasons.length > 0;
193
+ }
194
+ timelineEntries.push({
195
+ snapshot: pickKnownSnapshotFields(snapshot),
196
+ isBaseline,
197
+ hasRepeatedObservation,
198
+ changes,
199
+ hasIncomparableChange,
200
+ incomparableReasons,
201
+ });
202
+ }
203
+ }
204
+ const latest = latestCaseEvidence({ evidenceHistory: caseRecord.evidenceHistory ?? undefined });
205
+ const currentAssessment = latest ? pickKnownSnapshotFields(latest) : null;
206
+ const json = {
207
+ schema: CASE_REPORT_SCHEMA,
208
+ schemaVersion: CASE_REPORT_SCHEMA_VERSION,
209
+ generatedAt: now,
210
+ application: { name: APPLICATION_NAME },
211
+ case: {
212
+ id: caseRecord.id,
213
+ domain: caseRecord.domain,
214
+ status: caseRecord.status,
215
+ disposition: caseRecord.disposition,
216
+ reviewReasonCode: caseRecord.reviewReasonCode ?? null,
217
+ interoperabilityTags: analystInteroperabilityTags(caseRecord.disposition, caseRecord.reviewReasonCode),
218
+ tags: Array.isArray(caseRecord.tags) ? [...caseRecord.tags] : [],
219
+ source: caseRecord.source,
220
+ openedAt: caseRecord.createdAt,
221
+ updatedAt: caseRecord.updatedAt,
222
+ notesIncluded: includeNotes,
223
+ ...(includeNotes && Array.isArray(caseRecord.notes) ? { notes: caseRecord.notes.map((n) => ({ createdAt: n.createdAt, body: n.body })) } : {}),
224
+ },
225
+ currentAssessment,
226
+ evidenceTimeline: timelineEntries,
227
+ analystResponse: {
228
+ evidencePins: caseRecord.evidencePins.map((item) => ({ ...item, limitations: [...item.limitations] })),
229
+ decisions: caseRecord.decisions.map((item) => ({ ...item, evidencePinIds: [...item.evidencePinIds] })),
230
+ actions: caseRecord.actions.map((item) => ({ ...item, contactLimitations: [...item.contactLimitations] })),
231
+ assertions: caseRecord.assertions.map((item) => ({
232
+ ...item,
233
+ evidencePinIds: [...item.evidencePinIds],
234
+ ...(item.evidenceRelations ? { evidenceRelations: item.evidenceRelations.map((relation) => ({ ...relation })) } : {}),
235
+ ...(item.provenance ? {
236
+ provenance: {
237
+ ...item.provenance,
238
+ labels: [...item.provenance.labels],
239
+ markings: [...item.provenance.markings],
240
+ },
241
+ } : {}),
242
+ })),
243
+ manualTrail: caseRecord.manualTrail.map((item) => ({ ...item })),
244
+ sightings: caseRecord.sightings.map((item) => ({ ...item, limitations: [...item.limitations] })),
245
+ },
246
+ limitations: LIMITATIONS_TEXT,
247
+ };
248
+ // --- Build Markdown ---
249
+ const md = buildMarkdown(json);
250
+ return { json, markdown: md };
251
+ }
252
+ // ---------------------------------------------------------------------------
253
+ // Markdown generation
254
+ // ---------------------------------------------------------------------------
255
+ /**
256
+ * Builds a Markdown string from a case report JSON object.
257
+ * @param {object} report
258
+ * @returns {string}
259
+ */
260
+ function buildMarkdown(report) {
261
+ const lines = [];
262
+ // Title
263
+ const domain = escapeMarkdownInline(report.case.domain || 'unknown');
264
+ lines.push(`# Case Report: ${domain}`);
265
+ lines.push('');
266
+ // Metadata
267
+ lines.push(`**Generated:** ${escapeMarkdownInline(report.generatedAt)}`);
268
+ lines.push(`**Report schema:** ${escapeMarkdownInline(report.schema)} v${report.schemaVersion}`);
269
+ lines.push('');
270
+ // Status summary
271
+ lines.push('## Status');
272
+ lines.push('');
273
+ lines.push(`| Field | Value |`);
274
+ lines.push(`|-------|-------|`);
275
+ lines.push(`| Status | ${escapeMarkdownInline(report.case.status)} |`);
276
+ lines.push(`| Disposition | ${escapeMarkdownInline(report.case.disposition)} |`);
277
+ if (report.case.reviewReasonCode)
278
+ lines.push(`| Review reason | ${escapeMarkdownInline(report.case.reviewReasonCode.replaceAll('_', ' '))} |`);
279
+ lines.push(`| Source | ${escapeMarkdownInline(report.case.source)} |`);
280
+ lines.push(`| Opened | ${escapeMarkdownInline(report.case.openedAt)} |`);
281
+ lines.push(`| Updated | ${escapeMarkdownInline(report.case.updatedAt)} |`);
282
+ if (report.case.tags.length > 0) {
283
+ lines.push(`| Tags | ${escapeMarkdownInline(report.case.tags.join(', '))} |`);
284
+ }
285
+ lines.push(`| Notes included | ${report.case.notesIncluded ? 'Yes' : 'No'} |`);
286
+ lines.push('');
287
+ // Current assessment
288
+ lines.push('## Current Assessment');
289
+ lines.push('');
290
+ if (report.currentAssessment) {
291
+ const a = report.currentAssessment;
292
+ lines.push(`- **Availability:** ${escapeMarkdownInline(formatReportValue(a.availability))}`);
293
+ lines.push(`- **Risk score:** ${escapeMarkdownInline(formatReportValue(a.riskScore))}`);
294
+ lines.push(`- **Risk model:** ${a.riskModelVersion === null ? 'Unversioned' : `v${escapeMarkdownInline(formatReportValue(a.riskModelVersion))}`}`);
295
+ lines.push(`- **Opportunity model:** ${a.opportunityModelVersion == null ? 'Unversioned' : `v${escapeMarkdownInline(formatReportValue(a.opportunityModelVersion))}`}`);
296
+ lines.push(`- **Registrar:** ${escapeMarkdownInline(formatReportValue(a.registrar))}`);
297
+ lines.push(`- **Website activity:** ${escapeMarkdownInline(formatReportValue(a.activityStatus))}`);
298
+ if (a.httpResponseStatus != null)
299
+ lines.push(`- **HTTP response:** ${escapeMarkdownInline(formatReportValue(a.httpResponseStatus))}`);
300
+ if (a.httpFinalOrigin)
301
+ lines.push(`- **Final website origin:** ${escapeMarkdownInline(a.httpFinalOrigin)}`);
302
+ lines.push(`- **Last captured:** ${escapeMarkdownInline(a.capturedAt)}`);
303
+ lines.push(`- **Scan depth:** ${escapeMarkdownInline(formatReportValue(a.scanDepth))}`);
304
+ lines.push(`- **Source:** ${escapeMarkdownInline(formatReportValue(a.source))}`);
305
+ }
306
+ else {
307
+ lines.push('No evidence captured.');
308
+ }
309
+ lines.push('');
310
+ // Evidence timeline
311
+ lines.push('## Evidence Timeline');
312
+ lines.push('');
313
+ const timeline = report.evidenceTimeline || [];
314
+ if (timeline.length === 0) {
315
+ lines.push('No evidence snapshots recorded.');
316
+ lines.push('');
317
+ }
318
+ else {
319
+ lines.push(`${timeline.length} snapshot${timeline.length === 1 ? '' : 's'} (chronological order):`);
320
+ lines.push('');
321
+ for (let i = 0; i < timeline.length; i++) {
322
+ const entry = timeline[i];
323
+ if (!entry)
324
+ continue;
325
+ const snap = entry.snapshot;
326
+ const index = i + 1;
327
+ lines.push(`### ${index}. ${entry.isBaseline ? 'Baseline' : 'Observation'}: ${escapeMarkdownInline(snap.capturedAt)}`);
328
+ lines.push('');
329
+ if (entry.hasRepeatedObservation) {
330
+ lines.push(`- **First observed:** ${escapeMarkdownInline(snap.firstCapturedAt)}`);
331
+ lines.push(`- **Last observed:** ${escapeMarkdownInline(snap.capturedAt)}`);
332
+ }
333
+ lines.push(`- **Source:** ${escapeMarkdownInline(snap.source)}`);
334
+ lines.push(`- **Scan depth:** ${escapeMarkdownInline(formatReportValue(snap.scanDepth))}`);
335
+ if (entry.isBaseline) {
336
+ lines.push(`- **Baseline:** first recorded observation`);
337
+ }
338
+ lines.push('');
339
+ // Key evidence fields
340
+ lines.push('**Key evidence:**');
341
+ lines.push('');
342
+ lines.push(`- Availability: ${escapeMarkdownInline(formatReportValue(snap.availability))}`);
343
+ lines.push(`- Risk score: ${escapeMarkdownInline(formatReportValue(snap.riskScore))}`);
344
+ lines.push(`- Risk model: ${snap.riskModelVersion === null ? 'Unversioned' : `v${escapeMarkdownInline(formatReportValue(snap.riskModelVersion))}`}`);
345
+ lines.push(`- Registrar: ${escapeMarkdownInline(formatReportValue(snap.registrar))}`);
346
+ lines.push(`- Website activity: ${escapeMarkdownInline(formatReportValue(snap.activityStatus))}`);
347
+ if (snap.httpResponseStatus != null)
348
+ lines.push(`- HTTP response: ${escapeMarkdownInline(formatReportValue(snap.httpResponseStatus))}`);
349
+ if (snap.httpFinalOrigin)
350
+ lines.push(`- Final website origin: ${escapeMarkdownInline(snap.httpFinalOrigin)}`);
351
+ if (snap.httpTransportSecurity)
352
+ lines.push(`- Website transport: ${escapeMarkdownInline(snap.httpTransportSecurity.toUpperCase())}`);
353
+ if (snap.httpRedirectCount != null)
354
+ lines.push(`- HTTP redirects: ${escapeMarkdownInline(formatReportValue(snap.httpRedirectCount))}`);
355
+ if (snap.httpHttpsDowngrade === true)
356
+ lines.push('- HTTPS downgrade: Detected');
357
+ if (Array.isArray(snap.httpSecurityHeaders)) {
358
+ const labels = snap.httpSecurityHeaders.map(httpSecurityHeaderLabel);
359
+ lines.push(`- Observed security headers: ${escapeMarkdownInline(labels.length ? labels.join(', ') : 'None')}`);
360
+ }
361
+ if (snap.pageTitle) {
362
+ lines.push(`- Page title: ${escapeMarkdownInline(formatReportValue(snap.pageTitle))}`);
363
+ }
364
+ if (Array.isArray(snap.nameservers) && snap.nameservers.length > 0) {
365
+ lines.push(`- Nameservers: ${escapeMarkdownInline(snap.nameservers.join(', '))}`);
366
+ }
367
+ if (snap.hasMx !== null && snap.hasMx !== undefined) {
368
+ lines.push(`- MX: ${escapeMarkdownInline(formatReportValue(snap.hasMx))}`);
369
+ }
370
+ if (snap.hasSpf !== null && snap.hasSpf !== undefined) {
371
+ lines.push(`- SPF: ${escapeMarkdownInline(formatReportValue(snap.hasSpf))}`);
372
+ }
373
+ if (snap.hasDmarc !== null && snap.hasDmarc !== undefined) {
374
+ lines.push(`- DMARC: ${escapeMarkdownInline(formatReportValue(snap.hasDmarc))}`);
375
+ }
376
+ if (snap.faviconMatch !== null && snap.faviconMatch !== undefined) {
377
+ lines.push(`- Official favicon match: ${escapeMarkdownInline(formatReportValue(snap.faviconMatch))}`);
378
+ }
379
+ if (Array.isArray(snap.mutationTypes) && snap.mutationTypes.length > 0) {
380
+ lines.push(`- Mutation types: ${escapeMarkdownInline(snap.mutationTypes.join(', '))}`);
381
+ }
382
+ if (Array.isArray(snap.riskFactors) && snap.riskFactors.length > 0) {
383
+ lines.push(`- Risk factors: ${escapeMarkdownInline(formatReportValue(snap.riskFactors, 'riskFactors'))}`);
384
+ }
385
+ lines.push('');
386
+ // Material changes
387
+ if (entry.changes && entry.changes.length > 0) {
388
+ lines.push('**Material changes from previous observation:**');
389
+ lines.push('');
390
+ for (const change of entry.changes) {
391
+ const beforeText = formatReportValue(change.before, change.field);
392
+ const afterText = formatReportValue(change.after, change.field);
393
+ lines.push(`- **${escapeMarkdownInline(change.label)}** (${escapeMarkdownInline(change.tone)}): ${escapeMarkdownInline(beforeText)} → ${escapeMarkdownInline(afterText)}`);
394
+ }
395
+ lines.push('');
396
+ }
397
+ if (entry.hasIncomparableChange) {
398
+ const reasons = Array.isArray(entry.incomparableReasons) ? entry.incomparableReasons : [];
399
+ if (reasons.includes('risk-model'))
400
+ lines.push('> Risk scores and factors use different or unversioned models, so their numeric difference is not treated as a domain change.');
401
+ if (reasons.includes('opportunity-model'))
402
+ lines.push('> Opportunity scores and factors use different or unversioned models, so their numeric difference is not treated as a domain change.');
403
+ if (reasons.includes('scan-depth'))
404
+ lines.push('> Capture depths differ, so unevaluated deep signals are not treated as additions or removals.');
405
+ if (reasons.length === 0 || reasons.includes('other'))
406
+ lines.push('> The observations differ materially, but no reliable field-level comparison is available.');
407
+ lines.push('');
408
+ }
409
+ }
410
+ }
411
+ lines.push('## Analyst Decision Packet');
412
+ lines.push('');
413
+ const response = report.analystResponse;
414
+ if (!response.evidencePins.length && !response.sightings.length && !response.decisions.length && !response.actions.length && !response.assertions.length && !response.manualTrail.length) {
415
+ lines.push('No evidence pins, source-qualified sightings, structured assertions, decision records, case actions, or manual investigation steps recorded.');
416
+ lines.push('');
417
+ }
418
+ else {
419
+ lines.push('### Evidence pins');
420
+ lines.push('');
421
+ if (!response.evidencePins.length)
422
+ lines.push('No evidence pins recorded.');
423
+ for (const pin of response.evidencePins) {
424
+ lines.push(`- **${escapeMarkdownInline(pin.label)}:** ${escapeMarkdownInline(pin.value)}`);
425
+ lines.push(` Source: ${escapeMarkdownInline(pin.source)}; observed ${escapeMarkdownInline(pin.observedAt)}; completeness ${escapeMarkdownInline(pin.completeness)}.`);
426
+ if (pin.limitations.length)
427
+ lines.push(` Limitations: ${escapeMarkdownInline(pin.limitations.join('; '))}`);
428
+ }
429
+ lines.push('');
430
+ lines.push('### Source-qualified sightings');
431
+ lines.push('');
432
+ if (!response.sightings.length)
433
+ lines.push('No source-qualified sightings recorded.');
434
+ for (const sighting of response.sightings) {
435
+ lines.push(`- **${escapeMarkdownInline(sighting.state.replaceAll('_', ' '))}:** ${escapeMarkdownInline(sighting.category)} (${escapeMarkdownInline(sighting.observedAt)})`);
436
+ lines.push(` Source: ${escapeMarkdownInline(sighting.source)}; class ${escapeMarkdownInline(sighting.sourceClass)}; completeness ${escapeMarkdownInline(sighting.completeness)}.`);
437
+ if (sighting.evidencePinId)
438
+ lines.push(` Evidence pin: ${escapeMarkdownInline(sighting.evidencePinId)}`);
439
+ if (sighting.limitations.length)
440
+ lines.push(` Limitations: ${escapeMarkdownInline(sighting.limitations.join('; '))}`);
441
+ }
442
+ lines.push('');
443
+ for (const section of [
444
+ { kind: 'verified_fact', heading: 'Verified facts' },
445
+ { kind: 'hypothesis', heading: 'Hypotheses' },
446
+ { kind: 'unknown', heading: 'Unknowns' },
447
+ { kind: 'contradiction', heading: 'Contradictory evidence' },
448
+ { kind: 'next_step', heading: 'Recommended next manual steps' },
449
+ ]) {
450
+ lines.push(`### ${section.heading}`);
451
+ lines.push('');
452
+ const assertions = response.assertions.filter((item) => item.kind === section.kind);
453
+ if (!assertions.length)
454
+ lines.push(`No ${section.heading.toLowerCase()} recorded.`);
455
+ for (const assertion of assertions) {
456
+ lines.push(`- **${escapeMarkdownInline(assertion.state)}:** ${escapeMarkdownInline(assertion.statement)}`);
457
+ if (assertion.rationale)
458
+ lines.push(` ${escapeMarkdownInline(assertion.rationale)}`);
459
+ if (assertion.provenance) {
460
+ lines.push(` External provenance: ${escapeMarkdownInline(assertion.provenance.format.toUpperCase())}; source ${escapeMarkdownInline(assertion.provenance.sourceName)}; file SHA-256 ${escapeMarkdownInline(assertion.provenance.sourceDigestSha256)}.`);
461
+ if (assertion.provenance.publisher)
462
+ lines.push(` Publisher: ${escapeMarkdownInline(assertion.provenance.publisher)}`);
463
+ if (assertion.provenance.externalId)
464
+ lines.push(` External ID: ${escapeMarkdownInline(assertion.provenance.externalId)}`);
465
+ if (assertion.provenance.observedAt)
466
+ lines.push(` Externally reported observation time: ${escapeMarkdownInline(assertion.provenance.observedAt)}`);
467
+ if (assertion.provenance.labels.length)
468
+ lines.push(` Labels: ${escapeMarkdownInline(assertion.provenance.labels.join(', '))}`);
469
+ if (assertion.provenance.markings.length)
470
+ lines.push(` Markings: ${escapeMarkdownInline(assertion.provenance.markings.join(', '))}`);
471
+ }
472
+ if (assertion.evidencePinIds.length)
473
+ lines.push(` Evidence pins: ${escapeMarkdownInline(assertion.evidencePinIds.join(', '))}`);
474
+ if (assertion.evidenceRelations?.length) {
475
+ for (const stance of CASE_EVIDENCE_RELATION_STANCES) {
476
+ const pins = assertion.evidenceRelations.filter((item) => item.stance === stance).map((item) => item.evidencePinId);
477
+ if (pins.length)
478
+ lines.push(` ${escapeMarkdownInline(stance)}: ${escapeMarkdownInline(pins.join(', '))}`);
479
+ }
480
+ }
481
+ }
482
+ lines.push('');
483
+ }
484
+ lines.push('### Decisions');
485
+ lines.push('');
486
+ if (!response.decisions.length)
487
+ lines.push('No decision records recorded.');
488
+ for (const decision of response.decisions) {
489
+ lines.push(`- **${escapeMarkdownInline(decision.summary)}** (${escapeMarkdownInline(decision.createdAt)})`);
490
+ lines.push(` ${escapeMarkdownInline(decision.rationale)}`);
491
+ if (decision.evidencePinIds.length)
492
+ lines.push(` Evidence pins: ${escapeMarkdownInline(decision.evidencePinIds.join(', '))}`);
493
+ }
494
+ lines.push('');
495
+ lines.push('### Actions and outcomes');
496
+ lines.push('');
497
+ if (!response.actions.length)
498
+ lines.push('No case actions recorded.');
499
+ for (const action of response.actions) {
500
+ lines.push(`- **${escapeMarkdownInline(action.type.replaceAll('_', ' '))}:** ${escapeMarkdownInline(action.recipient)} (${escapeMarkdownInline(action.state)})`);
501
+ lines.push(` Contact source: ${escapeMarkdownInline(action.contactSource)}; updated ${escapeMarkdownInline(action.updatedAt)}.`);
502
+ if (action.dueAt)
503
+ lines.push(` Due: ${escapeMarkdownInline(action.dueAt)}`);
504
+ if (action.followUpAt)
505
+ lines.push(` Follow-up: ${escapeMarkdownInline(action.followUpAt)}`);
506
+ if (action.reference)
507
+ lines.push(` Reference: ${escapeMarkdownInline(action.reference)}`);
508
+ if (action.outcome)
509
+ lines.push(` Outcome: ${escapeMarkdownInline(action.outcome)}`);
510
+ if (action.contactLimitations.length)
511
+ lines.push(` Contact limitations: ${escapeMarkdownInline(action.contactLimitations.join('; '))}`);
512
+ }
513
+ lines.push('');
514
+ lines.push('### Explicit investigation trail');
515
+ lines.push('');
516
+ if (!response.manualTrail.length)
517
+ lines.push('No manual pivots, handoffs, or review steps recorded. Browser navigation is not tracked.');
518
+ for (const item of response.manualTrail) {
519
+ lines.push(`- **${escapeMarkdownInline(item.kind)}** (${escapeMarkdownInline(item.createdAt)}): ${escapeMarkdownInline(item.summary)}`);
520
+ if (item.target)
521
+ lines.push(` Target: ${escapeMarkdownInline(item.target)}`);
522
+ }
523
+ lines.push('');
524
+ }
525
+ // Analyst notes (only when included)
526
+ if (report.case.notesIncluded && Array.isArray(report.case.notes) && report.case.notes.length > 0) {
527
+ lines.push('## Analyst Notes');
528
+ lines.push('');
529
+ lines.push('> **Warning:** Notes may contain sensitive investigation detail. Review before sharing.');
530
+ lines.push('');
531
+ for (const note of report.case.notes) {
532
+ lines.push(`**${escapeMarkdownInline(note.createdAt)}**`);
533
+ lines.push('');
534
+ lines.push(escapeMarkdownNote(note.body));
535
+ lines.push('');
536
+ }
537
+ }
538
+ // Limitations
539
+ lines.push('---');
540
+ lines.push('');
541
+ lines.push('## Limitations & Provenance');
542
+ lines.push('');
543
+ lines.push(LIMITATIONS_TEXT);
544
+ lines.push('');
545
+ return lines.join('\n');
546
+ }
547
+ // ---------------------------------------------------------------------------
548
+ // Filename generation
549
+ // ---------------------------------------------------------------------------
550
+ /**
551
+ * Produces a safe, bounded filesystem name for a case report download.
552
+ * Contains no path separators, control characters, or unbounded user content.
553
+ * @param {string} domain - the case domain
554
+ * @param {'json' | 'md'} format
555
+ * @param {string} [generatedAt] - ISO timestamp (injectable for tests)
556
+ * @returns {string}
557
+ */
558
+ export function caseReportFilename(domain, format, generatedAt) {
559
+ const safeDomain = String(domain || '')
560
+ .toLowerCase()
561
+ .replace(/[^a-z0-9.-]+/g, '-')
562
+ .replace(/\.{2,}/g, '.')
563
+ .replace(/^[.-]+|[.-]+$/g, '')
564
+ .slice(0, 80) || 'case';
565
+ const ext = format === 'md' ? 'md' : 'json';
566
+ const ts = String(generatedAt || new Date().toISOString());
567
+ const safeTs = ts
568
+ .replace(/[^a-zA-Z0-9-]+/g, '-')
569
+ .replace(/-+/g, '-')
570
+ .replace(/^-+|-+$/g, '')
571
+ .slice(0, 48) || 'undated';
572
+ return `whoisleuth-case-${safeDomain}-${safeTs}.${ext}`;
573
+ }
@@ -20,6 +20,7 @@ export const MAX_DECISION_PIN_REFERENCES = 20;
20
20
  export const MAX_TRAIL_TARGET_LENGTH = 500;
21
21
  export const MAX_ASSERTION_PROVENANCE_LABELS = 20;
22
22
  export const MAX_ASSERTION_PROVENANCE_MARKINGS = 12;
23
+ export const CASE_EVIDENCE_RELATION_STANCES = ['supports', 'contradicts', 'unresolved'];
23
24
  export const CASE_PIN_COMPLETENESS = ['complete', 'partial', 'inconclusive', 'unknown'];
24
25
  export const CASE_TRANSITION_EXPECTATIONS = ['preserve', 'change', 'review'];
25
26
  export const CASE_ACTION_TYPES = [
@@ -82,6 +83,7 @@ const ACTION_TYPES = new Set(CASE_ACTION_TYPES);
82
83
  const ACTION_STATES = new Set(CASE_ACTION_STATES);
83
84
  const ASSERTION_KINDS = new Set(CASE_ASSERTION_KINDS);
84
85
  const ASSERTION_STATES = new Set(CASE_ASSERTION_STATES);
86
+ const EVIDENCE_RELATION_STANCES = new Set(CASE_EVIDENCE_RELATION_STANCES);
85
87
  const ASSERTION_EXTERNAL_FORMATS = new Set(CASE_ASSERTION_EXTERNAL_FORMATS);
86
88
  const ASSERTION_EXTERNAL_ENTITY_TYPES = new Set(CASE_ASSERTION_EXTERNAL_ENTITY_TYPES);
87
89
  const TRAIL_KINDS = new Set(CASE_MANUAL_TRAIL_KINDS);
@@ -170,6 +172,30 @@ function uniqueIds(value, validIds) {
170
172
  }
171
173
  return [...unique];
172
174
  }
175
+ function normalizeEvidenceRelations(value, legacyIds, validIds) {
176
+ const output = new Map();
177
+ if (Array.isArray(value)) {
178
+ for (const raw of value.slice(0, MAX_DECISION_PIN_REFERENCES * 2)) {
179
+ const item = record(raw);
180
+ const evidencePinId = typeof item.evidencePinId === 'string' && SAFE_ID_RE.test(item.evidencePinId)
181
+ ? item.evidencePinId
182
+ : '';
183
+ if (!evidencePinId || (validIds && !validIds.has(evidencePinId)))
184
+ continue;
185
+ if (typeof item.stance !== 'string' || !EVIDENCE_RELATION_STANCES.has(item.stance))
186
+ continue;
187
+ if (!output.has(evidencePinId))
188
+ output.set(evidencePinId, item.stance);
189
+ if (output.size >= MAX_DECISION_PIN_REFERENCES)
190
+ break;
191
+ }
192
+ }
193
+ if (!output.size) {
194
+ for (const evidencePinId of legacyIds)
195
+ output.set(evidencePinId, 'supports');
196
+ }
197
+ return [...output].map(([evidencePinId, stance]) => ({ evidencePinId, stance }));
198
+ }
173
199
  function normalizePin(raw, fallback) {
174
200
  const item = record(raw);
175
201
  const label = text(item.label, MAX_RESPONSE_LABEL_LENGTH);
@@ -430,6 +456,8 @@ function normalizeAssertion(raw, fallback, validPinIds) {
430
456
  return null;
431
457
  const createdAt = iso(item.createdAt, fallback);
432
458
  const provenance = normalizeAssertionProvenance(item.provenance);
459
+ const legacyIds = uniqueIds(item.evidencePinIds, validPinIds);
460
+ const evidenceRelations = normalizeEvidenceRelations(item.evidenceRelations, legacyIds, validPinIds);
433
461
  return {
434
462
  id: safeId(item.id, 'assertion', { statement, createdAt }),
435
463
  kind: typeof item.kind === 'string' && ASSERTION_KINDS.has(item.kind)
@@ -437,7 +465,8 @@ function normalizeAssertion(raw, fallback, validPinIds) {
437
465
  : 'hypothesis',
438
466
  statement,
439
467
  rationale: text(item.rationale, MAX_RESPONSE_RATIONALE_LENGTH) || null,
440
- evidencePinIds: uniqueIds(item.evidencePinIds, validPinIds),
468
+ evidencePinIds: evidenceRelations.map((relation) => relation.evidencePinId),
469
+ evidenceRelations,
441
470
  state: typeof item.state === 'string' && ASSERTION_STATES.has(item.state)
442
471
  ? item.state
443
472
  : 'open',