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 evidence CLI) 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 no POST /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.WebService currently wires the deterministic InMemoryEvidenceBundleExporter (a single seeded golden bundle bundle-golden-001) rather than the full TarGzBundleExporter from StellaOps.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

ActorTypeRole
Auditor / OperatorHuman or service accountTriggers export of a sealed bundle and verifies the archive
EvidenceLockerServiceSeals bundles (snapshot path), then exports them as tar.gz
Crypto provider registryIn-processSigns the bundle manifest (DSSE) via ICryptoSigner and resolves the SHA-256 hasher for Merkle roots — not a separate network “Signer” service
KMS backup encryptorIn-process plugin (optional)Encrypts the archive into an opaque artifact when encryptArchive is requested (IKmsBackupEncryptor)

The historical Signer / Provenance modules are consolidated into Attestor (see repo CLAUDE.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

Authorization

OperationEndpointRequired scope
Trigger exportPOST /api/v1/bundles/{bundleId}/exportexport.operator
Poll status / downloadGET /api/v1/bundles/{bundleId}/export/{exportId}[/download]export.viewer
Read stored evidence (service default policy)other EvidenceLocker routesevidence: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 / fileDirectoryFormatSource flag
SBOMssboms/CycloneDX / SPDX JSONincludeSboms
VEX statementsvex/OpenVEX / CSAF JSONincludeVex
Attestationsattestations/DSSE envelopesincludeAttestations
Policy verdictspolicy/JSONincludePolicyVerdicts
Scan resultsscans/JSONincludeScanResults
Runtime witnessesruntime-witnesses/witness trace + DSSE + Sigstore bundle tripletsincludeRuntimeWitnesses
Public keyskeys/PEMincludeKeys
Manifestmanifest.jsonJSON (artifact index)always
Metadatametadata.jsonJSON (subject + provenance + time window)always
Checksumschecksums.sha256BSD-style SHA-256 listalways
Verify scriptsverify.sh, verify.ps1shell / PowerShellincludeVerifyScripts
ReadmeREADME.mdMarkdownalways

Not emitted by the export archive today: a separate detached manifest.sig, hashes.json, merkle.json, or audit/activity.ndjson file. Per-file integrity lives in checksums.sha256; the DSSE-signed manifest is produced on the seal path and stored in evidence_bundle_signatures. Reachability / K4-lattice evidence is not a distinct bundle section in BundlePaths.

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 no name/description/scope/include/signing/transparency_log in this contract — the bundle’s contents and signature were fixed when it was sealed. If bundleId is unknown the endpoint returns 404 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"
}

estimatedSize is the stored bundle size (bytes) from bundle metadata. Job state lives in an in-process ConcurrentDictionary (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
}

merkleRoot is an optional manifest field but is not populated by the export path today — the TarGzBundleExporter builds the manifest without calling MerkleTreeBuilder. Per-file integrity is carried by checksums.sha256. The Merkle/root-hash primitive is used elsewhere: when a bundle is sealed (snapshot path), MerkleTreeCalculator computes EvidenceBundle.RootHash over 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):

                    root_hash
                   /         \
              hash_01       hash_23
             /      \       /      \
         hash_0  hash_1  hash_2  hash_3

This is a plain concatenation tree (no RFC-6962 0x00/0x01 leaf/node domain-separation prefixes). Earlier drafts that labelled the proof format rfc6962 were incorrect. MerkleTreeBuilder also exposes GenerateInclusionProof / VerifyInclusion helpers 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 encryptArchive is requested (IKmsBackupEncryptor registered), the gzip stream is encrypted into an opaque artifact and a signed BackupManifest is written to a <file>.manifest.json sidecar; if encryption is requested with no encryptor configured, the export fails closed (KEYS_NOT_AVAILABLE) rather than emitting plaintext.

Live-wiring note: the EvidenceLocker.WebService composition root currently registers InMemoryEvidenceBundleExporter, which materialises only the seeded golden bundle (manifest.json, canonical_bom.json, dsse.envelope.json) deterministically for QA. The full multi-section TarGzBundleExporter layout above lives in StellaOps.EvidenceLocker.Export and is the target once the Postgres-backed bundle store hydrates ExportRequest.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:hold scope); region/data-residency is tagged on the bundle row (RegionTag) and enforced by IDataResidencyEnforcer.

Two distinct ExportConfiguration types — important. There are two records with this name and they are not the same:

  1. Library configStellaOps.EvidenceLocker.Export.Models.ExportConfiguration (in Models/BundleMetadata.cs). This is the rich, section-toggling record documented in the TypeScript block above; it is consumed by TarGzBundleExporter. (this is the roadmap full-export path)
  2. Live WebService configStellaOps.EvidenceLocker.Export.ExportConfiguration (in the WebService project’s Export/IEvidenceBundleExporter.cs). It has only three fields: compressionLevel (default 6), includeLayerSboms (default true), includeRekorProofs (default true). This is the record ExportJobService actually builds from ExportTriggerRequest and hands to the currently-wired InMemoryEvidenceBundleExporter.

Consequence: the ExportTriggerRequest fields below (compressionLevel/includeLayerSboms/includeRekorProofs) map onto the live (three-field) config — not onto the section-include flags (includeSboms, includeVex, …) of the library config. The format and compression request fields are accepted by the contract but are not plumbed into either exporter today (the live exporter only reads compressionLevel/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 to TarGzBundleExporter + 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):

ConditionResponse
Unknown bundleId on trigger404 Not Found (evidencelocker.error.bundle_id_not_found)
Unknown exportId on status/download404 Not Found (evidencelocker.error.export_not_found)
Download requested before ready409 Conflict (evidencelocker.error.export_not_ready)
Background export throwsjob transitions to failed; status reflects failed

Exporter-level error codes (ExportErrorCodes, returned by TarGzBundleExporter as a failed ExportResult):

CodeMeaning
BUNDLE_NOT_FOUNDBundle data provider returned no bundle for id/tenant
KEYS_NOT_AVAILABLEencryptArchive requested but no IKmsBackupEncryptor registered (fail-closed)
IO_ERRORArchive write/compression failure
ACCESS_DENIED, ARTIFACT_MISSING, COMPRESSION_ERROR, INVALID_CONFIGURATIONDefined 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_count series from earlier drafts do not exist in src/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.

EventSourceNotes
AuditActions.Evidence.ExportPOST .../export (AuditModules.Evidence)Recorded for every export trigger
evidence.bundle.sealedTimeline (TimelineIndexerEvidenceTimelinePublisher)Emitted when a bundle is sealed, not on export
evidence.hold.createdTimelineLegal-hold placement
evidence.incident.modeTimelineIncident-mode toggle
evidence.eu.artifact.generated / evidence.eu.artifact.exportedRegulatoryAuditEventEU regulatory artifact lifecycle

The evidence.bundle.requested / evidence.bundle.generating / evidence.bundle.verified log events from earlier drafts are not present in code; ExportJobService logs job lifecycle via standard ILogger messages (created / starting / completed / failed).

Source References