@slicedearth/whoisleuth-cli 2.6.0 → 2.7.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 (84) hide show
  1. package/DISCLOSURE +8 -5
  2. package/NOTICE +3 -2
  3. package/README.md +60 -67
  4. package/cli/artifact-validation/case-response.mjs +6 -857
  5. package/cli/artifact-validation/lookup-evidence.mjs +15 -2
  6. package/cli/artifact-verify.mjs +32 -1
  7. package/cli/case-command.mjs +17 -17
  8. package/cli/case-pack.mjs +7 -1
  9. package/cli/ci-report.mjs +17 -5
  10. package/cli/command-definition.mjs +5 -5
  11. package/cli/doctor.mjs +37 -3
  12. package/cli/formatters/html.mjs +3 -0
  13. package/cli/formatters/json.mjs +8 -1
  14. package/cli/formatters/terminal-lookup.mjs +24 -1
  15. package/cli/history-command-definitions.mjs +1 -1
  16. package/cli/investigation-plan.mjs +15 -0
  17. package/cli/lookup-brief.mjs +11 -5
  18. package/cli/lookup-command-runner.mjs +20 -13
  19. package/cli/review-command-definitions.mjs +11 -11
  20. package/cli/saved-lookup.mjs +22 -7
  21. package/cli/source-reliability.mjs +12 -10
  22. package/cli/support-arguments.mjs +1 -1
  23. package/cli/support-command-definitions.mjs +5 -4
  24. package/cli/support-command-runner.mjs +6 -4
  25. package/cli/workflow-command-definitions.mjs +2 -1
  26. package/docs/cli-reference.md +49 -11
  27. package/docs/cli.md +109 -32
  28. package/docs/contextual-reviews.md +10 -0
  29. package/docs/infrastructure-observations.md +9 -0
  30. package/lib/dmarc-discovery.mjs +4 -2
  31. package/lib/dns-inheritance-review.mjs +14 -1
  32. package/lib/evidence-export.mjs +10 -4
  33. package/lib/evidence-report.mjs +2 -1
  34. package/lib/generated/cisa-kev-catalog.mjs +17 -4
  35. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  36. package/lib/generated/retire-browser-catalog.mjs +178 -5
  37. package/lib/idn-confusables.mjs +5 -1
  38. package/lib/lookup-collection-receipt.mjs +52 -0
  39. package/lib/lookup-diagnostics.mjs +2 -12
  40. package/lib/lookup-progress.mjs +29 -31
  41. package/lib/lookup-response-contract.mjs +9 -0
  42. package/lib/lookup-response.mjs +15 -14
  43. package/lib/lookup-source-progress.mjs +4 -2
  44. package/lib/lookup.mjs +33 -2
  45. package/lib/rdap-capabilities.mjs +43 -9
  46. package/lib/registry-insights.mjs +17 -6
  47. package/lib/sslbl-certificates.generated.mjs +143 -142
  48. package/lib/whois-authority.mjs +56 -1
  49. package/lib/whois-chain.mjs +2 -1
  50. package/lib/whois-contracts.mjs +2 -0
  51. package/lib/whois-normalization.mjs +4 -4
  52. package/package.json +6 -6
  53. package/packages/analysis/bounded-json.mjs +12 -2
  54. package/packages/cases/case-attachment-model.mjs +36 -1
  55. package/packages/cases/case-checkpoint-receipts.mjs +68 -0
  56. package/packages/cases/case-evidence-links.mjs +28 -0
  57. package/packages/cases/case-evidence-model.mjs +39 -6
  58. package/packages/cases/case-migration-model.mjs +9 -1
  59. package/packages/cases/case-object-coverage.mjs +106 -20
  60. package/packages/cases/case-record-operations.mjs +13 -0
  61. package/packages/cases/case-record-projection.mjs +28 -2
  62. package/packages/cases/case-report.mjs +5 -4
  63. package/packages/cases/case-response-packet-validation.mjs +880 -0
  64. package/packages/cases/case-response-packet-vocabulary.mjs +12 -1
  65. package/packages/cases/case-response-packet.mjs +2 -15
  66. package/packages/cases/case-response-review-inputs.mjs +18 -1
  67. package/packages/cases/case-type-presentation.mjs +10 -0
  68. package/packages/cases/case-workflow-metadata.mjs +0 -8
  69. package/packages/comparison/capture-context.mjs +3 -2
  70. package/packages/contracts/analyst-review-state-contract.mjs +1 -1
  71. package/packages/contracts/case-portability.mjs +59 -28
  72. package/packages/contracts/cli-doctor.mjs +4 -0
  73. package/packages/contracts/cli-lookup.mjs +57 -46
  74. package/packages/contracts/lookup-evidence.mjs +12 -2
  75. package/packages/contracts/registry-insights.mjs +6 -0
  76. package/packages/evidence/dmarc-policy-context.mjs +80 -0
  77. package/packages/evidence/lookup-collection-receipt.mjs +140 -0
  78. package/packages/evidence/lookup-collection-review.mjs +74 -0
  79. package/packages/interchange/external-findings-import.mjs +8 -4
  80. package/packages/investigation/bulk-mail-exposure.mjs +2 -2
  81. package/packages/investigation/domain-history-review.mjs +7 -1
  82. package/packages/workspace/brand-keyword-campaign.mjs +11 -1
  83. package/packages/workspace/bulk-session-model.mjs +8 -0
  84. package/third-party-notices.txt +29 -29
@@ -1,33 +1,119 @@
1
- import { retainedCaseResponseObjects, sameCaseResponseObject } from "./case-response-object.mjs";
2
- export function buildCaseIncidentCoverage(record) {
1
+ import { retainedCaseResponseObjects, sameCaseResponseObject, } from "./case-response-object.mjs";
2
+ import { correctionDelivery } from "./case-packet-correction.mjs";
3
+ export function caseIncidentObjects(record) {
3
4
  const objects = new Map();
4
5
  const explicit = retainedCaseResponseObjects(record);
5
6
  for (const target of record.workflowMetadata?.incidentTargets ?? []) {
6
- const matching = explicit.filter(object => object.incidentTargetId === target.id && object.identifier === target.url);
7
- for (const object of matching.length ? matching : [{ kind: 'other', identifier: target.url, incidentTargetId: target.id }])
7
+ const matching = explicit.filter((object) => object.incidentTargetId === target.id && object.identifier === target.url);
8
+ for (const object of matching.length
9
+ ? matching
10
+ : [{ kind: 'other', identifier: target.url, incidentTargetId: target.id }])
8
11
  objects.set(JSON.stringify(object), { target, object, targetRetained: true });
9
12
  }
10
13
  for (const object of explicit)
11
14
  if (!objects.has(JSON.stringify(object)))
12
15
  objects.set(JSON.stringify(object), {
13
- object, targetRetained: false,
14
- target: { id: object.incidentTargetId ?? `${object.kind}-${object.identifier}`, url: object.identifier, state: 'open', createdAt: record.createdAt ?? '', updatedAt: record.updatedAt ?? '' },
16
+ object,
17
+ targetRetained: false,
18
+ target: {
19
+ id: object.incidentTargetId ?? `${object.kind}-${object.identifier}`,
20
+ url: object.identifier,
21
+ state: 'open',
22
+ createdAt: record.createdAt ?? '',
23
+ updatedAt: record.updatedAt ?? '',
24
+ },
15
25
  });
16
- return [...objects.values()].map(({ target, object, targetRetained }) => {
17
- const actions = (record.actions ?? []).filter(action => action.responseObjects?.some(bound => sameCaseResponseObject(bound, object)));
18
- const reviews = (record.observedEffects?.reviews ?? []).filter(review => sameCaseResponseObject(review.responseObject, object));
19
- const latestAt = Math.max(...reviews.map(review => Date.parse(review.observedAt)));
20
- const latest = reviews.filter(review => Date.parse(review.observedAt) === latestAt);
21
- return { target, targetRetained, responseObject: object,
22
- hostname: object.kind === 'domain' || object.kind === 'hostname' ? object.identifier : new URL(object.identifier).hostname,
26
+ return [...objects.values()];
27
+ }
28
+ export function buildCaseIncidentCoverage(record) {
29
+ return caseIncidentObjects(record).map(({ target, object, targetRetained }) => {
30
+ const contexts = (record.actions ?? [])
31
+ .map((action) => ({
32
+ action,
33
+ currentlyBound: Boolean(action.responseObjects?.some((bound) => sameCaseResponseObject(bound, object))),
34
+ events: action.history.filter((event) => event.responseObjects?.some((bound) => sameCaseResponseObject(bound, object))),
35
+ }))
36
+ .filter((context) => context.currentlyBound || context.events.length);
37
+ const actions = contexts
38
+ .filter((context) => context.currentlyBound)
39
+ .map((context) => context.action);
40
+ const responseContexts = contexts.map(({ action, currentlyBound, events }) => {
41
+ let correction = null;
42
+ if (currentlyBound && action.correction) {
43
+ let linkAvailable = false;
44
+ try {
45
+ linkAvailable = Boolean(correctionDelivery(record.actions ?? [], action));
46
+ }
47
+ catch {
48
+ /* Historical linkage can be unavailable; the draft is not re-authorised here. */
49
+ }
50
+ correction = {
51
+ purpose: action.correction.purpose,
52
+ originActionId: action.originActionId,
53
+ deliveryEventId: action.correction.deliveryEventId,
54
+ packetDigestSha256: action.correction.packetDigestSha256,
55
+ packetVersion: action.correction.packetVersion,
56
+ linkAvailable,
57
+ };
58
+ }
59
+ return {
60
+ actionId: action.id,
61
+ type: action.type,
62
+ recipient: action.recipient,
63
+ contactSource: action.contactSource,
64
+ routeObservedAt: action.routeObservedAt,
65
+ routeReviewAfter: action.routeReviewAfter,
66
+ contactLimitations: action.contactLimitations,
67
+ metadataUpdatedAt: action.metadataUpdatedAt,
68
+ currentState: action.state,
69
+ currentlyBound,
70
+ historyOmitted: action.historyOmitted,
71
+ historyLimitations: action.historyLimitations,
72
+ reviewEvents: events.filter((event) => event.sourceClass === 'analyst' &&
73
+ (event.nextState === 'reviewed' || event.nextState === 'authorised')),
74
+ // A repeated submitted-state update alone does not establish another delivery.
75
+ deliveryEvents: events.filter((event) => event.sourceClass === 'analyst' &&
76
+ event.nextState === 'submitted' &&
77
+ (event.previousState === 'authorised' || Boolean(event.packetReceipt))),
78
+ correction,
79
+ };
80
+ });
81
+ const responseEvents = contexts.flatMap(({ action, events }) => events
82
+ .filter((event) => event.providerOutcome || event.objectOutcome || event.nextState === 'acknowledged')
83
+ .map((event) => ({
84
+ actionId: action.id,
85
+ eventId: event.id,
86
+ state: event.nextState,
87
+ outcome: event.objectOutcome ?? event.providerOutcome,
88
+ occurredAt: event.occurredAt,
89
+ sourceClass: event.sourceClass,
90
+ applied: event.applied,
91
+ provenance: event.provenance,
92
+ reference: event.reference,
93
+ })));
94
+ const reviews = (record.observedEffects?.reviews ?? []).filter((review) => sameCaseResponseObject(review.responseObject, object));
95
+ const latestAt = Math.max(...reviews.map((review) => Date.parse(review.observedAt)));
96
+ const latest = reviews.filter((review) => Date.parse(review.observedAt) === latestAt);
97
+ return {
98
+ target,
99
+ targetRetained,
100
+ responseObject: object,
101
+ hostname: object.kind === 'domain' || object.kind === 'hostname'
102
+ ? object.identifier
103
+ : new URL(object.identifier).hostname,
23
104
  actionCoverage: actions.length ? 'bound' : 'unknown',
24
- actionIds: actions.map(action => action.id),
25
- providerEvents: (record.actions ?? []).flatMap(action => action.history.filter(event => event.applied
26
- && event.responseObjects?.some(bound => sameCaseResponseObject(bound, object))
27
- && (event.providerOutcome || event.objectOutcome || event.nextState === 'acknowledged'))
28
- .map(event => ({ actionId: action.id, eventId: event.id, state: event.nextState, outcome: event.objectOutcome ?? event.providerOutcome, occurredAt: event.occurredAt, sourceClass: event.sourceClass }))),
29
- observationCoverage: !reviews.length ? 'unknown' : new Set(latest.map(review => `${review.state}\u0000${review.objectOutcome ?? ''}`)).size > 1 ? 'ambiguous' : 'available',
105
+ actionIds: actions.map((action) => action.id),
106
+ responseContexts,
107
+ providerEvents: responseEvents.filter((event) => event.applied),
108
+ unappliedProviderEvents: responseEvents.filter((event) => !event.applied),
109
+ observationCoverage: !reviews.length
110
+ ? 'unknown'
111
+ : new Set(latest.map((review) => `${review.state}\u0000${review.objectOutcome ?? ''}`))
112
+ .size > 1
113
+ ? 'ambiguous'
114
+ : 'available',
30
115
  reviews,
31
- closures: (record.closures?.records ?? []).filter(closure => sameCaseResponseObject(closure.responseObject, object)), };
116
+ closures: (record.closures?.records ?? []).filter((closure) => sameCaseResponseObject(closure.responseObject, object)),
117
+ };
32
118
  });
33
119
  }
@@ -10,6 +10,7 @@ import { appendCaseAction, appendCaseAssertion, appendCaseClosure, appendCaseDec
10
10
  import { MAX_CASES, MAX_NOTES_PER_CASE, } from "./case-record-contracts.mjs";
11
11
  import { MAX_CASE_OBJECTIVE_LENGTH, MAX_RESPONSE_VALUE_LENGTH } from "../contracts/case-portability.mjs";
12
12
  import { caseRecordVersionInput } from "./case-record-version-input.mjs";
13
+ import { readCaseCheckpointReceipts, mergeCaseCheckpointReceipts } from "./case-checkpoint-receipts.mjs";
13
14
  import { readCaseAttachments } from "./case-attachment-model.mjs";
14
15
  import { readCaseEvidenceLinks, appendCaseEvidenceLink, withdrawCaseEvidenceLink } from "./case-evidence-links.mjs";
15
16
  import { assertCurrentRecheckQuestion, assertRecheckNonReproduction, assertCaseObjectObservationOutcome, readCaseRecheckAnswerContext } from "./case-recheck-model.mjs";
@@ -66,6 +67,7 @@ export function normalizeCase(raw, existing, nowIso, sourceVersion) {
66
67
  const createdAt = existing ? existing.createdAt : caseTimestampOrNull(record.createdAt, sourceVersion) || now;
67
68
  const updatedAt = caseTimestampOrNull(record.updatedAt, sourceVersion) || createdAt;
68
69
  const evidencePins = normalizeCaseEvidencePins(record.evidencePins, updatedAt, timestampOptions);
70
+ const checkpointCollectionReceipts = readCaseCheckpointReceipts(record.checkpointCollectionReceipts, evidencePins, sourceVersion);
69
71
  const pinIds = new Set(evidencePins.map((item) => item.id));
70
72
  const actions = normalizeCaseActions(record.actions, updatedAt, { ...timestampOptions, validEvidencePinIds: pinIds });
71
73
  for (const action of actions)
@@ -98,6 +100,7 @@ export function normalizeCase(raw, existing, nowIso, sourceVersion) {
98
100
  source: normalizeSource(record.source),
99
101
  evidenceHistory: normalizeCaseEvidence(record, domain, createdAt, updatedAt, now, sourceVersion),
100
102
  evidencePins,
103
+ ...(checkpointCollectionReceipts === undefined ? {} : { checkpointCollectionReceipts }),
101
104
  decisions: normalizeCaseDecisions(record.decisions, updatedAt, pinIds, timestampOptions),
102
105
  actions,
103
106
  assertions,
@@ -140,6 +143,7 @@ export function createCase(input, nowIso) {
140
143
  const actions = input.action !== undefined
141
144
  ? appendCaseAction([], input.action, now)
142
145
  : [];
146
+ const checkpointCollectionReceipts = readCaseCheckpointReceipts(input.checkpointCollectionReceipts, evidencePins);
143
147
  const assertions = input.assertion !== undefined
144
148
  ? appendCaseAssertion([], input.assertion, now)
145
149
  : [];
@@ -186,6 +190,7 @@ export function createCase(input, nowIso) {
186
190
  caseDomain: domain,
187
191
  }),
188
192
  evidencePins,
193
+ ...(checkpointCollectionReceipts === undefined ? {} : { checkpointCollectionReceipts }),
189
194
  decisions: input.decision !== undefined
190
195
  ? appendCaseDecision([], input.decision, now)
191
196
  : [],
@@ -318,6 +323,11 @@ export function updateCase(cases, id, patch, nowIso) {
318
323
  if (patch.evidencePin !== undefined) {
319
324
  evidencePins = appendCaseEvidencePin(evidencePins, patch.evidencePin, now);
320
325
  }
326
+ const previousPinIds = new Set(current.evidencePins.map(pin => pin.id));
327
+ const checkpointCollectionReceipts = mergeCaseCheckpointReceipts(current, {
328
+ evidencePins: evidencePins.filter(pin => !previousPinIds.has(pin.id)),
329
+ checkpointCollectionReceipts: patch.checkpointCollectionReceipts,
330
+ }, evidencePins);
321
331
  const pinIds = new Set(evidencePins.map((item) => item.id));
322
332
  const correctionInput = patch.action !== undefined ? objectRecord(patch.action) : patch.actionUpdate !== undefined
323
333
  ? { ...current.actions.find(action => action.id === objectRecord(patch.actionUpdate).id), ...objectRecord(patch.actionUpdate) } : null;
@@ -416,6 +426,7 @@ export function updateCase(cases, id, patch, nowIso) {
416
426
  source,
417
427
  evidenceHistory,
418
428
  evidencePins,
429
+ ...(checkpointCollectionReceipts === undefined ? {} : { checkpointCollectionReceipts }),
419
430
  ...((patch.evidenceLink !== undefined || patch.evidenceLinkWithdrawal !== undefined) ? {
420
431
  evidenceLinks: patch.evidenceLink !== undefined
421
432
  ? appendCaseEvidenceLink(current.evidenceLinks ?? [], patch.evidenceLink, evidencePins, now)
@@ -433,6 +444,8 @@ export function updateCase(cases, id, patch, nowIso) {
433
444
  updatedAt: now,
434
445
  };
435
446
  const next = [...cases];
447
+ if (checkpointCollectionReceipts === undefined)
448
+ delete record.checkpointCollectionReceipts;
436
449
  next[index] = record;
437
450
  return { cases: next, record };
438
451
  }
@@ -1,5 +1,20 @@
1
- import { PUBLIC_CASE_SCHEMA_VERSION, INCIDENT_CASE_SCHEMA_VERSION, EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
1
+ import { COLLECTION_RECEIPT_CASE_SCHEMA_VERSION, PUBLIC_CASE_SCHEMA_VERSION, INCIDENT_CASE_SCHEMA_VERSION, EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION, MAX_CASE_IMPORT_BYTES } from "../contracts/case-portability.mjs";
2
2
  import { caseStatusIsClosed } from "./case-record-decisions.mjs";
3
+ import { caseEvidenceWithoutCollectionReceipts } from "./case-evidence-model.mjs";
4
+ import { assertBoundedJsonStructure, boundedJsonLimitsForBytes, scanBoundedJson } from "../analysis/bounded-json.mjs";
5
+ /** Check the whole shared envelope, including otherwise unrecognised nesting.
6
+ * Writer projection alone cannot enforce the audience of an imported pack. */
7
+ export function assertCaseCollectionReceiptAudience(root, audience) {
8
+ if (audience === 'internal')
9
+ return;
10
+ const limits = boundedJsonLimitsForBytes(MAX_CASE_IMPORT_BYTES);
11
+ assertBoundedJsonStructure(root, 'Shared Case pack', limits);
12
+ scanBoundedJson(JSON.stringify(root), limits, entry => {
13
+ if (entry.kind === 'key' && (entry.value === 'collectionReceipt' || entry.value === 'checkpointCollectionReceipts')) {
14
+ throw new TypeError('Shared Case packs cannot contain private collection receipts.');
15
+ }
16
+ });
17
+ }
3
18
  const PRESERVE = Object.freeze({
4
19
  durable: 'preserve', internal: 'preserve', trusted: 'preserve', public: 'preserve',
5
20
  });
@@ -101,13 +116,24 @@ const CASE_FIELD_RULES = Object.freeze({
101
116
  },
102
117
  }),
103
118
  source: preservedField('source'),
104
- evidenceHistory: fieldRule('evidenceHistory', PRESERVE, (record) => structuredClone(record.evidenceHistory)),
119
+ evidenceHistory: fieldRule('evidenceHistory', SHARED_REDACT, (record, profile) => (profile === 'trusted' || profile === 'public'
120
+ ? caseEvidenceWithoutCollectionReceipts(record) : structuredClone(record.evidenceHistory)), {
121
+ nestedSensitiveFields: ['collectionReceipt'],
122
+ audienceExclusions: {
123
+ trusted: { label: 'Private collection receipts', order: 4, sinceVersion: COLLECTION_RECEIPT_CASE_SCHEMA_VERSION },
124
+ public: { label: 'Private collection receipts', order: 4, sinceVersion: COLLECTION_RECEIPT_CASE_SCHEMA_VERSION },
125
+ },
126
+ }),
105
127
  evidencePins: fieldRule('evidencePins', PUBLIC_REDACT, (record, profile) => structuredClone(record.evidencePins).map(pin => {
106
128
  if (profile !== 'public')
107
129
  return pin;
108
130
  const { responseObject: _scope, infrastructureObservation: _infrastructure, ...publicPin } = pin;
109
131
  return publicPin;
110
132
  }), { nestedSensitiveFields: ['responseObject', 'infrastructureObservation'], audienceExclusions: { public: { label: 'Exact-object evidence scope and infrastructure observations', order: 4, sinceVersion: 18 } } }),
133
+ checkpointCollectionReceipts: fieldRule('checkpointCollectionReceipts', SHARED_EXCLUDE, (record, profile) => (profile === 'trusted' || profile === 'public' ? undefined : structuredClone(record.checkpointCollectionReceipts)), { audienceExclusions: {
134
+ trusted: { label: 'Private collection receipts', order: 4, sinceVersion: COLLECTION_RECEIPT_CASE_SCHEMA_VERSION },
135
+ public: { label: 'Private collection receipts', order: 4, sinceVersion: COLLECTION_RECEIPT_CASE_SCHEMA_VERSION },
136
+ } }),
111
137
  evidenceLinks: fieldRule('evidenceLinks', PUBLIC_EXCLUDE, (record, profile) => (profile === 'public' ? undefined : structuredClone(record.evidenceLinks)), { audienceExclusions: { public: { label: 'Analyst evidence relationships', order: 4, sinceVersion: EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION } } }),
112
138
  decisions: fieldRule('decisions', PRESERVE, (record) => structuredClone(record.decisions)),
113
139
  actions: fieldRule('actions', TRUSTED_REDACT_PUBLIC_EXCLUDE, (record, profile) => (profile === 'public'
@@ -8,12 +8,12 @@
8
8
  // contains raw RDAP/WHOIS material. This module deliberately excludes all raw
9
9
  // registry/web responses, contacts, cookies, screenshots, and authentication
10
10
  // data. Reports contain only the normalized case record.
11
- import { caseEvidenceTimeline, compareCaseEvidence, currentCaseEvidence, publishedCaseEvidenceTimelineForVerification } from "./case-evidence-model.mjs";
11
+ import { caseEvidenceTimeline, caseEvidenceWithoutCollectionReceipts, compareCaseEvidence, currentCaseEvidence, publishedCaseEvidenceTimelineForVerification } from "./case-evidence-model.mjs";
12
12
  import { httpSecurityHeaderLabel } from "./http-summary.mjs";
13
13
  import { webCollectionScoreLimitation } from "../evidence/collection-quality.mjs";
14
14
  import { analystInteroperabilityTags } from "../analysis/analyst-taxonomy.mjs";
15
15
  import { caseEvidenceLinkIssues } from "./case-evidence-links.mjs";
16
- import { caseTypeSummary } from "./case-workflow-metadata.mjs";
16
+ import { caseTypeSummary } from "./case-type-presentation.mjs";
17
17
  import { caseClosureHistoryQualification } from "./case-response-outcomes.mjs";
18
18
  import { buildPortableGeneratorMetadata, portableGeneratorAttribution, } from "../analysis/portable-generator.mjs";
19
19
  import { buildCaseResponseLifecycleSummary, CASE_EVIDENCE_RELATION_STANCES } from "./case-response-model.mjs";
@@ -178,9 +178,10 @@ export function buildCaseReport(caseRecord, options = {}) {
178
178
  const now = generatedAt || new Date().toISOString();
179
179
  const generator = buildPortableGeneratorMetadata(options.applicationVersion);
180
180
  // --- Build JSON report ---
181
- const timeline = caseEvidenceTimeline(caseRecord.evidenceHistory);
181
+ const sharedEvidence = { ...caseRecord, evidenceHistory: caseEvidenceWithoutCollectionReceipts(caseRecord) };
182
+ const timeline = caseEvidenceTimeline(sharedEvidence.evidenceHistory);
182
183
  const timelineEntries = projectReportTimeline(timeline);
183
- const selection = currentCaseEvidence(caseRecord);
184
+ const selection = currentCaseEvidence(sharedEvidence);
184
185
  const latest = selection.snapshot;
185
186
  const currentAssessment = latest ? pickKnownSnapshotFields(latest) : null;
186
187
  const responseLifecycle = buildCaseResponseLifecycleSummary(caseRecord);