10 KiB
Unified Evidence Model
Module:
StellaOps.Evidence.Core
Status: Production
Owner: Platform Guild
Overview
The Unified Evidence Model provides a standardized interface (IEvidence) and implementation (EvidenceRecord) for representing evidence across all StellaOps modules. This enables:
- Cross-module evidence linking: Evidence from Scanner, Attestor, Excititor, and Policy modules share a common contract.
- Content-addressed verification: Evidence records are immutable and verifiable via deterministic hashing.
- Unified storage: A single
IEvidenceStoreinterface abstracts persistence across modules. - Cryptographic attestation: Multiple signatures from different signers (internal, vendor, CI, operator) can vouch for evidence.
Core Types
IEvidence Interface
public interface IEvidence
{
string SubjectNodeId { get; } // Content-addressed subject
EvidenceType EvidenceType { get; } // Type discriminator
string EvidenceId { get; } // Computed hash identifier
ReadOnlyMemory<byte> Payload { get; } // Canonical JSON payload
IReadOnlyList<EvidenceSignature> Signatures { get; }
EvidenceProvenance Provenance { get; }
string? ExternalPayloadCid { get; } // For large payloads
string PayloadSchemaVersion { get; }
}
EvidenceType Enum
The platform supports these evidence types:
| Type | Value | Description | Example Payload |
|---|---|---|---|
Reachability |
1 | Call graph analysis | Paths, confidence, graph digest |
Scan |
2 | Vulnerability finding | CVE, severity, affected package |
Policy |
3 | Policy evaluation | Rule ID, verdict, inputs |
Artifact |
4 | SBOM entry metadata | PURL, digest, build info |
Vex |
5 | VEX statement | Status, justification, impact |
Epss |
6 | EPSS score | Score, percentile, model date |
Runtime |
7 | Runtime observation | eBPF/ETW traces, call frames |
Provenance |
8 | Build provenance | SLSA attestation, builder info |
Exception |
9 | Applied exception | Exception ID, reason, expiry |
Guard |
10 | Guard/gate analysis | Gate type, condition, bypass |
Kev |
11 | KEV status | In-KEV flag, added date |
License |
12 | License analysis | SPDX ID, compliance status |
Dependency |
13 | Dependency metadata | Graph edge, version range |
Custom |
100 | User-defined | Schema-versioned custom payload |
EvidenceRecord
The concrete implementation with deterministic identity:
public sealed record EvidenceRecord : IEvidence
{
public static EvidenceRecord Create(
string subjectNodeId,
EvidenceType evidenceType,
ReadOnlyMemory<byte> payload,
EvidenceProvenance provenance,
string payloadSchemaVersion,
IReadOnlyList<EvidenceSignature>? signatures = null,
string? externalPayloadCid = null);
public bool VerifyIntegrity();
}
EvidenceId Computation:
The EvidenceId is a SHA-256 hash of the canonicalized fields using versioned prefixing:
EvidenceId = "evidence:" + CanonJson.HashVersionedPrefixed("IEvidence", "v1", {
SubjectNodeId,
EvidenceType,
PayloadHash,
Provenance.GeneratorId,
Provenance.GeneratorVersion,
Provenance.GeneratedAt (ISO 8601)
})
EvidenceSignature
Cryptographic attestation by a signer:
public sealed record EvidenceSignature
{
public required string SignerId { get; init; }
public required string Algorithm { get; init; } // ES256, RS256, EdDSA
public required string SignatureBase64 { get; init; }
public required DateTimeOffset SignedAt { get; init; }
public SignerType SignerType { get; init; }
public IReadOnlyList<string>? CertificateChain { get; init; }
}
SignerType Values:
Internal(0): StellaOps serviceVendor(1): External vendor/supplierCI(2): CI/CD pipelineOperator(3): Human operatorTransparencyLog(4): Rekor/transparency logScanner(5): Security scannerPolicyEngine(6): Policy engineUnknown(255): Unclassified
EvidenceProvenance
Generation context:
public sealed record EvidenceProvenance
{
public required string GeneratorId { get; init; }
public required string GeneratorVersion { get; init; }
public required DateTimeOffset GeneratedAt { get; init; }
public string? CorrelationId { get; init; }
public Guid? TenantId { get; init; }
// ... additional fields
}
Adapters
Adapters convert module-specific evidence types to the unified IEvidence interface:
Available Adapters
| Adapter | Source Module | Source Type | Target Evidence Types |
|---|---|---|---|
EvidenceBundleAdapter |
Scanner | EvidenceBundle |
Reachability, Vex, Provenance, Scan |
EvidenceStatementAdapter |
Attestor | EvidenceStatement (in-toto) |
Scan |
ProofSegmentAdapter |
Scanner | ProofSegment |
Varies by segment type |
VexObservationAdapter |
Excititor | VexObservation |
Vex, Provenance |
ExceptionApplicationAdapter |
Policy | ExceptionApplication |
Exception |
Adapter Interface
public interface IEvidenceAdapter<TSource>
{
IReadOnlyList<IEvidence> Convert(
TSource source,
string subjectNodeId,
EvidenceProvenance provenance);
bool CanConvert(TSource source);
}
Using Adapters
Adapters use input DTOs to avoid circular dependencies:
// Using VexObservationAdapter
var adapter = new VexObservationAdapter();
var input = new VexObservationInput
{
ObservationId = "obs-001",
ProviderId = "nvd",
StreamId = "cve-feed",
// ... other fields from VexObservation
};
var provenance = new EvidenceProvenance
{
GeneratorId = "excititor-ingestor",
GeneratorVersion = "1.0.0",
GeneratedAt = DateTimeOffset.UtcNow
};
if (adapter.CanConvert(input))
{
IReadOnlyList<IEvidence> records = adapter.Convert(
input,
subjectNodeId: "sha256:abc123",
provenance);
}
Evidence Store
IEvidenceStore Interface
public interface IEvidenceStore
{
Task<EvidenceRecord> StoreAsync(
EvidenceRecord record,
CancellationToken ct = default);
Task<IReadOnlyList<EvidenceRecord>> StoreBatchAsync(
IEnumerable<EvidenceRecord> records,
CancellationToken ct = default);
Task<EvidenceRecord?> GetByIdAsync(
string evidenceId,
CancellationToken ct = default);
Task<IReadOnlyList<EvidenceRecord>> GetBySubjectAsync(
string subjectNodeId,
EvidenceType? evidenceType = null,
CancellationToken ct = default);
Task<IReadOnlyList<EvidenceRecord>> GetByTypeAsync(
EvidenceType evidenceType,
int limit = 100,
CancellationToken ct = default);
Task<bool> ExistsAsync(
string evidenceId,
CancellationToken ct = default);
Task<bool> DeleteAsync(
string evidenceId,
CancellationToken ct = default);
}
Implementations
InMemoryEvidenceStore: Thread-safe in-memory store for testing and development.PostgresEvidenceStore(planned): Production store with tenant isolation and indexing.
Usage Examples
Creating Evidence
var provenance = new EvidenceProvenance
{
GeneratorId = "scanner-service",
GeneratorVersion = "2.1.0",
GeneratedAt = DateTimeOffset.UtcNow,
TenantId = tenantId
};
// Serialize payload to canonical JSON
var payloadBytes = CanonJson.Canonicalize(new
{
cveId = "CVE-2024-1234",
severity = "HIGH",
affectedPackage = "pkg:npm/lodash@4.17.20"
});
var evidence = EvidenceRecord.Create(
subjectNodeId: "sha256:abc123def456...",
evidenceType: EvidenceType.Scan,
payload: payloadBytes,
provenance: provenance,
payloadSchemaVersion: "scan/v1");
Storing and Retrieving
var store = new InMemoryEvidenceStore();
// Store
await store.StoreAsync(evidence);
// Retrieve by ID
var retrieved = await store.GetByIdAsync(evidence.EvidenceId);
// Retrieve all evidence for a subject
var allForSubject = await store.GetBySubjectAsync(
"sha256:abc123def456...",
evidenceType: EvidenceType.Scan);
// Verify integrity
bool isValid = retrieved!.VerifyIntegrity();
Cross-Module Evidence Linking
// Scanner produces evidence bundle
var bundle = scanner.ProduceEvidenceBundle(target);
// Convert to unified evidence
var adapter = new EvidenceBundleAdapter();
var evidenceRecords = adapter.Convert(bundle, subjectNodeId, provenance);
// Store all records
await store.StoreBatchAsync(evidenceRecords);
// Later, any module can query by subject
var allEvidence = await store.GetBySubjectAsync(subjectNodeId);
// Filter by type
var reachabilityEvidence = allEvidence
.Where(e => e.EvidenceType == EvidenceType.Reachability);
var vexEvidence = allEvidence
.Where(e => e.EvidenceType == EvidenceType.Vex);
Schema Versioning
Each evidence type payload has a schema version (PayloadSchemaVersion) for forward compatibility:
scan/v1: Initial scan evidence schemareachability/v1: Reachability evidence schemavex-statement/v1: VEX statement evidence schemaproof-segment/v1: Proof segment evidence schemaexception-application/v1: Exception application schema
Consumers should check PayloadSchemaVersion before deserializing payloads to handle schema evolution.
Integration Patterns
Module Integration
Each module that produces evidence should:
- Create an adapter if converting from module-specific types
- Use
EvidenceRecord.Create()for new evidence - Store evidence via
IEvidenceStore - Include provenance with generator identification
Verification Flow
1. Retrieve evidence by SubjectNodeId
2. Call VerifyIntegrity() to check EvidenceId
3. Verify signatures against known trust roots
4. Deserialize and validate payload against schema
Testing
The StellaOps.Evidence.Core.Tests project includes:
- 111 unit tests covering:
- EvidenceRecord creation and hash computation
- InMemoryEvidenceStore CRUD operations
- All adapter conversions (VexObservation, ExceptionApplication, ProofSegment)
- Edge cases and error handling
Run tests:
dotnet test src/__Libraries/StellaOps.Evidence.Core.Tests/