Files
git.stella-ops.org/docs/modules/evidence/unified-model.md
2025-12-24 21:45:46 +02:00

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 IEvidenceStore interface 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 service
  • Vendor (1): External vendor/supplier
  • CI (2): CI/CD pipeline
  • Operator (3): Human operator
  • TransparencyLog (4): Rekor/transparency log
  • Scanner (5): Security scanner
  • PolicyEngine (6): Policy engine
  • Unknown (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 schema
  • reachability/v1: Reachability evidence schema
  • vex-statement/v1: VEX statement evidence schema
  • proof-segment/v1: Proof segment evidence schema
  • exception-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:

  1. Create an adapter if converting from module-specific types
  2. Use EvidenceRecord.Create() for new evidence
  3. Store evidence via IEvidenceStore
  4. 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/