Evidence Bundle Export Flow
Doc status (reconciled against
src/EvidenceLocker). The export transport (async job model, tar.gz archive, manifest/checksum/verify-script layout, Merkle hashing primitives, KMS-encrypted backups,stella evidenceCLI) is implemented. However several details in earlier drafts of this flow did not match the code and have been corrected inline:
- The API is
POST /api/v1/bundles/{bundleId}/export— it exports an already-sealed bundle by ID. There is noPOST /api/v1/evidence/bundles“create-bundle-from-scope” endpoint; bundles are sealed by the internal snapshot/builder path, not assembled on-demand from a request scope.- Bundle sealing and signing happen at snapshot time inside EvidenceLocker (in-process crypto signer + RFC-3161 timestamp + optional Rekor reference), not by calling out to a separate “Signer” microservice during export.
- The live
EvidenceLocker.WebServicecurrently wires the deterministicInMemoryEvidenceBundleExporter(a single seeded golden bundlebundle-golden-001) rather than the fullTarGzBundleExporterfromStellaOps.EvidenceLocker.Export. Multi-artifact production export, Merkle-root-in-manifest, and Rekor inclusion proofs in the export archive are roadmap for the WebService composition root.Items still aspirational are marked (roadmap) below.
Overview
The Evidence Bundle Export Flow describes how StellaOps exports comprehensive, tamper-evident evidence packages for audits, compliance, and legal proceedings. Sealed bundles include SBOMs, scan results, policy verdicts, attestations, VEX statements, and runtime witness artifacts, all indexed by a manifest with per-file SHA-256 checksums.
Audience: auditors and compliance operators exporting and verifying sealed evidence bundles, and engineers integrating with the EvidenceLocker export API or the stella evidence CLI.
Business Value: Auditable, legally defensible evidence packages that demonstrate due diligence in vulnerability management and support regulatory compliance.
Actors
| Actor | Type | Role |
|---|---|---|
| Auditor / Operator | Human or service account | Triggers export of a sealed bundle and verifies the archive |
| EvidenceLocker | Service | Seals bundles (snapshot path), then exports them as tar.gz |
| Crypto provider registry | In-process | Signs the bundle manifest (DSSE) via ICryptoSigner and resolves the SHA-256 hasher for Merkle roots — not a separate network “Signer” service |
| KMS backup encryptor | In-process plugin (optional) | Encrypts the archive into an opaque artifact when encryptArchive is requested (IKmsBackupEncryptor) |
The historical
Signer/Provenancemodules are consolidated into Attestor (see repoCLAUDE.md§1.4). EvidenceLocker does not make a synchronous network call to a “Signer” service during export; manifest signing is performed in-process by the registered crypto signer when the bundle is sealed.
Prerequisites
- The bundle already exists and is sealed in EvidenceLocker (it is referenced by
bundleId; sealing/signing happen on the snapshot path, not at export time). - Caller holds the required export scopes (see Authorization).
- For encrypted backups, an
IKmsBackupEncryptoris registered (export fails closed if encryption is requested without one).
Authorization
| Operation | Endpoint | Required scope |
|---|---|---|
| Trigger export | POST /api/v1/bundles/{bundleId}/export | export.operator |
| Poll status / download | GET /api/v1/bundles/{bundleId}/export/{exportId}[/download] | export.viewer |
| Read stored evidence (service default policy) | other EvidenceLocker routes | evidence:read |
Scope claim values are the canonical catalog in StellaOps.Auth.Abstractions/StellaOpsScopes.cs (export.viewer, export.operator, export.admin, evidence:create, evidence:read, evidence:hold). The export endpoint group also enforces RequireTenant() and emits an audit event (AuditModules.Evidence / AuditActions.Evidence.Export).
Bundle Contents
The sealed archive layout is defined by BundlePaths in StellaOps.EvidenceLocker.Export. Sections are included according to the ExportConfiguration flags (all default true).
| Section / file | Directory | Format | Source flag |
|---|---|---|---|
| SBOMs | sboms/ | CycloneDX / SPDX JSON | includeSboms |
| VEX statements | vex/ | OpenVEX / CSAF JSON | includeVex |
| Attestations | attestations/ | DSSE envelopes | includeAttestations |
| Policy verdicts | policy/ | JSON | includePolicyVerdicts |
| Scan results | scans/ | JSON | includeScanResults |
| Runtime witnesses | runtime-witnesses/ | witness trace + DSSE + Sigstore bundle triplets | includeRuntimeWitnesses |
| Public keys | keys/ | PEM | includeKeys |
| Manifest | manifest.json | JSON (artifact index) | always |
| Metadata | metadata.json | JSON (subject + provenance + time window) | always |
| Checksums | checksums.sha256 | BSD-style SHA-256 list | always |
| Verify scripts | verify.sh, verify.ps1 | shell / PowerShell | includeVerifyScripts |
| Readme | README.md | Markdown | always |
Not emitted by the export archive today: a separate detached
manifest.sig,hashes.json,merkle.json, oraudit/activity.ndjsonfile. Per-file integrity lives inchecksums.sha256; the DSSE-signed manifest is produced on the seal path and stored inevidence_bundle_signatures. Reachability / K4-lattice evidence is not a distinct bundle section inBundlePaths.
Flow Diagram
The export API operates on a bundle that is already sealed. Artifact collection, manifest signing, and Merkle/root-hash computation happen earlier on the snapshot/seal path; the diagram below shows the async export-and-download interaction implemented by ExportEndpoints + ExportJobService.
┌───────────────────────────────────────────────────────────────────────┐
│ Evidence Bundle Export & Download (async job) │
└───────────────────────────────────────────────────────────────────────┘
┌──────────────────┐ ┌────────────────┐ ┌──────────────────┐ ┌──────────────┐
│ Auditor/Operator │ │ ExportEndpoints│ │ ExportJobService │ │ Bundle │
│ (export.*) │ │ (HTTP) │ │ (in-proc jobs) │ │ exporter │
└────────┬─────────┘ └───────┬────────┘ └────────┬─────────┘ └──────┬───────┘
│ │ │ │
│ POST .../{id}/export│ │ │
│────────────────────>│ │ │
│ │ CreateExportJobAsync│ │
│ │────────────────────>│ │
│ │ │ lookup bundle meta │
│ │ │ (404 if unknown) │
│ │ │───┐ │
│ │ │<──┘ │
│ │ 202 + exportId │ │
│ │ + statusUrl │ start bg processing│
│ 202 Accepted │<────────────────────│───────────────────>│
│<────────────────────│ │ ExportAsync(...) │
│ │ │ build tar.gz │
│ │ │<───────────────────│
│ GET .../{exportId} │ │ │
│────────────────────>│ GetExportStatusAsync│ │
│ │────────────────────>│ │
│ 202 (processing, │ │ │
│ progress%) OR │<────────────────────│ │
│ 200 (ready, │ │ │
│ downloadUrl) │ │ │
│<────────────────────│ │ │
│ GET .../download │ │ │
│────────────────────>│ GetExportFileAsync │ │
│ │────────────────────>│ │
│ 200 application/ │ FileStream │ │
│ gzip (or 409 if │<────────────────────│ │
│ not ready) │ │ │
│<────────────────────│ │ │
Step-by-Step
1. Trigger Export
The caller triggers an async export of an existing, sealed bundle by its bundleId. The endpoint group requires a tenant (RequireTenant()) and the export.operator scope.
POST /api/v1/bundles/{bundleId}/export HTTP/1.1
Authorization: Bearer {jwt}
X-Tenant-Id: acme-corp
Content-Type: application/json
{
"format": "tar.gz",
"compression": "gzip",
"compressionLevel": 6,
"includeRekorProofs": true,
"includeLayerSboms": true
}
The request body is
ExportTriggerRequest. All five fields are optional and default as shown above; there is noname/description/scope/include/signing/transparency_login this contract — the bundle’s contents and signature were fixed when it was sealed. IfbundleIdis unknown the endpoint returns404 Not Found.
2. Accepted (job enqueued)
ExportJobService checks the bundle exists, generates an exportId (exp-<yyyyMMddHHmmss>-<8 hex>), records an in-process job, and starts background processing. The endpoint returns 202 Accepted with a Location header pointing at the status URL.
{
"exportId": "exp-20241229103000-a1b2c3d4",
"status": "pending",
"estimatedSize": 257000,
"statusUrl": "/api/v1/bundles/bundle-789ghi/export/exp-20241229103000-a1b2c3d4"
}
estimatedSizeis the stored bundle size (bytes) from bundle metadata. Job state lives in an in-processConcurrentDictionary(the service is registered as a Singleton), so status/download must be polled from the same instance.
3. Poll Status
GET /api/v1/bundles/{bundleId}/export/{exportId} (scope export.viewer). While the job is pending/processing it returns 202 with progress; when ready it returns 200 with the download URL.
Processing:
{
"exportId": "exp-20241229103000-a1b2c3d4",
"status": "processing",
"progress": 25,
"estimatedTimeRemaining": null
}
Ready:
{
"exportId": "exp-20241229103000-a1b2c3d4",
"status": "ready",
"downloadUrl": "/api/v1/bundles/bundle-789ghi/export/exp-20241229103000-a1b2c3d4/download",
"fileSize": 257013,
"completedAt": "2024-12-29T10:30:42Z"
}
Status values come from ExportJobStatusEnum: pending, processing, ready, failed.
4. Bundle Manifest
The exporter writes manifest.json (a BundleManifest) indexing every artifact with its sha256: digest, media type, size, and type. The shape below reflects the StellaOps.EvidenceLocker.Export model (note schemaVersion, the per-section arrays, and totalArtifacts).
{
"schemaVersion": "1.0.0",
"bundleId": "bundle-789ghi",
"createdAt": "2024-12-29T10:30:00Z",
"metadata": {
"schemaVersion": "1.0.0",
"subject": { "type": "container_image", "digest": "sha256:...", "name": "myorg/app" },
"provenance": {
"creator": { "name": "StellaOps EvidenceLocker", "version": "..." },
"exportedAt": "2024-12-29T10:30:00Z"
},
"timeWindow": { "earliest": "2024-10-01T00:00:00Z", "latest": "2024-12-31T23:59:59Z" }
},
"sboms": [
{ "path": "sboms/app.cdx.json", "digest": "sha256:abc123...", "mediaType": "application/vnd.cyclonedx+json", "size": 89012, "type": "sbom", "format": "cyclonedx-1.6" }
],
"vexStatements": [],
"attestations": [
{ "path": "attestations/app-sbom.dsse.json", "digest": "sha256:jkl012...", "mediaType": "application/vnd.dsse.envelope+json", "size": 4096, "type": "attestation" }
],
"policyVerdicts": [],
"scanResults": [],
"runtimeWitnesses": [],
"publicKeys": [],
"totalArtifacts": 2
}
merkleRootis an optional manifest field but is not populated by the export path today — theTarGzBundleExporterbuilds the manifest without callingMerkleTreeBuilder. Per-file integrity is carried bychecksums.sha256. The Merkle/root-hash primitive is used elsewhere: when a bundle is sealed (snapshot path),MerkleTreeCalculatorcomputesEvidenceBundle.RootHashover the canonical manifest entries.
5. Merkle / Root Hash (seal path)
When a bundle is sealed, EvidenceLocker computes a Merkle root over the canonical leaf values and stores it as EvidenceBundle.RootHash. The algorithm (MerkleTreeCalculator / MerkleTreeBuilder):
- leaves are SHA-256 (resolved via the crypto provider registry, default
HashAlgorithms.Sha256), ordered deterministically; - internal nodes are
H(left || right); an odd trailing node is paired with itself.
root_hash
/ \
hash_01 hash_23
/ \ / \
hash_0 hash_1 hash_2 hash_3
This is a plain concatenation tree (no RFC-6962
0x00/0x01leaf/node domain-separation prefixes). Earlier drafts that labelled the proof formatrfc6962were incorrect.MerkleTreeBuilderalso exposesGenerateInclusionProof/VerifyInclusionhelpers that follow the same concatenation scheme.
6. Manifest Signing & Timestamping (seal path)
On the seal path, IEvidenceSignatureService.SignManifestAsync canonicalises the manifest and produces an EvidenceBundleSignature (PayloadType, Payload, Signature, KeyId, Algorithm, Provider, optional RFC-3161 TimestampedAt/TimestampAuthority/TimestampToken) via the in-process crypto signer. The signature is persisted in the evidence_bundle_signatures table — there is no synchronous call to an external “Signer” microservice. The capsule path (CapsuleService) similarly seals via a registered ICapsuleSigner and degrades gracefully to an unsigned sealed capsule when no signer is configured.
Transparency-log (Rekor) and TSA references are modelled at the domain level (TransparencyReference, TimestampReference) and may be attached to the sealed bundle manifest.
7. Archive Layout
TarGzBundleExporter writes a gzip-compressed tar with the layout below (paths from BundlePaths). Sections present depend on the ExportConfiguration include flags.
evidence-bundle-bundle-789ghi.tar.gz
├── manifest.json # Bundle manifest (artifact index)
├── metadata.json # Subject + provenance + time window
├── checksums.sha256 # BSD-format SHA-256 for every file
├── verify.sh # Verification script (Unix)
├── verify.ps1 # Verification script (Windows)
├── README.md # Generated bundle documentation
├── sboms/ # SBOM artifacts
├── vex/ # VEX statements
├── attestations/ # DSSE attestation envelopes
├── policy/ # Policy verdicts
├── scans/ # Scan results
├── runtime-witnesses/ # Runtime witness triplets (trace/dsse/sigstore)
└── keys/ # Public keys for offline verification
When
encryptArchiveis requested (IKmsBackupEncryptorregistered), the gzip stream is encrypted into an opaque artifact and a signedBackupManifestis written to a<file>.manifest.jsonsidecar; if encryption is requested with no encryptor configured, the export fails closed (KEYS_NOT_AVAILABLE) rather than emitting plaintext.
Live-wiring note: the
EvidenceLocker.WebServicecomposition root currently registersInMemoryEvidenceBundleExporter, which materialises only the seeded golden bundle (manifest.json,canonical_bom.json,dsse.envelope.json) deterministically for QA. The full multi-sectionTarGzBundleExporterlayout above lives inStellaOps.EvidenceLocker.Exportand is the target once the Postgres-backed bundle store hydratesExportRequest.OutputDirectory. (roadmap)
8. Download
GET /api/v1/bundles/{bundleId}/export/{exportId}/download (scope export.viewer) streams the archive as application/gzip once the job is ready; it returns 409 Conflict if the export is still in progress and 404 if the export ID is unknown.
9. Verification (CLI)
The CLI binary is stella (assembly StellaOps.Cli). Verify an exported bundle:
# Verify an exported evidence bundle archive
stella evidence verify ./evidence-bundle-bundle-789ghi.tar.gz
# Useful flags:
# --offline skip Rekor transparency-log verification (air-gapped)
# --skip-signatures verify checksums only (skip DSSE signature checks)
# --output json machine-readable output (default: table)
stella evidence export <bundleId> drives the same POST .../export → poll → download pipeline; stella evidence status <exportId> polls an in-flight job. (Exact verification line items depend on which sections the bundle contains; verification covers checksums.sha256, the DSSE manifest signature, and — unless --offline — the Rekor reference.)
Export Configuration
What goes into a fully-materialised bundle is governed by ExportConfiguration (StellaOps.EvidenceLocker.Export.Models.ExportConfiguration), applied by TarGzBundleExporter. All include flags default to true. There is no scope/profile object in the export API — the API exports a pre-sealed bundle; the configuration only toggles sections, compression, and encryption. (See the “Two distinct ExportConfiguration types” note below — this section documents the library record, not the smaller config the live WebService path currently consumes.)
// StellaOps.EvidenceLocker.Export.Models.ExportConfiguration
interface ExportConfiguration {
includeSboms: boolean; // default true
includeVex: boolean; // default true
includeAttestations: boolean; // default true
includePolicyVerdicts: boolean; // default true
includeScanResults: boolean; // default true
includeRuntimeWitnesses: boolean; // default true
includeKeys: boolean; // default true
includeVerifyScripts: boolean; // default true
compression: 'gzip' | 'brotli' | 'none'; // default 'gzip'
compressionLevel: number; // 1-9, default 6
encryptArchive: boolean; // default false; requires IKmsBackupEncryptor
}
The richer “compliance/incident profile” YAML and per-section retention/encryption policy shown in earlier drafts are not part of the export contract. Retention and legal holds are governed separately (
EvidenceHold,evidence:holdscope); region/data-residency is tagged on the bundle row (RegionTag) and enforced byIDataResidencyEnforcer.
Two distinct
ExportConfigurationtypes — important. There are two records with this name and they are not the same:
- Library config —
StellaOps.EvidenceLocker.Export.Models.ExportConfiguration(inModels/BundleMetadata.cs). This is the rich, section-toggling record documented in the TypeScript block above; it is consumed byTarGzBundleExporter. (this is the roadmap full-export path)- Live WebService config —
StellaOps.EvidenceLocker.Export.ExportConfiguration(in the WebService project’sExport/IEvidenceBundleExporter.cs). It has only three fields:compressionLevel(default 6),includeLayerSboms(default true),includeRekorProofs(default true). This is the recordExportJobServiceactually builds fromExportTriggerRequestand hands to the currently-wiredInMemoryEvidenceBundleExporter.Consequence: the
ExportTriggerRequestfields below (compressionLevel/includeLayerSboms/includeRekorProofs) map onto the live (three-field) config — not onto the section-include flags (includeSboms,includeVex, …) of the library config. Theformatandcompressionrequest fields are accepted by the contract but are not plumbed into either exporter today (the live exporter only readscompressionLevel/includeLayerSboms/includeRekorProofs; the in-memory golden exporter ignores all of them and emits a fixed deterministic archive). The section-include flags only take effect once the WebService swaps toTarGzBundleExporter+ the library config. (roadmap)
Data Contracts
These match the records in ExportEndpoints.cs / IExportJobService.cs.
Export Trigger Request
// ExportTriggerRequest — POST /api/v1/bundles/{bundleId}/export body
interface ExportTriggerRequest {
format?: string; // default "tar.gz"
compression?: string; // default "gzip"
compressionLevel?: number; // 1-9, default 6
includeRekorProofs?: boolean; // default true
includeLayerSboms?: boolean; // default true
}
Export Trigger Response (202 Accepted)
// ExportTriggerResponse
interface ExportTriggerResponse {
exportId: string;
status: string; // "pending"
estimatedSize?: number; // bytes (from bundle metadata)
statusUrl: string;
}
Export Status Response
// ExportStatusResponse — GET .../export/{exportId}
interface ExportStatusResponse {
exportId: string;
status: string; // pending | processing | ready | failed
progress?: number; // 0-100 while processing (202)
estimatedTimeRemaining?: string; // while processing
downloadUrl?: string; // when ready (200)
fileSize?: number; // bytes, when ready
completedAt?: string; // when ready
}
Error Handling
HTTP-level (ExportEndpoints):
| Condition | Response |
|---|---|
Unknown bundleId on trigger | 404 Not Found (evidencelocker.error.bundle_id_not_found) |
Unknown exportId on status/download | 404 Not Found (evidencelocker.error.export_not_found) |
| Download requested before ready | 409 Conflict (evidencelocker.error.export_not_ready) |
| Background export throws | job transitions to failed; status reflects failed |
Exporter-level error codes (ExportErrorCodes, returned by TarGzBundleExporter as a failed ExportResult):
| Code | Meaning |
|---|---|
BUNDLE_NOT_FOUND | Bundle data provider returned no bundle for id/tenant |
KEYS_NOT_AVAILABLE | encryptArchive requested but no IKmsBackupEncryptor registered (fail-closed) |
IO_ERROR | Archive write/compression failure |
ACCESS_DENIED, ARTIFACT_MISSING, COMPRESSION_ERROR, INVALID_CONFIGURATION | Defined codes for other failure modes |
The “scope too large”, “partial bundle with warning”, “retry or fall back to internal signing”, and “storage quota exceeded → queue for later” recovery behaviours from earlier drafts are not implemented in the export path. Signing is fixed at seal time (no export-time fallback), and there is no scope-driven partial-bundle generation.
Observability
Metrics
No dedicated bundle-export metrics are defined in code today. The
evidence_bundles_created_total/evidence_bundle_size_bytes/evidence_bundle_generation_seconds/evidence_bundle_artifact_countseries from earlier drafts do not exist insrc/EvidenceLocker. Treat them as (roadmap). Standard ASP.NET request metrics apply to the endpoints.
Audit & Timeline Events
The export endpoint emits an audit event via the .Audited(...) filter; sealing emits a timeline event.
| Event | Source | Notes |
|---|---|---|
AuditActions.Evidence.Export | POST .../export (AuditModules.Evidence) | Recorded for every export trigger |
evidence.bundle.sealed | Timeline (TimelineIndexerEvidenceTimelinePublisher) | Emitted when a bundle is sealed, not on export |
evidence.hold.created | Timeline | Legal-hold placement |
evidence.incident.mode | Timeline | Incident-mode toggle |
evidence.eu.artifact.generated / evidence.eu.artifact.exported | RegulatoryAuditEvent | EU regulatory artifact lifecycle |
The
evidence.bundle.requested/evidence.bundle.generating/evidence.bundle.verifiedlog events from earlier drafts are not present in code;ExportJobServicelogs job lifecycle via standardILoggermessages (created / starting / completed / failed).
Related Flows
- Export Flow - General export mechanics
- Scan Submission Flow - Source of evidence
- Offline Sync Flow - Air-gapped bundle transfer
Source References
src/EvidenceLocker/StellaOps.EvidenceLocker/Api/ExportEndpoints.cs— HTTP surface, request/response records, scopes, audit.src/EvidenceLocker/StellaOps.EvidenceLocker/Api/IExportJobService.cs+ExportJobService.cs— async job model and status enum (ExportJobServiceregistered Singleton inProgram.cs; job state in aConcurrentDictionary).src/EvidenceLocker/StellaOps.EvidenceLocker/Export/IEvidenceBundleExporter.cs— the live WebServiceIEvidenceBundleExporter,ExportRequest, and the three-fieldExportConfiguration(compressionLevel/includeLayerSboms/includeRekorProofs) thatExportJobServiceactually uses. Distinct from the library*.Export.Models.ExportConfiguration.src/EvidenceLocker/__Libraries/StellaOps.EvidenceLocker.Export/TarGzBundleExporter.cs+Models/BundleManifest.cs+Models/BundleMetadata.cs— full archive layout, manifest/metadata models,ExportConfiguration,BundlePaths, error codes.src/EvidenceLocker/__Libraries/StellaOps.EvidenceLocker.Export/MerkleTreeBuilder.csand.../Core/Builders/MerkleTreeCalculator.cs— Merkle hashing (concatenation tree).src/EvidenceLocker/StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.Core/Signing/IEvidenceSignatureService.cs+Domain/EvidenceBundleSignature.cs,Domain/EvidenceBundleMetadata.cs— seal-path signing, bundle Kind/Status.src/EvidenceLocker/StellaOps.EvidenceLocker/Api/InMemoryEvidenceBundleExporter.cs+.WebService/Program.cs— live (in-memory golden-bundle) wiring.src/Cli/StellaOps.Cli/Commands/EvidenceCommandGroup.cs—stella evidence export|verify|status.- Scopes:
src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs.
