Chain of Custody
Maintaining forensic integrity through comprehensive audit trails.
Overview
Chain of custody is a critical component of forensic data collection. Every action in Traces is logged to create an immutable audit trail that can be used in legal proceedings.
Why Chain of Custody Matters
In legal and compliance contexts, data must be:
- Authentic - Proven to be what it claims to be
- Reliable - Collected through consistent, documented processes
- Complete - No gaps in the handling record
- Tamper-evident - Any modifications are detectable
Event Model
Every significant action creates a ChainOfCustodyEvent (table chain_of_custody_events, defined in packages/database/prisma/schema.prisma):
interface ChainOfCustodyEvent {
id: string;
collection_id: string;
event_type: ChainOfCustodyEventType; // see enum below
actor_id?: string; // FK -> users.id (nullable for system events)
actor_type: 'system' | 'analyst' | 'customer' | 'automated';
actor_ip?: string; // INET
on_behalf_of?: string; // FK -> customers.id (analyst acting for a customer)
event_data?: object; // JSONB
previous_state?: string; // collection status before the transition
new_state?: string; // collection status after the transition
timestamp: string; // ISO 8601
created_at: string;
}
This table is append-only — events are never updated or deleted. Tamper-evidence comes from this immutability plus the per-response forensic hashes recorded in api_logs and the per-file SHA-256 hashes in collected_files. Individual chain-of-custody events are not linked by a per-event hash / previous_hash blockchain; ordering is established by timestamp (and api_logs.chain_of_custody_sequence for API calls). State changes are captured via the previous_state / new_state columns rather than a cryptographic event chain.
Event Types
The ChainOfCustodyEventType enum (defined in schema.prisma) is the complete, authoritative set of event types:
Collection Lifecycle
| Event | Triggered When |
|---|---|
collection_created | Customer or analyst creates a collection |
credentials_validated | Credentials validated before collection |
collection_started | Collection processing begins (also used for queued / in_progress) |
collection_paused | Collection temporarily paused |
collection_resumed | Paused collection resumed |
collection_completed | Collection finished (used for both completed and partial) |
collection_cancelled | Collection cancelled by customer or analyst |
collection_archived | Collection archived |
error_occurred | Collection entered the error state |
Data Collection
| Event | Triggered When |
|---|---|
data_collected | Data collected from an endpoint |
endpoint_skipped | An endpoint was skipped (e.g. unmet path-parameter dependency) |
Analyst Actions
| Event | Triggered When |
|---|---|
analyst_assigned | Analyst assigned to collection |
analyst_reviewed | Analyst reviewed collection data (also used for the reviewing transition) |
analyst_signed_off | Analyst signed off on collection |
Credential Events
| Event | Triggered When |
|---|---|
credentials_validated | Credentials tested against the platform API |
credentials_revoked | Credentials revoked |
Delivery Events
| Event | Triggered When |
|---|---|
zip_created | ZIP archive created |
sas_url_generated | Signed download URL generated |
customer_notified | Customer notified |
collection_delivered | Collection delivered to customer |
There is no collection_failed, analyst_rejected, endpoint_started/completed/failed, file_collected/hash_verified, credentials_created/accessed, or delivery_* event type. Use the enum values above. A rejection during review is recorded as the reviewing → error transition (error_occurred).
Tamper-Evidence
The integrity guarantees come from three layers, not from a per-event hash chain:
- Append-only events —
chain_of_custody_eventsrows are never updated or deleted. - Per-response forensic hashing — every API call is logged in
api_logswithresponse_body_hash(andrequest_body_hash), an orderingchain_of_custody_sequence, and a dual-library validation flag (response_body_hash_validated). - Per-file hashing — every collected file in
collected_filescarries asha256_hashandsha256_hash_validatedflag.
This ensures:
- Events cannot be silently modified or deleted
- Each collected artifact's integrity is independently verifiable via its SHA-256 hash
- The sequence of API calls is preserved via
chain_of_custody_sequence
Event Data Examples
Collection Created
{
"event_type": "collection_created",
"actor_type": "customer",
"event_data": {
"connector_id": "conn_slack001",
"connector_version": "1.2.0",
"scope_config": {
"date_range": {
"from": "2024-01-01T00:00:00Z",
"to": "2024-01-31T23:59:59Z"
}
},
"priority": "high",
"business_context": "Internal investigation"
}
}
Data Collected
{
"event_type": "data_collected",
"actor_type": "system",
"event_data": {
"filename": "channel_C0123ABC_messages.json",
"file_size_bytes": 24576,
"sha256_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"source_endpoint": "/api/conversations.history",
"source_params": {
"channel": "C0123ABC"
}
}
}
Analyst Sign-Off
{
"event_type": "analyst_signed_off",
"actor_type": "analyst",
"event_data": {
"signature_data": "data:image/png;base64,iVBORw0KGgo...",
"certification": "I certify that this collection is complete and accurate",
"files_reviewed": 1247,
"total_bytes_reviewed": 524288000,
"notes": "All data verified. No anomalies detected."
}
}
Querying the Chain
Get Collection History
const history = await prisma.chainOfCustodyEvent.findMany({
where: { collection_id: 'col_abc123' },
orderBy: { timestamp: 'asc' }
});
Verify Artifact Integrity
Integrity verification operates on the hashed artifacts (api_logs responses and collected_files), not on a per-event hash chain. Re-hash each stored artifact and compare against the recorded SHA-256:
async function verifyAllHashes(collectionId: string): Promise<boolean> {
const files = await prisma.collectedFile.findMany({
where: { collectionId, deletedAt: null }
});
for (const file of files) {
if (!file.sha256HashValidated) return false;
// (re-hash the stored bytes and compare against file.sha256Hash for a deep check)
}
return true;
}
Reporting
Chain of Custody Report
Generate a formal report for legal proceedings:
async function generateCustodyReport(collectionId: string) {
const collection = await getCollection(collectionId);
const events = await getChainOfCustodyEvents(collectionId);
return {
collection_id: collection.id,
customer: collection.customer.company_name,
platform: collection.connector.platform_name,
date_range: collection.scope_config.date_range,
timeline: events.map(e => ({
timestamp: e.timestamp,
action: e.event_type,
actor: e.actor_type,
details: summarizeEvent(e)
})),
integrity: {
total_events: events.length,
first_event: events[0].timestamp,
last_event: events[events.length - 1].timestamp
},
files: {
total_count: collection.totalFilesCollected,
total_size: collection.totalBytesCollected,
all_hashes_verified: await verifyAllHashes(collectionId)
},
sign_off: {
analyst: collection.assigned_analyst.full_name,
timestamp: collection.signed_off_at,
signature: getSignatureData(collectionId)
}
};
}
Best Practices
1. Never Modify Events
Events are immutable. If corrections are needed, create a new event referencing the original:
{
"event_type": "correction",
"event_data": {
"corrects_event_id": "evt_abc123",
"reason": "Typo in business context",
"correction": {
"field": "business_context",
"old_value": "Intenral investigation",
"new_value": "Internal investigation"
}
}
}
2. Include Context
Always capture relevant context:
await createEvent({
collection_id: collectionId,
event_type: 'analyst_reviewed',
actor_id: analystId,
actor_type: 'analyst',
event_data: {
review_duration_minutes: 45,
files_examined: 234,
notes: 'Spot-checked 10% of files for completeness'
},
ip_address: request.ip,
user_agent: request.headers['user-agent']
});
3. Verify on Access
Verify artifact-hash integrity before generating reports:
async function getCustodyReport(collectionId: string) {
const isValid = await verifyAllHashes(collectionId);
if (!isValid) {
throw new Error('Chain of custody integrity compromised');
}
return generateReport(collectionId);
}
A chain-of-custody PDF is generated as part of delivery by the worker — see apps/worker/src/jobs/delivery/chain-of-custody-pdf.ts. The customer- and analyst-facing chain-of-custody views are at app/(protected)/analyst/collections/[id]/chain-of-custody/page.tsx and the api/collections/[id]/chain-of-custody/route.ts handler.
Legal Considerations
The chain of custody system is designed to meet requirements for:
- Federal Rules of Evidence (FRE 901, 902)
- eDiscovery requirements
- GDPR audit trail requirements
- SOC 2 compliance
- ISO 27001 information security standards
Consult with legal counsel when using Traces data in legal proceedings.