@slicedearth/whoisleuth-cli 1.39.1 → 1.43.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.
- package/README.md +15 -0
- package/bin/whoisleuth.mjs +13 -2
- package/cli/archive-inspect.mjs +37 -2
- package/cli/arguments.mjs +361 -256
- package/cli/artifact-verify.mjs +35 -1
- package/cli/bulk-checkpoint.mjs +7 -11
- package/cli/bulk-command-runner.mjs +19 -2
- package/cli/case-pack.mjs +136 -0
- package/cli/ci-report.mjs +73 -0
- package/cli/collection-preflight.mjs +59 -0
- package/cli/command-reference.mjs +395 -0
- package/cli/completion.mjs +33 -10
- package/cli/config-profile.mjs +180 -0
- package/cli/ct-event-intake.mjs +159 -0
- package/cli/discovery-scan-command-runner.mjs +15 -1
- package/cli/domain-control-monitor.mjs +104 -0
- package/cli/domain-control-observations.mjs +235 -0
- package/cli/evidence-command-arguments.mjs +11 -0
- package/cli/evidence-command-runner.mjs +1 -0
- package/cli/external-observation-mapping.mjs +162 -0
- package/cli/fail-policy.mjs +64 -0
- package/cli/formatters/html.mjs +1 -1
- package/cli/investigation-manifest.mjs +113 -0
- package/cli/investigation-run.mjs +112 -0
- package/cli/lookup-brief.mjs +116 -0
- package/cli/lookup-command-runner.mjs +8 -1
- package/cli/lookup-diff.mjs +17 -19
- package/cli/network-command-runner.mjs +4 -1
- package/cli/offline-evidence-review.mjs +105 -8
- package/cli/open-asset-model-bridge.mjs +169 -0
- package/cli/registry-cohort.mjs +121 -0
- package/cli/registry-doctor.mjs +106 -11
- package/cli/registry-fixture-scaffold.mjs +73 -0
- package/cli/runner.mjs +368 -306
- package/docs/cli-reference.md +240 -14
- package/docs/cli.md +42 -7
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
- package/frontend/src/lib/analysis/brand-profile-model.js +46 -4
- package/frontend/src/lib/analysis/case-report.js +573 -0
- package/frontend/src/lib/analysis/case-response-model.js +30 -1
- package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
- package/frontend/src/lib/analysis/external-findings-import.js +320 -0
- package/frontend/src/lib/analysis/lookup-decision-support.js +7 -0
- package/frontend/src/lib/analysis/lookup-presentation.js +10 -20
- package/frontend/src/lib/analysis/scoring.js +1 -1
- package/frontend/src/lib/analysis/visualization-models.js +2 -3
- package/frontend/src/lib/analysis/workspace-archive.js +2 -0
- package/lib/abusech-auth.mjs +1 -1
- package/lib/availability.mjs +1 -1
- package/lib/bounded-contract-normalizers.mjs +33 -0
- package/lib/dns-convergence-review.mjs +183 -0
- package/lib/dns-delegation-health.mjs +196 -3
- package/lib/domain-change-packet.mjs +133 -0
- package/lib/domain-change-review.mjs +307 -0
- package/lib/domain-control-flight-recorder.mjs +268 -0
- package/lib/domain-portfolio-review.mjs +151 -0
- package/lib/evidence-export.mjs +1 -1
- package/lib/html-signals.mjs +5 -11
- package/lib/nameserver-preflight-review.mjs +159 -0
- package/lib/page-language-signals.mjs +124 -0
- package/lib/risk-scoring.mjs +42 -1
- package/lib/static-html-analysis.mjs +29 -1
- package/lib/trust-store-comparison.mjs +146 -0
- package/lib/website-technology.mjs +24 -10
- package/lib/zone-intent-review.mjs +534 -0
- 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:
|
|
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',
|