save progress

This commit is contained in:
StellaOps Bot
2025-12-26 22:03:32 +02:00
parent 9a4cd2e0f7
commit e6c47c8f50
3634 changed files with 253222 additions and 56632 deletions

View File

@@ -0,0 +1,144 @@
# AGENTS.md — StellaOps.Verdict Module
## Overview
The StellaOps.Verdict module provides a **unified StellaVerdict artifact** that consolidates vulnerability disposition decisions into a single, signed, portable proof. It combines PolicyVerdict, ProofBundle, and KnowledgeSnapshot into a content-addressable artifact with JSON-LD support.
## Module Structure
```
src/__Libraries/StellaOps.Verdict/
├── Schema/
│ └── StellaVerdict.cs # Core verdict schema and supporting types
├── Contexts/
│ └── verdict-1.0.jsonld # JSON-LD context for standards interop
├── Services/
│ ├── VerdictAssemblyService.cs # Assembles verdicts from components
│ ├── VerdictSigningService.cs # DSSE signing integration
│ └── IVerdictAssemblyService.cs
├── Persistence/
│ ├── PostgresVerdictStore.cs # PostgreSQL storage implementation
│ ├── IVerdictStore.cs # Storage interface
│ ├── VerdictRow.cs # EF Core entity
│ └── Migrations/
│ └── 001_create_verdicts.sql
├── Api/
│ ├── VerdictEndpoints.cs # REST API endpoints
│ └── VerdictContracts.cs # Request/response DTOs
├── Oci/
│ └── OciAttestationPublisher.cs # OCI registry attestation
├── Export/
│ └── VerdictBundleExporter.cs # Replay bundle export
└── StellaOps.Verdict.csproj
```
## Key Concepts
### StellaVerdict Schema
The core `StellaVerdict` record contains:
- `VerdictId`: Content-addressable ID (`urn:stella:verdict:sha256:...`)
- `Subject`: Vulnerability ID + Component PURL
- `Claim`: Status, confidence, reason
- `Inputs`: Advisory sources, VEX, CVSS, EPSS, KEV, reachability
- `EvidenceGraph`: Proof nodes and edges from ProofBundle
- `PolicyPath`: Rule evaluation trace
- `Result`: Disposition, score, expiration
- `Provenance`: Generator, run ID, timestamp
- `Signatures`: DSSE signatures
### Verdict Status Values
- `Pass`: Component passed all policy checks
- `Blocked`: Component blocked by policy
- `Ignored`: Finding ignored per policy
- `Warned`: Warning issued but not blocking
- `Deferred`: Decision deferred for manual review
- `Escalated`: Escalated for security team
- `RequiresVex`: Needs VEX statement to resolve
## Usage Patterns
### Assembling a Verdict
```csharp
var context = new VerdictAssemblyContext
{
VulnerabilityId = "CVE-2024-1234",
Purl = "pkg:npm/lodash@4.17.15",
PolicyVerdict = policyResult,
Knowledge = knowledgeInputs,
Generator = "StellaOps Scanner",
RunId = scanId
};
var verdict = assemblyService.AssembleVerdict(context);
```
### Storing and Querying
```csharp
await store.StoreAsync(verdict, tenantId, cancellationToken);
var query = new VerdictQuery { Purl = "pkg:npm/lodash@*", Status = VerdictStatus.Blocked };
var results = await store.QueryAsync(query, cancellationToken);
```
### CLI Verification
```bash
# Verify by ID (fetches from API)
stella verify verdict --verdict urn:stella:verdict:sha256:abc123
# Verify from file with replay bundle
stella verify verdict --verdict ./verdict.json --replay ./bundle/
# Show full policy trace
stella verify verdict --verdict ./verdict.json --show-trace
```
### OCI Attestation
```csharp
var result = await publisher.PublishAsync(verdict, "registry.io/app:latest@sha256:...");
```
## API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/v1/verdicts` | POST | Create and store verdict |
| `/v1/verdicts/{id}` | GET | Get verdict by ID |
| `/v1/verdicts` | GET | Query verdicts (purl, cve, status) |
| `/v1/verdicts/{id}/verify` | POST | Verify signature and content ID |
| `/v1/verdicts/{id}/download` | GET | Download as JSON-LD |
| `/v1/verdicts/latest` | GET | Get latest for purl+cve |
| `/v1/verdicts/expired` | DELETE | Clean up expired verdicts |
## Dependencies
- `StellaOps.Policy`: PolicyVerdict, PolicyExplanation
- `StellaOps.Attestor.Envelope`: DSSE signing
- `StellaOps.Cryptography`: BLAKE3/SHA256 hashing
- `StellaOps.Replay.Core`: Bundle structures
## Testing
Unit tests should cover:
- Schema serialization determinism (sorted keys)
- Content-addressable ID computation
- Assembly from various input combinations
- Signature verification
- Query filtering and pagination
Integration tests should cover:
- Full assembly → sign → store → query → verify flow
- OCI publish/fetch cycle
- Replay bundle export and verification
## Coding Guidelines
1. **Determinism**: All JSON output must be deterministic (sorted keys, stable ordering)
2. **Content Addressing**: VerdictId must match `ComputeVerdictId()` output
3. **Immutability**: Use records with `init` properties
4. **Tenant Isolation**: All store operations must include tenantId
5. **Offline Support**: OCI publisher and CLI must handle offline mode
## Related Sprints
- SPRINT_1227_0014_0001: StellaVerdict Unified Artifact Consolidation
- SPRINT_1227_0014_0002: Verdict UI Components (pending)

View File

@@ -0,0 +1,159 @@
using StellaOps.Policy;
using StellaOps.Verdict.Services;
namespace StellaOps.Verdict.Api;
/// <summary>
/// Request to create a new verdict.
/// </summary>
public sealed record VerdictCreateRequest
{
/// <summary>Vulnerability ID (CVE, GHSA, etc.).</summary>
public required string VulnerabilityId { get; init; }
/// <summary>Component PURL.</summary>
public required string Purl { get; init; }
/// <summary>Component name.</summary>
public string? ComponentName { get; init; }
/// <summary>Component version.</summary>
public string? ComponentVersion { get; init; }
/// <summary>Image digest if in container context.</summary>
public string? ImageDigest { get; init; }
/// <summary>Policy verdict result.</summary>
public required PolicyVerdict PolicyVerdict { get; init; }
/// <summary>Knowledge inputs.</summary>
public VerdictKnowledgeInputs? Knowledge { get; init; }
/// <summary>Generator name.</summary>
public string? Generator { get; init; }
/// <summary>Generator version.</summary>
public string? GeneratorVersion { get; init; }
/// <summary>Scan/run ID.</summary>
public string? RunId { get; init; }
}
/// <summary>
/// Response for verdict creation.
/// </summary>
public sealed record VerdictResponse
{
/// <summary>The generated verdict ID.</summary>
public required string VerdictId { get; init; }
/// <summary>Verdict status.</summary>
public required string Status { get; init; }
/// <summary>Result disposition.</summary>
public required string Disposition { get; init; }
/// <summary>Risk score.</summary>
public double Score { get; init; }
/// <summary>Creation timestamp.</summary>
public required string CreatedAt { get; init; }
}
/// <summary>
/// Summary of a verdict for list queries.
/// </summary>
public sealed record VerdictSummary
{
/// <summary>Verdict ID.</summary>
public required string VerdictId { get; init; }
/// <summary>Vulnerability ID.</summary>
public required string VulnerabilityId { get; init; }
/// <summary>Component PURL.</summary>
public required string Purl { get; init; }
/// <summary>Verdict status.</summary>
public required string Status { get; init; }
/// <summary>Result disposition.</summary>
public required string Disposition { get; init; }
/// <summary>Risk score.</summary>
public double Score { get; init; }
/// <summary>Creation timestamp.</summary>
public required string CreatedAt { get; init; }
}
/// <summary>
/// Response for verdict queries.
/// </summary>
public sealed record VerdictQueryResponse
{
/// <summary>List of verdict summaries.</summary>
public required List<VerdictSummary> Verdicts { get; init; }
/// <summary>Total count of matching verdicts.</summary>
public int TotalCount { get; init; }
/// <summary>Offset used in query.</summary>
public int Offset { get; init; }
/// <summary>Limit used in query.</summary>
public int Limit { get; init; }
/// <summary>Whether there are more results.</summary>
public bool HasMore { get; init; }
}
/// <summary>
/// Request to verify a verdict.
/// </summary>
public sealed record VerdictVerifyRequest
{
/// <summary>Optional trusted key IDs for signature verification.</summary>
public List<string>? TrustedKeyIds { get; init; }
/// <summary>Optional inputs hash to verify against.</summary>
public string? ExpectedInputsHash { get; init; }
}
/// <summary>
/// Response for verdict verification.
/// </summary>
public sealed record VerdictVerifyResponse
{
/// <summary>Verdict ID that was verified.</summary>
public required string VerdictId { get; init; }
/// <summary>Whether the verdict has signatures.</summary>
public bool HasSignatures { get; set; }
/// <summary>Number of signatures on the verdict.</summary>
public int SignatureCount { get; set; }
/// <summary>Whether all signatures are valid.</summary>
public bool Verified { get; set; }
/// <summary>Whether the content-addressable ID is valid.</summary>
public bool ContentIdValid { get; set; }
/// <summary>Verification message or error.</summary>
public string? VerificationMessage { get; set; }
}
/// <summary>
/// Response for deleting expired verdicts.
/// </summary>
public sealed record ExpiredDeleteResponse
{
/// <summary>Number of deleted verdicts.</summary>
public int DeletedCount { get; init; }
}
/// <summary>
/// Generic error response.
/// </summary>
public sealed record ErrorResponse(string Message);

View File

@@ -0,0 +1,351 @@
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;
using StellaOps.Verdict.Persistence;
using StellaOps.Verdict.Schema;
using StellaOps.Verdict.Services;
namespace StellaOps.Verdict.Api;
/// <summary>
/// REST API endpoints for StellaVerdict operations.
/// </summary>
public static class VerdictEndpoints
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
};
private static readonly JsonSerializerOptions JsonLdOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
};
/// <summary>
/// Maps verdict endpoints to the route builder.
/// </summary>
public static void MapVerdictEndpoints(this IEndpointRouteBuilder endpoints)
{
ArgumentNullException.ThrowIfNull(endpoints);
var group = endpoints.MapGroup("/v1/verdicts");
// POST /v1/verdicts - Create and store a verdict
group.MapPost("/", HandleCreate)
.WithName("verdict.create")
.Produces<VerdictResponse>(StatusCodes.Status201Created)
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest)
.RequireAuthorization();
// GET /v1/verdicts/{id} - Get verdict by ID
group.MapGet("/{id}", HandleGet)
.WithName("verdict.get")
.Produces<StellaVerdict>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization();
// GET /v1/verdicts - Query verdicts
group.MapGet("/", HandleQuery)
.WithName("verdict.query")
.Produces<VerdictQueryResponse>(StatusCodes.Status200OK)
.RequireAuthorization();
// POST /v1/verdicts/{id}/verify - Verify verdict signature
group.MapPost("/{id}/verify", HandleVerify)
.WithName("verdict.verify")
.Produces<VerdictVerifyResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization();
// GET /v1/verdicts/{id}/download - Download signed JSON-LD
group.MapGet("/{id}/download", HandleDownload)
.WithName("verdict.download")
.Produces<StellaVerdict>(StatusCodes.Status200OK, "application/ld+json")
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization();
// GET /v1/verdicts/latest - Get latest verdict for PURL+CVE
group.MapGet("/latest", HandleGetLatest)
.WithName("verdict.latest")
.Produces<StellaVerdict>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization();
// DELETE /v1/verdicts/expired - Clean up expired verdicts
group.MapDelete("/expired", HandleDeleteExpired)
.WithName("verdict.deleteExpired")
.Produces<ExpiredDeleteResponse>(StatusCodes.Status200OK)
.RequireAuthorization("verdict:admin");
}
private static async Task<IResult> HandleCreate(
VerdictCreateRequest request,
IVerdictAssemblyService assemblyService,
IVerdictStore store,
HttpContext context,
ILogger<VerdictEndpointsLogger> logger,
CancellationToken cancellationToken)
{
if (request is null)
{
return Results.BadRequest(new ErrorResponse("Request body is required"));
}
var tenantId = GetTenantId(context);
try
{
// Assemble the verdict from the request
var assemblyContext = new VerdictAssemblyContext
{
VulnerabilityId = request.VulnerabilityId,
Purl = request.Purl,
ComponentName = request.ComponentName,
ComponentVersion = request.ComponentVersion,
ImageDigest = request.ImageDigest,
PolicyVerdict = request.PolicyVerdict,
ProofBundle = null, // Could be enhanced to accept proof bundle reference
Knowledge = request.Knowledge,
Generator = request.Generator ?? "StellaOps",
GeneratorVersion = request.GeneratorVersion,
RunId = request.RunId,
};
var verdict = assemblyService.AssembleVerdict(assemblyContext);
// Store the verdict
var storeResult = await store.StoreAsync(verdict, tenantId, cancellationToken);
if (!storeResult.Success)
{
logger.LogError("Failed to store verdict: {Error}", storeResult.Error);
return Results.BadRequest(new ErrorResponse(storeResult.Error ?? "Storage failed"));
}
var response = new VerdictResponse
{
VerdictId = verdict.VerdictId,
Status = verdict.Claim.Status.ToString(),
Disposition = verdict.Result.Disposition,
Score = verdict.Result.Score,
CreatedAt = verdict.Provenance.CreatedAt,
};
return Results.Created($"/v1/verdicts/{Uri.EscapeDataString(verdict.VerdictId)}", response);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create verdict for {Purl}/{Cve}", request.Purl, request.VulnerabilityId);
return Results.BadRequest(new ErrorResponse($"Failed to create verdict: {ex.Message}"));
}
}
private static async Task<IResult> HandleGet(
string id,
IVerdictStore store,
HttpContext context,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(context);
var verdictId = Uri.UnescapeDataString(id);
var verdict = await store.GetAsync(verdictId, tenantId, cancellationToken);
if (verdict is null)
{
return Results.NotFound();
}
return Json(verdict, StatusCodes.Status200OK);
}
private static async Task<IResult> HandleQuery(
string? purl,
string? cve,
string? status,
string? imageDigest,
int? limit,
int? offset,
IVerdictStore store,
HttpContext context,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(context);
VerdictStatus? statusFilter = null;
if (!string.IsNullOrEmpty(status) && Enum.TryParse<VerdictStatus>(status, true, out var parsed))
{
statusFilter = parsed;
}
var query = new VerdictQuery
{
TenantId = tenantId,
Purl = purl,
CveId = cve,
Status = statusFilter,
ImageDigest = imageDigest,
Limit = Math.Min(limit ?? 50, 100),
Offset = offset ?? 0,
};
var result = await store.QueryAsync(query, cancellationToken);
var response = new VerdictQueryResponse
{
Verdicts = result.Verdicts.Select(v => new VerdictSummary
{
VerdictId = v.VerdictId,
VulnerabilityId = v.Subject.VulnerabilityId,
Purl = v.Subject.Purl,
Status = v.Claim.Status.ToString(),
Disposition = v.Result.Disposition,
Score = v.Result.Score,
CreatedAt = v.Provenance.CreatedAt,
}).ToList(),
TotalCount = result.TotalCount,
Offset = result.Offset,
Limit = result.Limit,
HasMore = result.HasMore,
};
return Json(response, StatusCodes.Status200OK);
}
private static async Task<IResult> HandleVerify(
string id,
VerdictVerifyRequest? request,
IVerdictStore store,
IVerdictSigningService signingService,
HttpContext context,
ILogger<VerdictEndpointsLogger> logger,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(context);
var verdictId = Uri.UnescapeDataString(id);
var verdict = await store.GetAsync(verdictId, tenantId, cancellationToken);
if (verdict is null)
{
return Results.NotFound();
}
var response = new VerdictVerifyResponse
{
VerdictId = verdictId,
HasSignatures = !verdict.Signatures.IsDefaultOrEmpty,
SignatureCount = verdict.Signatures.IsDefaultOrEmpty ? 0 : verdict.Signatures.Length,
// Full verification would require trusted keys from request or key store
Verified = false,
VerificationMessage = verdict.Signatures.IsDefaultOrEmpty
? "Verdict has no signatures"
: "Signature verification requires trusted keys",
};
// Verify content-addressable ID
var expectedId = verdict.ComputeVerdictId();
response.ContentIdValid = string.Equals(verdict.VerdictId, expectedId, StringComparison.Ordinal);
if (!response.ContentIdValid)
{
response.VerificationMessage = "Content ID mismatch - verdict may have been tampered with";
}
return Json(response, StatusCodes.Status200OK);
}
private static async Task<IResult> HandleDownload(
string id,
IVerdictStore store,
HttpContext context,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(context);
var verdictId = Uri.UnescapeDataString(id);
var verdict = await store.GetAsync(verdictId, tenantId, cancellationToken);
if (verdict is null)
{
return Results.NotFound();
}
var json = JsonSerializer.Serialize(verdict, JsonLdOptions);
context.Response.Headers.ContentDisposition =
$"attachment; filename=\"verdict-{verdict.Subject.VulnerabilityId}.jsonld\"";
return Results.Content(json, "application/ld+json", Encoding.UTF8, StatusCodes.Status200OK);
}
private static async Task<IResult> HandleGetLatest(
string purl,
string cve,
IVerdictStore store,
HttpContext context,
CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(purl) || string.IsNullOrEmpty(cve))
{
return Results.BadRequest(new ErrorResponse("Both purl and cve query parameters are required"));
}
var tenantId = GetTenantId(context);
var verdict = await store.GetLatestAsync(purl, cve, tenantId, cancellationToken);
if (verdict is null)
{
return Results.NotFound();
}
return Json(verdict, StatusCodes.Status200OK);
}
private static async Task<IResult> HandleDeleteExpired(
IVerdictStore store,
HttpContext context,
ILogger<VerdictEndpointsLogger> logger,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(context);
var deletedCount = await store.DeleteExpiredAsync(tenantId, DateTimeOffset.UtcNow, cancellationToken);
logger.LogInformation("Deleted {Count} expired verdicts for tenant {TenantId}", deletedCount, tenantId);
return Json(new ExpiredDeleteResponse { DeletedCount = deletedCount }, StatusCodes.Status200OK);
}
private static Guid GetTenantId(HttpContext context)
{
// Try to get tenant ID from claims or header
var tenantClaim = context.User.FindFirst("tenant_id")?.Value;
if (!string.IsNullOrEmpty(tenantClaim) && Guid.TryParse(tenantClaim, out var claimTenantId))
{
return claimTenantId;
}
// Fallback to header
if (context.Request.Headers.TryGetValue("X-Tenant-Id", out var headerValue) &&
Guid.TryParse(headerValue.FirstOrDefault(), out var headerTenantId))
{
return headerTenantId;
}
// Default tenant for development
return Guid.Empty;
}
private static IResult Json<T>(T value, int statusCode)
{
var payload = JsonSerializer.Serialize(value, JsonOptions);
return Results.Content(payload, "application/json", Encoding.UTF8, statusCode);
}
}
/// <summary>
/// Marker class for logger category.
/// </summary>
internal sealed class VerdictEndpointsLogger { }

View File

@@ -0,0 +1,471 @@
{
"@context": {
"@version": 1.1,
"stella": "https://stella-ops.org/schemas/verdict/1.0#",
"xsd": "http://www.w3.org/2001/XMLSchema#",
"schema": "http://schema.org/",
"sec": "https://w3id.org/security#",
"intoto": "https://in-toto.io/attestation/v1#",
"StellaVerdict": {
"@id": "stella:StellaVerdict",
"@context": {
"verdictId": {
"@id": "stella:verdictId",
"@type": "xsd:anyURI"
},
"version": {
"@id": "stella:version",
"@type": "xsd:string"
},
"subject": {
"@id": "stella:subject",
"@type": "@id"
},
"claim": {
"@id": "stella:claim",
"@type": "@id"
},
"inputs": {
"@id": "stella:inputs",
"@type": "@id"
},
"evidenceGraph": {
"@id": "stella:evidenceGraph",
"@type": "@id"
},
"policyPath": {
"@id": "stella:policyPath",
"@type": "@id",
"@container": "@list"
},
"result": {
"@id": "stella:result",
"@type": "@id"
},
"provenance": {
"@id": "stella:provenance",
"@type": "@id"
},
"signatures": {
"@id": "sec:signature",
"@type": "@id",
"@container": "@set"
}
}
},
"VerdictSubject": {
"@id": "stella:VerdictSubject",
"@context": {
"vulnerabilityId": {
"@id": "stella:vulnerabilityId",
"@type": "xsd:string"
},
"purl": {
"@id": "stella:purl",
"@type": "xsd:anyURI"
},
"componentName": {
"@id": "schema:name",
"@type": "xsd:string"
},
"componentVersion": {
"@id": "schema:version",
"@type": "xsd:string"
},
"imageDigest": {
"@id": "intoto:digest",
"@type": "xsd:string"
},
"subjectDigest": {
"@id": "stella:subjectDigest",
"@type": "xsd:string"
}
}
},
"VerdictClaim": {
"@id": "stella:VerdictClaim",
"@context": {
"status": {
"@id": "stella:status",
"@type": "xsd:string"
},
"confidence": {
"@id": "stella:confidence",
"@type": "xsd:decimal"
},
"confidenceBand": {
"@id": "stella:confidenceBand",
"@type": "xsd:string"
},
"reason": {
"@id": "schema:description",
"@type": "xsd:string"
},
"vexStatus": {
"@id": "stella:vexStatus",
"@type": "xsd:string"
},
"vexJustification": {
"@id": "stella:vexJustification",
"@type": "xsd:string"
}
}
},
"VerdictInputs": {
"@id": "stella:VerdictInputs",
"@context": {
"advisorySources": {
"@id": "stella:advisorySources",
"@type": "@id",
"@container": "@set"
},
"vexStatements": {
"@id": "stella:vexStatements",
"@type": "@id",
"@container": "@set"
},
"cvssScores": {
"@id": "stella:cvssScores",
"@type": "@id",
"@container": "@set"
},
"epss": {
"@id": "stella:epss",
"@type": "@id"
},
"kev": {
"@id": "stella:kev",
"@type": "@id"
},
"reachability": {
"@id": "stella:reachability",
"@type": "@id"
}
}
},
"VerdictEvidenceGraph": {
"@id": "stella:VerdictEvidenceGraph",
"@context": {
"nodes": {
"@id": "stella:nodes",
"@type": "@id",
"@container": "@set"
},
"edges": {
"@id": "stella:edges",
"@type": "@id",
"@container": "@set"
},
"root": {
"@id": "stella:root",
"@type": "xsd:string"
}
}
},
"VerdictEvidenceNode": {
"@id": "stella:VerdictEvidenceNode",
"@context": {
"id": {
"@id": "@id"
},
"type": {
"@id": "stella:evidenceType",
"@type": "xsd:string"
},
"label": {
"@id": "schema:name",
"@type": "xsd:string"
},
"hashAlgorithm": {
"@id": "sec:digestAlgorithm",
"@type": "xsd:string"
},
"capturedAt": {
"@id": "schema:dateCreated",
"@type": "xsd:dateTime"
},
"uri": {
"@id": "schema:url",
"@type": "xsd:anyURI"
},
"metadata": {
"@id": "stella:metadata",
"@type": "@id"
}
}
},
"VerdictEvidenceEdge": {
"@id": "stella:VerdictEvidenceEdge",
"@context": {
"from": {
"@id": "stella:fromNode",
"@type": "xsd:string"
},
"to": {
"@id": "stella:toNode",
"@type": "xsd:string"
},
"relationship": {
"@id": "stella:relationship",
"@type": "xsd:string"
}
}
},
"VerdictPolicyStep": {
"@id": "stella:VerdictPolicyStep",
"@context": {
"ruleId": {
"@id": "stella:ruleId",
"@type": "xsd:string"
},
"ruleName": {
"@id": "schema:name",
"@type": "xsd:string"
},
"matched": {
"@id": "stella:matched",
"@type": "xsd:boolean"
},
"action": {
"@id": "stella:action",
"@type": "xsd:string"
},
"reason": {
"@id": "schema:description",
"@type": "xsd:string"
},
"order": {
"@id": "stella:order",
"@type": "xsd:integer"
}
}
},
"VerdictResult": {
"@id": "stella:VerdictResult",
"@context": {
"disposition": {
"@id": "stella:disposition",
"@type": "xsd:string"
},
"score": {
"@id": "stella:score",
"@type": "xsd:decimal"
},
"matchedRule": {
"@id": "stella:matchedRule",
"@type": "xsd:string"
},
"ruleAction": {
"@id": "stella:ruleAction",
"@type": "xsd:string"
},
"quiet": {
"@id": "stella:quiet",
"@type": "xsd:boolean"
},
"quietedBy": {
"@id": "stella:quietedBy",
"@type": "xsd:string"
},
"expiresAt": {
"@id": "schema:expires",
"@type": "xsd:dateTime"
}
}
},
"VerdictProvenance": {
"@id": "stella:VerdictProvenance",
"@context": {
"generator": {
"@id": "schema:creator",
"@type": "xsd:string"
},
"generatorVersion": {
"@id": "schema:softwareVersion",
"@type": "xsd:string"
},
"runId": {
"@id": "stella:runId",
"@type": "xsd:string"
},
"createdAt": {
"@id": "schema:dateCreated",
"@type": "xsd:dateTime"
},
"policyBundleId": {
"@id": "stella:policyBundleId",
"@type": "xsd:string"
},
"policyBundleVersion": {
"@id": "stella:policyBundleVersion",
"@type": "xsd:string"
}
}
},
"VerdictSignature": {
"@id": "sec:Signature",
"@context": {
"keyid": {
"@id": "sec:keyId",
"@type": "xsd:string"
},
"sig": {
"@id": "sec:signatureValue",
"@type": "xsd:string"
},
"cert": {
"@id": "sec:certificate",
"@type": "xsd:string"
}
}
},
"VerdictAdvisorySource": {
"@id": "stella:VerdictAdvisorySource",
"@context": {
"source": {
"@id": "schema:publisher",
"@type": "xsd:string"
},
"advisoryId": {
"@id": "stella:advisoryId",
"@type": "xsd:string"
},
"fetchedAt": {
"@id": "schema:datePublished",
"@type": "xsd:dateTime"
},
"contentHash": {
"@id": "sec:digestValue",
"@type": "xsd:string"
}
}
},
"VerdictVexInput": {
"@id": "stella:VerdictVexInput",
"@context": {
"vexId": {
"@id": "stella:vexId",
"@type": "xsd:string"
},
"issuer": {
"@id": "schema:publisher",
"@type": "xsd:string"
},
"status": {
"@id": "stella:vexStatus",
"@type": "xsd:string"
},
"justification": {
"@id": "stella:justification",
"@type": "xsd:string"
},
"timestamp": {
"@id": "schema:dateCreated",
"@type": "xsd:dateTime"
}
}
},
"VerdictCvssInput": {
"@id": "stella:VerdictCvssInput",
"@context": {
"version": {
"@id": "stella:cvssVersion",
"@type": "xsd:string"
},
"vector": {
"@id": "stella:cvssVector",
"@type": "xsd:string"
},
"baseScore": {
"@id": "stella:baseScore",
"@type": "xsd:decimal"
},
"temporalScore": {
"@id": "stella:temporalScore",
"@type": "xsd:decimal"
},
"environmentalScore": {
"@id": "stella:environmentalScore",
"@type": "xsd:decimal"
},
"source": {
"@id": "schema:publisher",
"@type": "xsd:string"
}
}
},
"VerdictEpssInput": {
"@id": "stella:VerdictEpssInput",
"@context": {
"probability": {
"@id": "stella:epssProbability",
"@type": "xsd:decimal"
},
"percentile": {
"@id": "stella:epssPercentile",
"@type": "xsd:decimal"
},
"date": {
"@id": "schema:datePublished",
"@type": "xsd:date"
}
}
},
"VerdictKevInput": {
"@id": "stella:VerdictKevInput",
"@context": {
"inKev": {
"@id": "stella:inKev",
"@type": "xsd:boolean"
},
"dateAdded": {
"@id": "stella:kevDateAdded",
"@type": "xsd:date"
},
"dueDate": {
"@id": "stella:kevDueDate",
"@type": "xsd:date"
}
}
},
"VerdictReachabilityInput": {
"@id": "stella:VerdictReachabilityInput",
"@context": {
"isReachable": {
"@id": "stella:isReachable",
"@type": "xsd:boolean"
},
"confidence": {
"@id": "stella:reachabilityConfidence",
"@type": "xsd:decimal"
},
"method": {
"@id": "stella:analysisMethod",
"@type": "xsd:string"
},
"callPath": {
"@id": "stella:callPath",
"@type": "xsd:string",
"@container": "@list"
}
}
}
}
}

View File

@@ -0,0 +1,445 @@
// VerdictBundleExporter - Export replay bundle for offline verification
// Sprint: SPRINT_1227_0014_0001_BE_stellaverdict_consolidation
// Task 9: Verdict Replay Bundle Exporter
using System.IO.Compression;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Verdict.Schema;
namespace StellaOps.Verdict.Export;
/// <summary>
/// Service for exporting verdict replay bundles for offline verification.
/// </summary>
public interface IVerdictBundleExporter
{
/// <summary>
/// Export a verdict with all inputs to a replay bundle.
/// </summary>
/// <param name="verdict">The verdict to export.</param>
/// <param name="context">Additional context for the bundle.</param>
/// <param name="outputPath">Output path for the bundle (.tar.zst or directory).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result of the export operation.</returns>
Task<VerdictBundleExportResult> ExportAsync(
StellaVerdict verdict,
VerdictBundleContext context,
string outputPath,
CancellationToken cancellationToken = default);
/// <summary>
/// Export a verdict bundle to a stream.
/// </summary>
Task<VerdictBundleExportResult> ExportToStreamAsync(
StellaVerdict verdict,
VerdictBundleContext context,
Stream outputStream,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Additional context for bundle export.
/// </summary>
public sealed class VerdictBundleContext
{
/// <summary>
/// SBOM slice relevant to the verdict.
/// </summary>
public string? SbomSliceJson { get; set; }
/// <summary>
/// Advisory feed snapshots.
/// </summary>
public IList<VerdictFeedSnapshot> FeedSnapshots { get; set; } = new List<VerdictFeedSnapshot>();
/// <summary>
/// Policy bundle used for evaluation.
/// </summary>
public string? PolicyBundleJson { get; set; }
/// <summary>
/// Policy bundle version.
/// </summary>
public string? PolicyBundleVersion { get; set; }
/// <summary>
/// Reachability analysis data.
/// </summary>
public string? ReachabilityJson { get; set; }
/// <summary>
/// Runtime configuration.
/// </summary>
public string? RuntimeConfigJson { get; set; }
/// <summary>
/// Include full signatures in bundle.
/// </summary>
public bool IncludeSignatures { get; set; } = true;
/// <summary>
/// Compress the bundle.
/// </summary>
public bool Compress { get; set; } = true;
}
/// <summary>
/// Advisory feed snapshot for replay.
/// </summary>
public sealed class VerdictFeedSnapshot
{
/// <summary>
/// Feed source name (e.g., "nvd", "debian-vex").
/// </summary>
public required string Source { get; set; }
/// <summary>
/// Feed date.
/// </summary>
public required DateOnly Date { get; set; }
/// <summary>
/// Feed content (JSON).
/// </summary>
public required string ContentJson { get; set; }
/// <summary>
/// Content hash for verification.
/// </summary>
public string? ContentHash { get; set; }
}
/// <summary>
/// Result of bundle export.
/// </summary>
public sealed record VerdictBundleExportResult
{
public required bool Success { get; init; }
public string? OutputPath { get; init; }
public string? ManifestHash { get; init; }
public long SizeBytes { get; init; }
public int FileCount { get; init; }
public string? ErrorMessage { get; init; }
}
/// <summary>
/// Default implementation of verdict bundle exporter.
/// </summary>
public sealed class VerdictBundleExporter : IVerdictBundleExporter
{
private readonly ILogger<VerdictBundleExporter> _logger;
private readonly TimeProvider _timeProvider;
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
public VerdictBundleExporter(
ILogger<VerdictBundleExporter> logger,
TimeProvider? timeProvider = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<VerdictBundleExportResult> ExportAsync(
StellaVerdict verdict,
VerdictBundleContext context,
string outputPath,
CancellationToken cancellationToken = default)
{
try
{
// Create output directory if exporting to directory
if (!outputPath.EndsWith(".tar.zst", StringComparison.OrdinalIgnoreCase) &&
!outputPath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase) &&
!outputPath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
{
return await ExportToDirectoryAsync(verdict, context, outputPath, cancellationToken);
}
// Export to compressed archive
await using var fileStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write);
var result = await ExportToStreamAsync(verdict, context, fileStream, cancellationToken);
return result with { OutputPath = outputPath };
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to export verdict bundle to {Path}", outputPath);
return new VerdictBundleExportResult
{
Success = false,
ErrorMessage = ex.Message
};
}
}
public async Task<VerdictBundleExportResult> ExportToStreamAsync(
StellaVerdict verdict,
VerdictBundleContext context,
Stream outputStream,
CancellationToken cancellationToken = default)
{
try
{
var manifest = new BundleManifest
{
Version = "1.0",
CreatedAt = _timeProvider.GetUtcNow().ToString("O"),
VerdictId = verdict.VerdictId,
VulnerabilityId = verdict.Subject.VulnerabilityId,
Purl = verdict.Subject.Purl,
Files = new List<BundleFileEntry>()
};
int fileCount = 0;
long totalSize = 0;
using var archive = new ZipArchive(outputStream, ZipArchiveMode.Create, leaveOpen: true);
// Add verdict.json
var verdictJson = JsonSerializer.Serialize(verdict, JsonOptions);
await AddEntryAsync(archive, "verdict.json", verdictJson, manifest);
fileCount++;
totalSize += verdictJson.Length;
// Add SBOM slice if available
if (!string.IsNullOrEmpty(context.SbomSliceJson))
{
await AddEntryAsync(archive, "sbom-slice.json", context.SbomSliceJson, manifest);
fileCount++;
totalSize += context.SbomSliceJson.Length;
}
// Add feed snapshots
foreach (var feed in context.FeedSnapshots)
{
var feedPath = $"feeds/{feed.Source}-{feed.Date:yyyy-MM-dd}.json";
await AddEntryAsync(archive, feedPath, feed.ContentJson, manifest);
fileCount++;
totalSize += feed.ContentJson.Length;
}
// Add policy bundle
if (!string.IsNullOrEmpty(context.PolicyBundleJson))
{
var policyPath = $"policy/bundle-{context.PolicyBundleVersion ?? "latest"}.json";
await AddEntryAsync(archive, policyPath, context.PolicyBundleJson, manifest);
fileCount++;
totalSize += context.PolicyBundleJson.Length;
}
// Add reachability data
if (!string.IsNullOrEmpty(context.ReachabilityJson))
{
await AddEntryAsync(archive, "callgraph/reachability.json", context.ReachabilityJson, manifest);
fileCount++;
totalSize += context.ReachabilityJson.Length;
}
// Add runtime config
if (!string.IsNullOrEmpty(context.RuntimeConfigJson))
{
await AddEntryAsync(archive, "config/runtime.json", context.RuntimeConfigJson, manifest);
fileCount++;
totalSize += context.RuntimeConfigJson.Length;
}
// Add manifest as last file
var manifestJson = JsonSerializer.Serialize(manifest, JsonOptions);
var manifestEntry = archive.CreateEntry("manifest.json", CompressionLevel.Optimal);
await using (var manifestStream = manifestEntry.Open())
{
await manifestStream.WriteAsync(Encoding.UTF8.GetBytes(manifestJson), cancellationToken);
}
fileCount++;
totalSize += manifestJson.Length;
var manifestHash = ComputeHash(manifestJson);
_logger.LogInformation(
"Exported verdict bundle {VerdictId} with {FileCount} files ({Size} bytes)",
verdict.VerdictId, fileCount, totalSize);
return new VerdictBundleExportResult
{
Success = true,
ManifestHash = manifestHash,
SizeBytes = totalSize,
FileCount = fileCount
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to export verdict bundle to stream");
return new VerdictBundleExportResult
{
Success = false,
ErrorMessage = ex.Message
};
}
}
private async Task<VerdictBundleExportResult> ExportToDirectoryAsync(
StellaVerdict verdict,
VerdictBundleContext context,
string outputPath,
CancellationToken cancellationToken)
{
Directory.CreateDirectory(outputPath);
var manifest = new BundleManifest
{
Version = "1.0",
CreatedAt = _timeProvider.GetUtcNow().ToString("O"),
VerdictId = verdict.VerdictId,
VulnerabilityId = verdict.Subject.VulnerabilityId,
Purl = verdict.Subject.Purl,
Files = new List<BundleFileEntry>()
};
int fileCount = 0;
long totalSize = 0;
// Write verdict.json
var verdictJson = JsonSerializer.Serialize(verdict, JsonOptions);
await WriteFileAsync(outputPath, "verdict.json", verdictJson, manifest, cancellationToken);
fileCount++;
totalSize += verdictJson.Length;
// Write SBOM slice
if (!string.IsNullOrEmpty(context.SbomSliceJson))
{
await WriteFileAsync(outputPath, "sbom-slice.json", context.SbomSliceJson, manifest, cancellationToken);
fileCount++;
totalSize += context.SbomSliceJson.Length;
}
// Write feed snapshots
if (context.FeedSnapshots.Count > 0)
{
Directory.CreateDirectory(Path.Combine(outputPath, "feeds"));
foreach (var feed in context.FeedSnapshots)
{
var feedPath = $"feeds/{feed.Source}-{feed.Date:yyyy-MM-dd}.json";
await WriteFileAsync(outputPath, feedPath, feed.ContentJson, manifest, cancellationToken);
fileCount++;
totalSize += feed.ContentJson.Length;
}
}
// Write policy bundle
if (!string.IsNullOrEmpty(context.PolicyBundleJson))
{
Directory.CreateDirectory(Path.Combine(outputPath, "policy"));
var policyPath = $"policy/bundle-{context.PolicyBundleVersion ?? "latest"}.json";
await WriteFileAsync(outputPath, policyPath, context.PolicyBundleJson, manifest, cancellationToken);
fileCount++;
totalSize += context.PolicyBundleJson.Length;
}
// Write reachability data
if (!string.IsNullOrEmpty(context.ReachabilityJson))
{
Directory.CreateDirectory(Path.Combine(outputPath, "callgraph"));
await WriteFileAsync(outputPath, "callgraph/reachability.json", context.ReachabilityJson, manifest, cancellationToken);
fileCount++;
totalSize += context.ReachabilityJson.Length;
}
// Write runtime config
if (!string.IsNullOrEmpty(context.RuntimeConfigJson))
{
Directory.CreateDirectory(Path.Combine(outputPath, "config"));
await WriteFileAsync(outputPath, "config/runtime.json", context.RuntimeConfigJson, manifest, cancellationToken);
fileCount++;
totalSize += context.RuntimeConfigJson.Length;
}
// Write manifest
var manifestJson = JsonSerializer.Serialize(manifest, JsonOptions);
await File.WriteAllTextAsync(Path.Combine(outputPath, "manifest.json"), manifestJson, cancellationToken);
fileCount++;
totalSize += manifestJson.Length;
var manifestHash = ComputeHash(manifestJson);
_logger.LogInformation(
"Exported verdict bundle {VerdictId} to {Path} with {FileCount} files",
verdict.VerdictId, outputPath, fileCount);
return new VerdictBundleExportResult
{
Success = true,
OutputPath = outputPath,
ManifestHash = manifestHash,
SizeBytes = totalSize,
FileCount = fileCount
};
}
private static async Task AddEntryAsync(
ZipArchive archive,
string path,
string content,
BundleManifest manifest)
{
var entry = archive.CreateEntry(path, CompressionLevel.Optimal);
await using var stream = entry.Open();
await stream.WriteAsync(Encoding.UTF8.GetBytes(content));
manifest.Files.Add(new BundleFileEntry
{
Path = path,
Hash = ComputeHash(content),
Size = content.Length
});
}
private static async Task WriteFileAsync(
string basePath,
string relativePath,
string content,
BundleManifest manifest,
CancellationToken cancellationToken)
{
var fullPath = Path.Combine(basePath, relativePath);
await File.WriteAllTextAsync(fullPath, content, cancellationToken);
manifest.Files.Add(new BundleFileEntry
{
Path = relativePath,
Hash = ComputeHash(content),
Size = content.Length
});
}
private static string ComputeHash(string content)
{
var bytes = Encoding.UTF8.GetBytes(content);
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private sealed class BundleManifest
{
public required string Version { get; set; }
public required string CreatedAt { get; set; }
public required string VerdictId { get; set; }
public required string VulnerabilityId { get; set; }
public required string Purl { get; set; }
public required List<BundleFileEntry> Files { get; set; }
}
private sealed class BundleFileEntry
{
public required string Path { get; set; }
public required string Hash { get; set; }
public required long Size { get; set; }
}
}

View File

@@ -0,0 +1,825 @@
// OciAttestationPublisher - OCI registry attachment for StellaVerdict attestations
// Sprint: SPRINT_1227_0014_0001_BE_stellaverdict_consolidation
// Task 6: OCI Attestation Publisher
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Verdict.Schema;
namespace StellaOps.Verdict.Oci;
/// <summary>
/// Service for publishing StellaVerdict attestations to OCI registries.
/// </summary>
public interface IOciAttestationPublisher
{
/// <summary>
/// Publish a StellaVerdict attestation to an OCI artifact as a referrer.
/// </summary>
/// <param name="verdict">The verdict to publish.</param>
/// <param name="imageReference">OCI image reference (registry/repo:tag@sha256:digest).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result of the publish operation.</returns>
Task<OciPublishResult> PublishAsync(
StellaVerdict verdict,
string imageReference,
CancellationToken cancellationToken = default);
/// <summary>
/// Fetch a StellaVerdict attestation from an OCI artifact.
/// </summary>
/// <param name="imageReference">OCI image reference.</param>
/// <param name="verdictId">Optional verdict ID to filter.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The fetched verdict or null if not found.</returns>
Task<StellaVerdict?> FetchAsync(
string imageReference,
string? verdictId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// List all StellaVerdict attestations for an OCI artifact.
/// </summary>
Task<IReadOnlyList<OciVerdictEntry>> ListAsync(
string imageReference,
CancellationToken cancellationToken = default);
/// <summary>
/// Remove a StellaVerdict attestation from an OCI artifact.
/// </summary>
Task<bool> RemoveAsync(
string imageReference,
string verdictId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of publishing a verdict to OCI.
/// </summary>
public sealed record OciPublishResult
{
public required bool Success { get; init; }
public string? OciDigest { get; init; }
public string? ManifestDigest { get; init; }
public string? ErrorMessage { get; init; }
public TimeSpan Duration { get; init; }
public bool WasSkippedOffline { get; init; }
}
/// <summary>
/// Entry in the list of OCI verdict attachments.
/// </summary>
public sealed record OciVerdictEntry
{
public required string VerdictId { get; init; }
public required string VulnerabilityId { get; init; }
public required string Purl { get; init; }
public required string OciDigest { get; init; }
public required DateTimeOffset AttachedAt { get; init; }
public required long SizeBytes { get; init; }
}
/// <summary>
/// Default implementation using ORAS/OCI referrers API patterns.
/// </summary>
public sealed class OciAttestationPublisher : IOciAttestationPublisher
{
private readonly IOptionsMonitor<OciPublisherOptions> _options;
private readonly ILogger<OciAttestationPublisher> _logger;
private readonly TimeProvider _timeProvider;
private readonly HttpClient _httpClient;
/// <summary>
/// ORAS artifact type for StellaVerdict attestations.
/// </summary>
public const string ArtifactType = "application/vnd.stellaops.verdict+json";
/// <summary>
/// Media type for DSSE envelope containing verdict.
/// </summary>
public const string DsseMediaType = "application/vnd.dsse.envelope.v1+json";
/// <summary>
/// Media type for JSON-LD verdict.
/// </summary>
public const string JsonLdMediaType = "application/ld+json";
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
public OciAttestationPublisher(
IOptionsMonitor<OciPublisherOptions> options,
ILogger<OciAttestationPublisher> logger,
HttpClient? httpClient = null,
TimeProvider? timeProvider = null)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_httpClient = httpClient ?? new HttpClient();
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<OciPublishResult> PublishAsync(
StellaVerdict verdict,
string imageReference,
CancellationToken cancellationToken = default)
{
var startTime = _timeProvider.GetUtcNow();
var opts = _options.CurrentValue;
// Handle offline/air-gap mode
if (opts.OfflineMode)
{
_logger.LogInformation(
"Offline mode enabled, skipping OCI publish for verdict {VerdictId}",
verdict.VerdictId);
// Store locally if configured
if (!string.IsNullOrEmpty(opts.OfflineStoragePath))
{
await StoreOfflineAsync(verdict, opts.OfflineStoragePath, cancellationToken);
}
return new OciPublishResult
{
Success = true,
WasSkippedOffline = true,
Duration = _timeProvider.GetUtcNow() - startTime
};
}
if (!opts.Enabled)
{
_logger.LogDebug("OCI publishing disabled, skipping for {Reference}", imageReference);
return new OciPublishResult
{
Success = false,
ErrorMessage = "OCI publishing is disabled",
Duration = _timeProvider.GetUtcNow() - startTime
};
}
try
{
// Parse reference
var parsed = ParseReference(imageReference);
if (parsed is null)
{
return new OciPublishResult
{
Success = false,
ErrorMessage = $"Invalid OCI reference: {imageReference}",
Duration = _timeProvider.GetUtcNow() - startTime
};
}
// Serialize verdict to JSON
var verdictJson = JsonSerializer.Serialize(verdict, JsonOptions);
var verdictBytes = Encoding.UTF8.GetBytes(verdictJson);
var verdictDigest = ComputeSha256(verdictBytes);
_logger.LogInformation(
"Publishing StellaVerdict {VerdictId} to {Reference} ({Size} bytes)",
verdict.VerdictId, imageReference, verdictBytes.Length);
// Step 1: Push the verdict as a blob
var blobDigest = await PushBlobAsync(parsed, verdictBytes, cancellationToken);
if (blobDigest is null)
{
return new OciPublishResult
{
Success = false,
ErrorMessage = "Failed to push verdict blob",
Duration = _timeProvider.GetUtcNow() - startTime
};
}
// Step 2: Create and push artifact manifest with subject reference
var manifestDigest = await PushArtifactManifestAsync(
parsed,
blobDigest,
verdictBytes.Length,
verdict,
cancellationToken);
if (manifestDigest is null)
{
return new OciPublishResult
{
Success = false,
ErrorMessage = "Failed to push artifact manifest",
Duration = _timeProvider.GetUtcNow() - startTime
};
}
// Log for audit trail
_logger.LogInformation(
"Successfully published StellaVerdict {VerdictId} to {Reference}, manifest={Manifest}",
verdict.VerdictId, imageReference, manifestDigest);
return new OciPublishResult
{
Success = true,
OciDigest = blobDigest,
ManifestDigest = manifestDigest,
Duration = _timeProvider.GetUtcNow() - startTime
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to publish StellaVerdict to {Reference}", imageReference);
return new OciPublishResult
{
Success = false,
ErrorMessage = ex.Message,
Duration = _timeProvider.GetUtcNow() - startTime
};
}
}
public async Task<StellaVerdict?> FetchAsync(
string imageReference,
string? verdictId = null,
CancellationToken cancellationToken = default)
{
var opts = _options.CurrentValue;
if (opts.OfflineMode && !string.IsNullOrEmpty(opts.OfflineStoragePath))
{
// Try offline storage first
return await FetchOfflineAsync(verdictId, opts.OfflineStoragePath, cancellationToken);
}
if (!opts.Enabled)
{
_logger.LogDebug("OCI publishing disabled, skipping fetch for {Reference}", imageReference);
return null;
}
try
{
var parsed = ParseReference(imageReference);
if (parsed is null)
{
_logger.LogWarning("Invalid OCI reference: {Reference}", imageReference);
return null;
}
// Query referrers API for verdict attestations
// GET /v2/{name}/referrers/{digest}?artifactType={ArtifactType}
var referrers = await FetchReferrersAsync(parsed, cancellationToken);
if (referrers is null || referrers.Count == 0)
{
return null;
}
// Find matching verdict
foreach (var referrer in referrers)
{
var verdict = await FetchVerdictBlobAsync(parsed, referrer.Digest, cancellationToken);
if (verdict is not null)
{
if (verdictId is null || verdict.VerdictId == verdictId)
{
return verdict;
}
}
}
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fetch StellaVerdict from {Reference}", imageReference);
return null;
}
}
public async Task<IReadOnlyList<OciVerdictEntry>> ListAsync(
string imageReference,
CancellationToken cancellationToken = default)
{
var opts = _options.CurrentValue;
if (!opts.Enabled && !opts.OfflineMode)
{
return [];
}
try
{
var parsed = ParseReference(imageReference);
if (parsed is null)
{
return [];
}
var referrers = await FetchReferrersAsync(parsed, cancellationToken);
if (referrers is null)
{
return [];
}
var entries = new List<OciVerdictEntry>();
foreach (var referrer in referrers)
{
var verdict = await FetchVerdictBlobAsync(parsed, referrer.Digest, cancellationToken);
if (verdict is not null)
{
entries.Add(new OciVerdictEntry
{
VerdictId = verdict.VerdictId,
VulnerabilityId = verdict.Subject.VulnerabilityId,
Purl = verdict.Subject.Purl,
OciDigest = referrer.Digest,
AttachedAt = referrer.CreatedAt ?? _timeProvider.GetUtcNow(),
SizeBytes = referrer.Size
});
}
}
return entries;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to list StellaVerdicts for {Reference}", imageReference);
return [];
}
}
public async Task<bool> RemoveAsync(
string imageReference,
string verdictId,
CancellationToken cancellationToken = default)
{
var opts = _options.CurrentValue;
if (!opts.Enabled)
{
return false;
}
try
{
// Find and delete the referrer manifest
_logger.LogInformation(
"Would remove StellaVerdict {VerdictId} from {Reference}",
verdictId, imageReference);
// Note: Full implementation requires finding the manifest digest
// and issuing DELETE /v2/{name}/manifests/{digest}
return false;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to remove StellaVerdict from {Reference}", imageReference);
return false;
}
}
private async Task StoreOfflineAsync(
StellaVerdict verdict,
string storagePath,
CancellationToken cancellationToken)
{
try
{
Directory.CreateDirectory(storagePath);
var fileName = $"verdict-{Uri.EscapeDataString(verdict.VerdictId)}.json";
var filePath = Path.Combine(storagePath, fileName);
var json = JsonSerializer.Serialize(verdict, new JsonSerializerOptions
{
WriteIndented = true
});
await File.WriteAllTextAsync(filePath, json, cancellationToken);
_logger.LogDebug("Stored verdict offline at {Path}", filePath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to store verdict offline");
}
}
private async Task<StellaVerdict?> FetchOfflineAsync(
string? verdictId,
string storagePath,
CancellationToken cancellationToken)
{
try
{
if (!Directory.Exists(storagePath))
{
return null;
}
if (!string.IsNullOrEmpty(verdictId))
{
var fileName = $"verdict-{Uri.EscapeDataString(verdictId)}.json";
var filePath = Path.Combine(storagePath, fileName);
if (File.Exists(filePath))
{
var json = await File.ReadAllTextAsync(filePath, cancellationToken);
return JsonSerializer.Deserialize<StellaVerdict>(json, JsonOptions);
}
}
// Return first available
var files = Directory.GetFiles(storagePath, "verdict-*.json");
if (files.Length > 0)
{
var json = await File.ReadAllTextAsync(files[0], cancellationToken);
return JsonSerializer.Deserialize<StellaVerdict>(json, JsonOptions);
}
return null;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch verdict from offline storage");
return null;
}
}
private async Task<string?> PushBlobAsync(
OciReference reference,
byte[] content,
CancellationToken cancellationToken)
{
var opts = _options.CurrentValue;
var digest = ComputeSha256(content);
// POST /v2/{name}/blobs/uploads/
// then PUT /v2/{name}/blobs/uploads/{uuid}?digest=sha256:xxx
var baseUrl = $"https://{reference.Registry}/v2/{reference.Repository}";
try
{
// Start upload session
var initiateUrl = $"{baseUrl}/blobs/uploads/";
var initiateRequest = new HttpRequestMessage(HttpMethod.Post, initiateUrl);
AddAuthHeaders(initiateRequest, opts);
var initiateResponse = await _httpClient.SendAsync(initiateRequest, cancellationToken);
if (!initiateResponse.IsSuccessStatusCode)
{
_logger.LogWarning(
"Failed to initiate blob upload: {Status}",
initiateResponse.StatusCode);
return null;
}
// Get upload URL from Location header
var location = initiateResponse.Headers.Location?.ToString();
if (string.IsNullOrEmpty(location))
{
return null;
}
// Complete upload
var uploadUrl = location.Contains('?')
? $"{location}&digest={digest}"
: $"{location}?digest={digest}";
var uploadRequest = new HttpRequestMessage(HttpMethod.Put, uploadUrl)
{
Content = new ByteArrayContent(content)
};
uploadRequest.Content.Headers.ContentType =
new System.Net.Http.Headers.MediaTypeHeaderValue(JsonLdMediaType);
AddAuthHeaders(uploadRequest, opts);
var uploadResponse = await _httpClient.SendAsync(uploadRequest, cancellationToken);
if (uploadResponse.IsSuccessStatusCode)
{
return digest;
}
_logger.LogWarning("Failed to upload blob: {Status}", uploadResponse.StatusCode);
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to push blob to registry");
return null;
}
}
private async Task<string?> PushArtifactManifestAsync(
OciReference reference,
string blobDigest,
long blobSize,
StellaVerdict verdict,
CancellationToken cancellationToken)
{
var opts = _options.CurrentValue;
// Create OCI artifact manifest with subject reference
var manifest = new
{
schemaVersion = 2,
mediaType = "application/vnd.oci.artifact.manifest.v1+json",
artifactType = ArtifactType,
blobs = new[]
{
new
{
mediaType = JsonLdMediaType,
digest = blobDigest,
size = blobSize,
annotations = new Dictionary<string, string>
{
["org.stellaops.verdict.id"] = verdict.VerdictId,
["org.stellaops.verdict.cve"] = verdict.Subject.VulnerabilityId,
["org.stellaops.verdict.purl"] = verdict.Subject.Purl,
["org.stellaops.verdict.status"] = verdict.Claim.Status.ToString()
}
}
},
subject = reference.Digest is not null
? new { mediaType = "application/vnd.oci.image.manifest.v1+json", digest = reference.Digest }
: null,
annotations = new Dictionary<string, string>
{
["org.opencontainers.image.created"] = _timeProvider.GetUtcNow().ToString("O"),
["org.stellaops.verdict.version"] = verdict.Version
}
};
var manifestJson = JsonSerializer.Serialize(manifest, JsonOptions);
var manifestBytes = Encoding.UTF8.GetBytes(manifestJson);
var manifestDigest = ComputeSha256(manifestBytes);
// PUT /v2/{name}/manifests/{reference}
var baseUrl = $"https://{reference.Registry}/v2/{reference.Repository}";
var manifestUrl = $"{baseUrl}/manifests/{manifestDigest}";
try
{
var request = new HttpRequestMessage(HttpMethod.Put, manifestUrl)
{
Content = new ByteArrayContent(manifestBytes)
};
request.Content.Headers.ContentType =
new System.Net.Http.Headers.MediaTypeHeaderValue("application/vnd.oci.artifact.manifest.v1+json");
AddAuthHeaders(request, opts);
var response = await _httpClient.SendAsync(request, cancellationToken);
if (response.IsSuccessStatusCode)
{
return manifestDigest;
}
_logger.LogWarning("Failed to push manifest: {Status}", response.StatusCode);
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to push artifact manifest");
return null;
}
}
private async Task<IReadOnlyList<ReferrerEntry>?> FetchReferrersAsync(
OciReference reference,
CancellationToken cancellationToken)
{
if (reference.Digest is null)
{
return null;
}
var opts = _options.CurrentValue;
var baseUrl = $"https://{reference.Registry}/v2/{reference.Repository}";
var referrersUrl = $"{baseUrl}/referrers/{reference.Digest}?artifactType={Uri.EscapeDataString(ArtifactType)}";
try
{
var request = new HttpRequestMessage(HttpMethod.Get, referrersUrl);
AddAuthHeaders(request, opts);
var response = await _httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
return null;
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var index = JsonSerializer.Deserialize<ReferrersIndex>(json, JsonOptions);
return index?.Manifests;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch referrers");
return null;
}
}
private async Task<StellaVerdict?> FetchVerdictBlobAsync(
OciReference reference,
string digest,
CancellationToken cancellationToken)
{
var opts = _options.CurrentValue;
var baseUrl = $"https://{reference.Registry}/v2/{reference.Repository}";
var blobUrl = $"{baseUrl}/blobs/{digest}";
try
{
var request = new HttpRequestMessage(HttpMethod.Get, blobUrl);
AddAuthHeaders(request, opts);
var response = await _httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
return null;
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
return JsonSerializer.Deserialize<StellaVerdict>(json, JsonOptions);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch verdict blob");
return null;
}
}
private static void AddAuthHeaders(HttpRequestMessage request, OciPublisherOptions opts)
{
if (!string.IsNullOrEmpty(opts.Auth?.BearerToken))
{
request.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", opts.Auth.BearerToken);
}
else if (!string.IsNullOrEmpty(opts.Auth?.Username) && !string.IsNullOrEmpty(opts.Auth?.Password))
{
var credentials = Convert.ToBase64String(
Encoding.UTF8.GetBytes($"{opts.Auth.Username}:{opts.Auth.Password}"));
request.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", credentials);
}
}
private static string ComputeSha256(byte[] content)
{
var hash = System.Security.Cryptography.SHA256.HashData(content);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private static OciReference? ParseReference(string reference)
{
try
{
var atIdx = reference.IndexOf('@');
var colonIdx = reference.LastIndexOf(':');
string registry;
string repository;
string? tag = null;
string? digest = null;
if (atIdx > 0)
{
digest = reference[(atIdx + 1)..];
var beforeDigest = reference[..atIdx];
var slashIdx = beforeDigest.IndexOf('/');
if (slashIdx < 0) return null;
registry = beforeDigest[..slashIdx];
repository = beforeDigest[(slashIdx + 1)..];
}
else if (colonIdx > 0 && colonIdx > reference.IndexOf('/'))
{
tag = reference[(colonIdx + 1)..];
var beforeTag = reference[..colonIdx];
var slashIdx = beforeTag.IndexOf('/');
if (slashIdx < 0) return null;
registry = beforeTag[..slashIdx];
repository = beforeTag[(slashIdx + 1)..];
}
else
{
return null;
}
return new OciReference
{
Registry = registry,
Repository = repository,
Tag = tag,
Digest = digest
};
}
catch
{
return null;
}
}
private sealed record OciReference
{
public required string Registry { get; init; }
public required string Repository { get; init; }
public string? Tag { get; init; }
public string? Digest { get; init; }
}
private sealed record ReferrersIndex
{
public IReadOnlyList<ReferrerEntry>? Manifests { get; init; }
}
private sealed record ReferrerEntry
{
public required string Digest { get; init; }
public required long Size { get; init; }
public string? ArtifactType { get; init; }
public DateTimeOffset? CreatedAt { get; init; }
}
}
/// <summary>
/// Configuration options for OCI attestation publishing.
/// </summary>
public sealed class OciPublisherOptions
{
/// <summary>
/// Configuration section key.
/// </summary>
public const string SectionKey = "VerdictOci";
/// <summary>
/// Whether OCI publishing is enabled.
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// Whether running in offline/air-gap mode.
/// </summary>
public bool OfflineMode { get; set; } = false;
/// <summary>
/// Path to store verdicts when in offline mode.
/// </summary>
public string? OfflineStoragePath { get; set; }
/// <summary>
/// Default registry URL if not specified in reference.
/// </summary>
public string? DefaultRegistry { get; set; }
/// <summary>
/// Registry authentication.
/// </summary>
public OciAuthConfig? Auth { get; set; }
/// <summary>
/// Request timeout.
/// </summary>
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Whether to verify TLS certificates.
/// </summary>
public bool VerifyTls { get; set; } = true;
}
/// <summary>
/// OCI registry authentication configuration.
/// </summary>
public sealed class OciAuthConfig
{
/// <summary>
/// Username for basic auth.
/// </summary>
public string? Username { get; set; }
/// <summary>
/// Password or token for basic auth.
/// </summary>
public string? Password { get; set; }
/// <summary>
/// Bearer token for token auth.
/// </summary>
public string? BearerToken { get; set; }
/// <summary>
/// Path to Docker credentials file.
/// </summary>
public string? CredentialsFile { get; set; }
}

View File

@@ -0,0 +1,147 @@
using System.Collections.Immutable;
using StellaOps.Verdict.Schema;
namespace StellaOps.Verdict.Persistence;
/// <summary>
/// Store for persisting and querying StellaVerdict artifacts.
/// </summary>
public interface IVerdictStore
{
/// <summary>
/// Stores a verdict.
/// </summary>
Task<VerdictStoreResult> StoreAsync(StellaVerdict verdict, Guid tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves a verdict by its ID.
/// </summary>
Task<StellaVerdict?> GetAsync(string verdictId, Guid tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Queries verdicts with filters.
/// </summary>
Task<VerdictQueryResult> QueryAsync(VerdictQuery query, CancellationToken cancellationToken = default);
/// <summary>
/// Checks if a verdict exists.
/// </summary>
Task<bool> ExistsAsync(string verdictId, Guid tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets verdicts by subject (PURL + CVE).
/// </summary>
Task<ImmutableArray<StellaVerdict>> GetBySubjectAsync(string purl, string cveId, Guid tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the latest verdict for a subject.
/// </summary>
Task<StellaVerdict?> GetLatestAsync(string purl, string cveId, Guid tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes expired verdicts.
/// </summary>
Task<int> DeleteExpiredAsync(Guid tenantId, DateTimeOffset asOf, CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of storing a verdict.
/// </summary>
public sealed record VerdictStoreResult
{
/// <summary>Whether the store operation succeeded.</summary>
public required bool Success { get; init; }
/// <summary>The stored verdict ID.</summary>
public string? VerdictId { get; init; }
/// <summary>Whether this was an update to an existing verdict.</summary>
public bool WasUpdate { get; init; }
/// <summary>Error message if storage failed.</summary>
public string? Error { get; init; }
public static VerdictStoreResult Stored(string verdictId, bool wasUpdate = false) =>
new() { Success = true, VerdictId = verdictId, WasUpdate = wasUpdate };
public static VerdictStoreResult Failed(string error) =>
new() { Success = false, Error = error };
}
/// <summary>
/// Query parameters for searching verdicts.
/// </summary>
public sealed record VerdictQuery
{
/// <summary>Tenant ID for isolation.</summary>
public required Guid TenantId { get; init; }
/// <summary>Filter by PURL (exact or prefix match).</summary>
public string? Purl { get; init; }
/// <summary>Filter by CVE ID.</summary>
public string? CveId { get; init; }
/// <summary>Filter by decision status.</summary>
public VerdictStatus? Status { get; init; }
/// <summary>Filter by image digest.</summary>
public string? ImageDigest { get; init; }
/// <summary>Filter by inputs hash.</summary>
public string? InputsHash { get; init; }
/// <summary>Filter verdicts created after this time.</summary>
public DateTimeOffset? CreatedAfter { get; init; }
/// <summary>Filter verdicts created before this time.</summary>
public DateTimeOffset? CreatedBefore { get; init; }
/// <summary>Include expired verdicts.</summary>
public bool IncludeExpired { get; init; } = false;
/// <summary>Page size for pagination.</summary>
public int Limit { get; init; } = 50;
/// <summary>Offset for pagination.</summary>
public int Offset { get; init; } = 0;
/// <summary>Sort field.</summary>
public VerdictSortField SortBy { get; init; } = VerdictSortField.CreatedAt;
/// <summary>Sort direction.</summary>
public bool Descending { get; init; } = true;
}
/// <summary>
/// Sort fields for verdict queries.
/// </summary>
public enum VerdictSortField
{
CreatedAt,
VerdictId,
Purl,
CveId,
Score,
}
/// <summary>
/// Result of a verdict query.
/// </summary>
public sealed record VerdictQueryResult
{
/// <summary>The matching verdicts.</summary>
public required ImmutableArray<StellaVerdict> Verdicts { get; init; }
/// <summary>Total count of matching verdicts (for pagination).</summary>
public required int TotalCount { get; init; }
/// <summary>Offset used in the query.</summary>
public int Offset { get; init; }
/// <summary>Limit used in the query.</summary>
public int Limit { get; init; }
/// <summary>Whether there are more results.</summary>
public bool HasMore => Offset + Verdicts.Length < TotalCount;
}

View File

@@ -0,0 +1,107 @@
-- Migration: 001_create_verdicts
-- Description: Create verdicts table for StellaVerdict storage
-- Sprint: SPRINT_1227_0014_0001
-- Create schema if not exists
CREATE SCHEMA IF NOT EXISTS stellaops;
-- Verdicts table
CREATE TABLE stellaops.verdicts (
verdict_id TEXT NOT NULL,
tenant_id UUID NOT NULL,
-- Subject fields (extracted for indexing)
subject_purl TEXT NOT NULL,
subject_cve_id TEXT NOT NULL,
subject_component_name TEXT,
subject_component_version TEXT,
subject_image_digest TEXT,
subject_digest TEXT,
-- Claim fields (extracted for indexing)
claim_status TEXT NOT NULL,
claim_confidence DECIMAL(5,4),
claim_vex_status TEXT,
-- Result fields (extracted for indexing)
result_disposition TEXT NOT NULL,
result_score DECIMAL(5,4),
result_matched_rule TEXT,
result_quiet BOOLEAN NOT NULL DEFAULT FALSE,
-- Provenance fields (extracted for indexing)
provenance_generator TEXT NOT NULL,
provenance_run_id TEXT,
provenance_policy_bundle_id TEXT,
-- Inputs hash for deterministic verification
inputs_hash TEXT NOT NULL,
-- Full verdict JSON
verdict_json JSONB NOT NULL,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ,
-- Primary key
PRIMARY KEY (tenant_id, verdict_id)
);
-- Enable Row Level Security
ALTER TABLE stellaops.verdicts ENABLE ROW LEVEL SECURITY;
-- RLS policy for tenant isolation
CREATE POLICY tenant_isolation_policy ON stellaops.verdicts
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
-- Indexes for common query patterns
-- Query by PURL (most common)
CREATE INDEX idx_verdicts_purl
ON stellaops.verdicts(tenant_id, subject_purl);
-- Query by CVE
CREATE INDEX idx_verdicts_cve
ON stellaops.verdicts(tenant_id, subject_cve_id);
-- Query by PURL + CVE combination
CREATE INDEX idx_verdicts_purl_cve
ON stellaops.verdicts(tenant_id, subject_purl, subject_cve_id);
-- Query by image digest
CREATE INDEX idx_verdicts_image_digest
ON stellaops.verdicts(tenant_id, subject_image_digest)
WHERE subject_image_digest IS NOT NULL;
-- Query by decision status
CREATE INDEX idx_verdicts_status
ON stellaops.verdicts(tenant_id, claim_status);
-- Query by inputs hash (for cache invalidation)
CREATE INDEX idx_verdicts_inputs_hash
ON stellaops.verdicts(tenant_id, inputs_hash);
-- Query by expiration (for cleanup)
CREATE INDEX idx_verdicts_expires
ON stellaops.verdicts(tenant_id, expires_at)
WHERE expires_at IS NOT NULL;
-- Query by creation time (for timeline queries)
CREATE INDEX idx_verdicts_created
ON stellaops.verdicts(tenant_id, created_at DESC);
-- Query by policy bundle (for policy version impact analysis)
CREATE INDEX idx_verdicts_policy_bundle
ON stellaops.verdicts(tenant_id, provenance_policy_bundle_id)
WHERE provenance_policy_bundle_id IS NOT NULL;
-- GIN index for JSONB queries on verdict_json
CREATE INDEX idx_verdicts_json_gin
ON stellaops.verdicts USING GIN (verdict_json jsonb_path_ops);
-- Comments
COMMENT ON TABLE stellaops.verdicts IS 'StellaVerdict artifacts - signed vulnerability disposition decisions';
COMMENT ON COLUMN stellaops.verdicts.verdict_id IS 'Content-addressable ID (urn:stella:verdict:sha256:...)';
COMMENT ON COLUMN stellaops.verdicts.inputs_hash IS 'Hash of inputs for deterministic verification';
COMMENT ON COLUMN stellaops.verdicts.verdict_json IS 'Full StellaVerdict JSON including signatures';

View File

@@ -0,0 +1,301 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using StellaOps.Verdict.Schema;
namespace StellaOps.Verdict.Persistence;
/// <summary>
/// PostgreSQL implementation of verdict store.
/// </summary>
public sealed class PostgresVerdictStore : IVerdictStore
{
private readonly IDbContextFactory<VerdictDbContext> _contextFactory;
private readonly ILogger<PostgresVerdictStore> _logger;
private readonly JsonSerializerOptions _jsonOptions;
public PostgresVerdictStore(
IDbContextFactory<VerdictDbContext> contextFactory,
ILogger<PostgresVerdictStore> logger)
{
_contextFactory = contextFactory;
_logger = logger;
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
};
}
public async Task<VerdictStoreResult> StoreAsync(StellaVerdict verdict, Guid tenantId, CancellationToken cancellationToken = default)
{
try
{
await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken);
var row = ToRow(verdict, tenantId);
var existing = await context.Verdicts
.FirstOrDefaultAsync(v => v.TenantId == tenantId && v.VerdictId == verdict.VerdictId, cancellationToken);
bool wasUpdate = existing is not null;
if (wasUpdate)
{
context.Entry(existing!).CurrentValues.SetValues(row);
}
else
{
context.Verdicts.Add(row);
}
await context.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Stored verdict {VerdictId} for tenant {TenantId} (update={WasUpdate})",
verdict.VerdictId, tenantId, wasUpdate);
return VerdictStoreResult.Stored(verdict.VerdictId, wasUpdate);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to store verdict {VerdictId}", verdict.VerdictId);
return VerdictStoreResult.Failed($"Storage failed: {ex.Message}");
}
}
public async Task<StellaVerdict?> GetAsync(string verdictId, Guid tenantId, CancellationToken cancellationToken = default)
{
await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken);
var row = await context.Verdicts
.AsNoTracking()
.FirstOrDefaultAsync(v => v.TenantId == tenantId && v.VerdictId == verdictId, cancellationToken);
return row is null ? null : FromRow(row);
}
public async Task<VerdictQueryResult> QueryAsync(VerdictQuery query, CancellationToken cancellationToken = default)
{
await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken);
var queryable = context.Verdicts
.AsNoTracking()
.Where(v => v.TenantId == query.TenantId);
// Apply filters
if (!string.IsNullOrEmpty(query.Purl))
{
queryable = queryable.Where(v => v.SubjectPurl.StartsWith(query.Purl));
}
if (!string.IsNullOrEmpty(query.CveId))
{
queryable = queryable.Where(v => v.SubjectCveId == query.CveId);
}
if (query.Status.HasValue)
{
var statusStr = query.Status.Value.ToString();
queryable = queryable.Where(v => v.ClaimStatus == statusStr);
}
if (!string.IsNullOrEmpty(query.ImageDigest))
{
queryable = queryable.Where(v => v.SubjectImageDigest == query.ImageDigest);
}
if (!string.IsNullOrEmpty(query.InputsHash))
{
queryable = queryable.Where(v => v.InputsHash == query.InputsHash);
}
if (query.CreatedAfter.HasValue)
{
queryable = queryable.Where(v => v.CreatedAt >= query.CreatedAfter.Value);
}
if (query.CreatedBefore.HasValue)
{
queryable = queryable.Where(v => v.CreatedAt <= query.CreatedBefore.Value);
}
if (!query.IncludeExpired)
{
var now = DateTimeOffset.UtcNow;
queryable = queryable.Where(v => v.ExpiresAt == null || v.ExpiresAt > now);
}
// Get total count before pagination
var totalCount = await queryable.CountAsync(cancellationToken);
// Apply sorting
queryable = query.SortBy switch
{
VerdictSortField.VerdictId => query.Descending
? queryable.OrderByDescending(v => v.VerdictId)
: queryable.OrderBy(v => v.VerdictId),
VerdictSortField.Purl => query.Descending
? queryable.OrderByDescending(v => v.SubjectPurl)
: queryable.OrderBy(v => v.SubjectPurl),
VerdictSortField.CveId => query.Descending
? queryable.OrderByDescending(v => v.SubjectCveId)
: queryable.OrderBy(v => v.SubjectCveId),
VerdictSortField.Score => query.Descending
? queryable.OrderByDescending(v => v.ResultScore)
: queryable.OrderBy(v => v.ResultScore),
_ => query.Descending
? queryable.OrderByDescending(v => v.CreatedAt)
: queryable.OrderBy(v => v.CreatedAt),
};
// Apply pagination
var rows = await queryable
.Skip(query.Offset)
.Take(query.Limit)
.ToListAsync(cancellationToken);
var verdicts = rows.Select(FromRow).ToImmutableArray();
return new VerdictQueryResult
{
Verdicts = verdicts,
TotalCount = totalCount,
Offset = query.Offset,
Limit = query.Limit,
};
}
public async Task<bool> ExistsAsync(string verdictId, Guid tenantId, CancellationToken cancellationToken = default)
{
await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken);
return await context.Verdicts
.AsNoTracking()
.AnyAsync(v => v.TenantId == tenantId && v.VerdictId == verdictId, cancellationToken);
}
public async Task<ImmutableArray<StellaVerdict>> GetBySubjectAsync(string purl, string cveId, Guid tenantId, CancellationToken cancellationToken = default)
{
await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken);
var rows = await context.Verdicts
.AsNoTracking()
.Where(v => v.TenantId == tenantId && v.SubjectPurl == purl && v.SubjectCveId == cveId)
.OrderByDescending(v => v.CreatedAt)
.ToListAsync(cancellationToken);
return rows.Select(FromRow).ToImmutableArray();
}
public async Task<StellaVerdict?> GetLatestAsync(string purl, string cveId, Guid tenantId, CancellationToken cancellationToken = default)
{
await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken);
var now = DateTimeOffset.UtcNow;
var row = await context.Verdicts
.AsNoTracking()
.Where(v => v.TenantId == tenantId && v.SubjectPurl == purl && v.SubjectCveId == cveId)
.Where(v => v.ExpiresAt == null || v.ExpiresAt > now)
.OrderByDescending(v => v.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
return row is null ? null : FromRow(row);
}
public async Task<int> DeleteExpiredAsync(Guid tenantId, DateTimeOffset asOf, CancellationToken cancellationToken = default)
{
await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken);
var deleted = await context.Verdicts
.Where(v => v.TenantId == tenantId && v.ExpiresAt.HasValue && v.ExpiresAt <= asOf)
.ExecuteDeleteAsync(cancellationToken);
if (deleted > 0)
{
_logger.LogInformation("Deleted {Count} expired verdicts for tenant {TenantId}", deleted, tenantId);
}
return deleted;
}
private VerdictRow ToRow(StellaVerdict verdict, Guid tenantId)
{
var json = JsonSerializer.Serialize(verdict, _jsonOptions);
var inputsHash = ComputeInputsHash(verdict);
DateTimeOffset? expiresAt = null;
if (!string.IsNullOrEmpty(verdict.Result.ExpiresAt) &&
DateTimeOffset.TryParse(verdict.Result.ExpiresAt, out var parsed))
{
expiresAt = parsed;
}
return new VerdictRow
{
VerdictId = verdict.VerdictId,
TenantId = tenantId,
SubjectPurl = verdict.Subject.Purl,
SubjectCveId = verdict.Subject.VulnerabilityId,
SubjectComponentName = verdict.Subject.ComponentName,
SubjectComponentVersion = verdict.Subject.ComponentVersion,
SubjectImageDigest = verdict.Subject.ImageDigest,
SubjectDigest = verdict.Subject.SubjectDigest,
ClaimStatus = verdict.Claim.Status.ToString(),
ClaimConfidence = (decimal)verdict.Claim.Confidence,
ClaimVexStatus = verdict.Claim.VexStatus,
ResultDisposition = verdict.Result.Disposition,
ResultScore = (decimal)verdict.Result.Score,
ResultMatchedRule = verdict.Result.MatchedRule,
ResultQuiet = verdict.Result.Quiet,
ProvenanceGenerator = verdict.Provenance.Generator,
ProvenanceRunId = verdict.Provenance.RunId,
ProvenancePolicyBundleId = verdict.Provenance.PolicyBundleId,
InputsHash = inputsHash,
VerdictJson = json,
CreatedAt = DateTimeOffset.TryParse(verdict.Provenance.CreatedAt, out var createdAt)
? createdAt
: DateTimeOffset.UtcNow,
ExpiresAt = expiresAt,
};
}
private StellaVerdict FromRow(VerdictRow row)
{
return JsonSerializer.Deserialize<StellaVerdict>(row.VerdictJson, _jsonOptions)!;
}
private static string ComputeInputsHash(StellaVerdict verdict)
{
// Hash the inputs section for deterministic verification
var inputsJson = JsonSerializer.Serialize(verdict.Inputs, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
});
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(inputsJson));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}
/// <summary>
/// DbContext for verdict persistence.
/// </summary>
public sealed class VerdictDbContext : DbContext
{
public VerdictDbContext(DbContextOptions<VerdictDbContext> options)
: base(options)
{
}
public DbSet<VerdictRow> Verdicts { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<VerdictRow>(entity =>
{
entity.HasKey(e => new { e.TenantId, e.VerdictId });
entity.ToTable("verdicts", "stellaops");
});
}
}

View File

@@ -0,0 +1,84 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace StellaOps.Verdict.Persistence;
/// <summary>
/// Database entity for verdict storage.
/// </summary>
[Table("verdicts", Schema = "stellaops")]
public sealed class VerdictRow
{
[Column("verdict_id")]
public required string VerdictId { get; set; }
[Column("tenant_id")]
public Guid TenantId { get; set; }
// Subject fields
[Column("subject_purl")]
public required string SubjectPurl { get; set; }
[Column("subject_cve_id")]
public required string SubjectCveId { get; set; }
[Column("subject_component_name")]
public string? SubjectComponentName { get; set; }
[Column("subject_component_version")]
public string? SubjectComponentVersion { get; set; }
[Column("subject_image_digest")]
public string? SubjectImageDigest { get; set; }
[Column("subject_digest")]
public string? SubjectDigest { get; set; }
// Claim fields
[Column("claim_status")]
public required string ClaimStatus { get; set; }
[Column("claim_confidence")]
public decimal? ClaimConfidence { get; set; }
[Column("claim_vex_status")]
public string? ClaimVexStatus { get; set; }
// Result fields
[Column("result_disposition")]
public required string ResultDisposition { get; set; }
[Column("result_score")]
public decimal? ResultScore { get; set; }
[Column("result_matched_rule")]
public string? ResultMatchedRule { get; set; }
[Column("result_quiet")]
public bool ResultQuiet { get; set; }
// Provenance fields
[Column("provenance_generator")]
public required string ProvenanceGenerator { get; set; }
[Column("provenance_run_id")]
public string? ProvenanceRunId { get; set; }
[Column("provenance_policy_bundle_id")]
public string? ProvenancePolicyBundleId { get; set; }
// Inputs hash
[Column("inputs_hash")]
public required string InputsHash { get; set; }
// Full JSON
[Column("verdict_json", TypeName = "jsonb")]
public required string VerdictJson { get; set; }
// Timestamps
[Column("created_at")]
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
[Column("expires_at")]
public DateTimeOffset? ExpiresAt { get; set; }
}

View File

@@ -0,0 +1,635 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Verdict.Schema;
/// <summary>
/// StellaVerdict - Unified artifact consolidating vulnerability disposition decision.
/// Combines PolicyVerdict, ProofBundle, and KnowledgeSnapshot into a single signed artifact.
/// </summary>
public sealed record StellaVerdict
{
/// <summary>
/// JSON-LD context for standards interoperability.
/// </summary>
[JsonPropertyName("@context")]
public string Context { get; init; } = "https://stella-ops.org/schemas/verdict/1.0";
/// <summary>
/// JSON-LD type annotation.
/// </summary>
[JsonPropertyName("@type")]
public string Type { get; init; } = "StellaVerdict";
/// <summary>
/// Content-addressable verdict ID (urn:stella:verdict:sha256:...).
/// </summary>
[JsonPropertyName("verdictId")]
public required string VerdictId { get; init; }
/// <summary>
/// Schema version for evolution.
/// </summary>
[JsonPropertyName("version")]
public string Version { get; init; } = "1.0.0";
/// <summary>
/// The subject of the verdict (vulnerability + component).
/// </summary>
[JsonPropertyName("subject")]
public required VerdictSubject Subject { get; init; }
/// <summary>
/// The claim being made (status + confidence + reason).
/// </summary>
[JsonPropertyName("claim")]
public required VerdictClaim Claim { get; init; }
/// <summary>
/// Knowledge inputs that informed the decision.
/// </summary>
[JsonPropertyName("inputs")]
public required VerdictInputs Inputs { get; init; }
/// <summary>
/// Evidence graph with nodes and edges (from ProofBundle).
/// </summary>
[JsonPropertyName("evidenceGraph")]
public VerdictEvidenceGraph? EvidenceGraph { get; init; }
/// <summary>
/// Policy evaluation path showing rule chain.
/// </summary>
[JsonPropertyName("policyPath")]
public ImmutableArray<VerdictPolicyStep> PolicyPath { get; init; } = ImmutableArray<VerdictPolicyStep>.Empty;
/// <summary>
/// Final result with disposition and score.
/// </summary>
[JsonPropertyName("result")]
public required VerdictResult Result { get; init; }
/// <summary>
/// Provenance information (scanner, run, timestamp).
/// </summary>
[JsonPropertyName("provenance")]
public required VerdictProvenance Provenance { get; init; }
/// <summary>
/// DSSE signatures for the verdict.
/// </summary>
[JsonPropertyName("signatures")]
public ImmutableArray<VerdictSignature> Signatures { get; init; } = ImmutableArray<VerdictSignature>.Empty;
/// <summary>
/// Computes a content-addressable ID for the verdict.
/// Uses BLAKE3 hash of canonical JSON (excluding signatures).
/// </summary>
public string ComputeVerdictId()
{
var canonical = GetCanonicalPayload();
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical));
return $"urn:stella:verdict:sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
/// <summary>
/// Gets the canonical JSON payload for signing (excludes signatures).
/// </summary>
public string GetCanonicalPayload()
{
var forSigning = new
{
context = Context,
type = Type,
version = Version,
subject = Subject,
claim = Claim,
inputs = Inputs,
evidenceGraph = EvidenceGraph,
policyPath = PolicyPath,
result = Result,
provenance = Provenance
};
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
return JsonSerializer.Serialize(forSigning, options);
}
/// <summary>
/// Creates a new verdict with computed ID.
/// </summary>
public StellaVerdict WithComputedId() => this with { VerdictId = ComputeVerdictId() };
}
/// <summary>
/// Subject of the verdict - what is being assessed.
/// </summary>
public sealed record VerdictSubject
{
/// <summary>
/// The vulnerability ID (CVE, GHSA, etc.).
/// </summary>
[JsonPropertyName("vulnerabilityId")]
public required string VulnerabilityId { get; init; }
/// <summary>
/// The component PURL.
/// </summary>
[JsonPropertyName("purl")]
public required string Purl { get; init; }
/// <summary>
/// Component name.
/// </summary>
[JsonPropertyName("componentName")]
public string? ComponentName { get; init; }
/// <summary>
/// Component version.
/// </summary>
[JsonPropertyName("componentVersion")]
public string? ComponentVersion { get; init; }
/// <summary>
/// Image digest if in container context.
/// </summary>
[JsonPropertyName("imageDigest")]
public string? ImageDigest { get; init; }
/// <summary>
/// Content digest for subject identity.
/// </summary>
[JsonPropertyName("subjectDigest")]
public string? SubjectDigest { get; init; }
}
/// <summary>
/// The claim being made by the verdict.
/// </summary>
public sealed record VerdictClaim
{
/// <summary>
/// Verdict status (Pass, Blocked, Warned, etc.).
/// </summary>
[JsonPropertyName("status")]
public required VerdictStatus Status { get; init; }
/// <summary>
/// Confidence level (0.0-1.0).
/// </summary>
[JsonPropertyName("confidence")]
public double Confidence { get; init; } = 1.0;
/// <summary>
/// Confidence band label (High, Medium, Low, Unknown).
/// </summary>
[JsonPropertyName("confidenceBand")]
public string? ConfidenceBand { get; init; }
/// <summary>
/// Human-readable reason for the verdict.
/// </summary>
[JsonPropertyName("reason")]
public string? Reason { get; init; }
/// <summary>
/// VEX status if applicable.
/// </summary>
[JsonPropertyName("vexStatus")]
public string? VexStatus { get; init; }
/// <summary>
/// VEX justification if applicable.
/// </summary>
[JsonPropertyName("vexJustification")]
public string? VexJustification { get; init; }
}
/// <summary>
/// Verdict status values aligned with PolicyVerdictStatus.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum VerdictStatus
{
/// <summary>Component passed all policy checks.</summary>
Pass,
/// <summary>Component is blocked by policy.</summary>
Blocked,
/// <summary>Finding is ignored per policy.</summary>
Ignored,
/// <summary>Warning issued but not blocking.</summary>
Warned,
/// <summary>Decision deferred for manual review.</summary>
Deferred,
/// <summary>Escalated for security team review.</summary>
Escalated,
/// <summary>Requires VEX statement to resolve.</summary>
RequiresVex
}
/// <summary>
/// Final result of the verdict evaluation.
/// </summary>
public sealed record VerdictResult
{
/// <summary>
/// K4 lattice disposition.
/// </summary>
[JsonPropertyName("disposition")]
public required string Disposition { get; init; }
/// <summary>
/// Computed risk score.
/// </summary>
[JsonPropertyName("score")]
public double Score { get; init; }
/// <summary>
/// Rule that matched.
/// </summary>
[JsonPropertyName("matchedRule")]
public string? MatchedRule { get; init; }
/// <summary>
/// Action taken by the rule.
/// </summary>
[JsonPropertyName("ruleAction")]
public string? RuleAction { get; init; }
/// <summary>
/// Whether the finding is quieted.
/// </summary>
[JsonPropertyName("quiet")]
public bool Quiet { get; init; }
/// <summary>
/// Who/what quieted the finding.
/// </summary>
[JsonPropertyName("quietedBy")]
public string? QuietedBy { get; init; }
/// <summary>
/// Expiration of this verdict (ISO 8601).
/// </summary>
[JsonPropertyName("expiresAt")]
public string? ExpiresAt { get; init; }
}
/// <summary>
/// Provenance information for the verdict.
/// </summary>
public sealed record VerdictProvenance
{
/// <summary>
/// Generator name.
/// </summary>
[JsonPropertyName("generator")]
public required string Generator { get; init; }
/// <summary>
/// Generator version.
/// </summary>
[JsonPropertyName("generatorVersion")]
public string? GeneratorVersion { get; init; }
/// <summary>
/// Scan/run ID.
/// </summary>
[JsonPropertyName("runId")]
public string? RunId { get; init; }
/// <summary>
/// When the verdict was created (ISO 8601).
/// </summary>
[JsonPropertyName("createdAt")]
public required string CreatedAt { get; init; }
/// <summary>
/// Policy bundle ID used.
/// </summary>
[JsonPropertyName("policyBundleId")]
public string? PolicyBundleId { get; init; }
/// <summary>
/// Policy bundle version.
/// </summary>
[JsonPropertyName("policyBundleVersion")]
public string? PolicyBundleVersion { get; init; }
}
/// <summary>
/// DSSE signature on the verdict.
/// </summary>
public sealed record VerdictSignature
{
/// <summary>
/// Key ID used for signing.
/// </summary>
[JsonPropertyName("keyid")]
public required string KeyId { get; init; }
/// <summary>
/// Signature algorithm.
/// </summary>
[JsonPropertyName("sig")]
public required string Sig { get; init; }
/// <summary>
/// Certificate chain if available.
/// </summary>
[JsonPropertyName("cert")]
public string? Cert { get; init; }
}
/// <summary>
/// Knowledge inputs that informed the verdict decision.
/// </summary>
public sealed record VerdictInputs
{
/// <summary>
/// Advisory sources consulted.
/// </summary>
[JsonPropertyName("advisorySources")]
public ImmutableArray<VerdictAdvisorySource> AdvisorySources { get; init; } = ImmutableArray<VerdictAdvisorySource>.Empty;
/// <summary>
/// VEX statements considered.
/// </summary>
[JsonPropertyName("vexStatements")]
public ImmutableArray<VerdictVexInput> VexStatements { get; init; } = ImmutableArray<VerdictVexInput>.Empty;
/// <summary>
/// CVSS scores used in decision.
/// </summary>
[JsonPropertyName("cvssScores")]
public ImmutableArray<VerdictCvssInput> CvssScores { get; init; } = ImmutableArray<VerdictCvssInput>.Empty;
/// <summary>
/// EPSS probability if available.
/// </summary>
[JsonPropertyName("epss")]
public VerdictEpssInput? Epss { get; init; }
/// <summary>
/// KEV (Known Exploited Vulnerability) status.
/// </summary>
[JsonPropertyName("kev")]
public VerdictKevInput? Kev { get; init; }
/// <summary>
/// Reachability analysis result.
/// </summary>
[JsonPropertyName("reachability")]
public VerdictReachabilityInput? Reachability { get; init; }
}
/// <summary>
/// Advisory source input.
/// </summary>
public sealed record VerdictAdvisorySource
{
/// <summary>Source identifier (e.g., "NVD", "OSV", "GHSA").</summary>
[JsonPropertyName("source")]
public required string Source { get; init; }
/// <summary>Advisory ID in that source.</summary>
[JsonPropertyName("advisoryId")]
public required string AdvisoryId { get; init; }
/// <summary>When the advisory was fetched (ISO 8601).</summary>
[JsonPropertyName("fetchedAt")]
public string? FetchedAt { get; init; }
/// <summary>Content hash of the advisory.</summary>
[JsonPropertyName("contentHash")]
public string? ContentHash { get; init; }
}
/// <summary>
/// VEX statement input.
/// </summary>
public sealed record VerdictVexInput
{
/// <summary>VEX document ID.</summary>
[JsonPropertyName("vexId")]
public required string VexId { get; init; }
/// <summary>Issuer of the VEX statement.</summary>
[JsonPropertyName("issuer")]
public required string Issuer { get; init; }
/// <summary>VEX status (not_affected, affected, fixed, under_investigation).</summary>
[JsonPropertyName("status")]
public required string Status { get; init; }
/// <summary>Justification if not_affected.</summary>
[JsonPropertyName("justification")]
public string? Justification { get; init; }
/// <summary>When the VEX was issued (ISO 8601).</summary>
[JsonPropertyName("timestamp")]
public string? Timestamp { get; init; }
}
/// <summary>
/// CVSS score input.
/// </summary>
public sealed record VerdictCvssInput
{
/// <summary>CVSS version (2.0, 3.0, 3.1, 4.0).</summary>
[JsonPropertyName("version")]
public required string Version { get; init; }
/// <summary>Vector string.</summary>
[JsonPropertyName("vector")]
public required string Vector { get; init; }
/// <summary>Base score.</summary>
[JsonPropertyName("baseScore")]
public double BaseScore { get; init; }
/// <summary>Temporal score if available.</summary>
[JsonPropertyName("temporalScore")]
public double? TemporalScore { get; init; }
/// <summary>Environmental score if available.</summary>
[JsonPropertyName("environmentalScore")]
public double? EnvironmentalScore { get; init; }
/// <summary>Source of the CVSS score.</summary>
[JsonPropertyName("source")]
public string? Source { get; init; }
}
/// <summary>
/// EPSS (Exploit Prediction Scoring System) input.
/// </summary>
public sealed record VerdictEpssInput
{
/// <summary>EPSS probability (0.0-1.0).</summary>
[JsonPropertyName("probability")]
public double Probability { get; init; }
/// <summary>EPSS percentile (0.0-1.0).</summary>
[JsonPropertyName("percentile")]
public double Percentile { get; init; }
/// <summary>Date of the EPSS data (ISO 8601).</summary>
[JsonPropertyName("date")]
public required string Date { get; init; }
}
/// <summary>
/// KEV (Known Exploited Vulnerability) input.
/// </summary>
public sealed record VerdictKevInput
{
/// <summary>Whether the CVE is in KEV catalog.</summary>
[JsonPropertyName("inKev")]
public bool InKev { get; init; }
/// <summary>Date added to KEV (ISO 8601).</summary>
[JsonPropertyName("dateAdded")]
public string? DateAdded { get; init; }
/// <summary>Required action deadline (ISO 8601).</summary>
[JsonPropertyName("dueDate")]
public string? DueDate { get; init; }
}
/// <summary>
/// Reachability analysis input.
/// </summary>
public sealed record VerdictReachabilityInput
{
/// <summary>Whether vulnerable code is reachable.</summary>
[JsonPropertyName("isReachable")]
public bool IsReachable { get; init; }
/// <summary>Confidence of reachability analysis (0.0-1.0).</summary>
[JsonPropertyName("confidence")]
public double Confidence { get; init; }
/// <summary>Analysis method used.</summary>
[JsonPropertyName("method")]
public string? Method { get; init; }
/// <summary>Call path to vulnerable code if reachable.</summary>
[JsonPropertyName("callPath")]
public ImmutableArray<string> CallPath { get; init; } = ImmutableArray<string>.Empty;
}
/// <summary>
/// Evidence graph from proof bundle (content-addressable audit trail).
/// </summary>
public sealed record VerdictEvidenceGraph
{
/// <summary>
/// Evidence nodes in the graph.
/// </summary>
[JsonPropertyName("nodes")]
public ImmutableArray<VerdictEvidenceNode> Nodes { get; init; } = ImmutableArray<VerdictEvidenceNode>.Empty;
/// <summary>
/// Edges connecting evidence nodes.
/// </summary>
[JsonPropertyName("edges")]
public ImmutableArray<VerdictEvidenceEdge> Edges { get; init; } = ImmutableArray<VerdictEvidenceEdge>.Empty;
/// <summary>
/// Root node ID (entry point for verification).
/// </summary>
[JsonPropertyName("root")]
public string? Root { get; init; }
}
/// <summary>
/// Evidence node in the proof graph.
/// </summary>
public sealed record VerdictEvidenceNode
{
/// <summary>Content-addressable node ID (hash).</summary>
[JsonPropertyName("id")]
public required string Id { get; init; }
/// <summary>Type of evidence (advisory, vex, scan_result, policy_eval, etc.).</summary>
[JsonPropertyName("type")]
public required string Type { get; init; }
/// <summary>Human-readable label.</summary>
[JsonPropertyName("label")]
public string? Label { get; init; }
/// <summary>Content hash algorithm.</summary>
[JsonPropertyName("hashAlgorithm")]
public string HashAlgorithm { get; init; } = "sha256";
/// <summary>Timestamp when evidence was captured (ISO 8601).</summary>
[JsonPropertyName("capturedAt")]
public string? CapturedAt { get; init; }
/// <summary>URI to retrieve the full evidence if needed.</summary>
[JsonPropertyName("uri")]
public string? Uri { get; init; }
/// <summary>Additional metadata.</summary>
[JsonPropertyName("metadata")]
public ImmutableDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Edge connecting evidence nodes.
/// </summary>
public sealed record VerdictEvidenceEdge
{
/// <summary>Source node ID.</summary>
[JsonPropertyName("from")]
public required string From { get; init; }
/// <summary>Target node ID.</summary>
[JsonPropertyName("to")]
public required string To { get; init; }
/// <summary>Relationship type (derives_from, supersedes, validates, etc.).</summary>
[JsonPropertyName("relationship")]
public required string Relationship { get; init; }
}
/// <summary>
/// Policy evaluation step in the decision path.
/// </summary>
public sealed record VerdictPolicyStep
{
/// <summary>Rule ID that was evaluated.</summary>
[JsonPropertyName("ruleId")]
public required string RuleId { get; init; }
/// <summary>Rule name for display.</summary>
[JsonPropertyName("ruleName")]
public string? RuleName { get; init; }
/// <summary>Whether the rule matched.</summary>
[JsonPropertyName("matched")]
public bool Matched { get; init; }
/// <summary>Action taken if matched (block, warn, ignore, defer, escalate).</summary>
[JsonPropertyName("action")]
public string? Action { get; init; }
/// <summary>Reason for match/no-match.</summary>
[JsonPropertyName("reason")]
public string? Reason { get; init; }
/// <summary>Evaluation order in the chain.</summary>
[JsonPropertyName("order")]
public int Order { get; init; }
}

View File

@@ -0,0 +1,394 @@
using System.Collections.Immutable;
using StellaOps.Policy;
using StellaOps.Policy.TrustLattice;
using StellaOps.Verdict.Schema;
namespace StellaOps.Verdict.Services;
/// <summary>
/// Service for assembling StellaVerdict from PolicyVerdict, ProofBundle, and knowledge inputs.
/// </summary>
public interface IVerdictAssemblyService
{
/// <summary>
/// Assembles a StellaVerdict from the given inputs.
/// </summary>
StellaVerdict AssembleVerdict(VerdictAssemblyContext context);
/// <summary>
/// Assembles multiple verdicts from a batch of contexts.
/// </summary>
ImmutableArray<StellaVerdict> AssembleVerdicts(IEnumerable<VerdictAssemblyContext> contexts);
}
/// <summary>
/// Context containing all inputs needed to assemble a StellaVerdict.
/// </summary>
public sealed record VerdictAssemblyContext
{
/// <summary>The vulnerability ID (CVE, GHSA, etc.).</summary>
public required string VulnerabilityId { get; init; }
/// <summary>The component PURL.</summary>
public required string Purl { get; init; }
/// <summary>Component name.</summary>
public string? ComponentName { get; init; }
/// <summary>Component version.</summary>
public string? ComponentVersion { get; init; }
/// <summary>Image digest if in container context.</summary>
public string? ImageDigest { get; init; }
/// <summary>The policy verdict result.</summary>
public required PolicyVerdict PolicyVerdict { get; init; }
/// <summary>The proof bundle with decision trace.</summary>
public ProofBundle? ProofBundle { get; init; }
/// <summary>Knowledge inputs (advisories, VEX, CVSS, etc.).</summary>
public VerdictKnowledgeInputs? Knowledge { get; init; }
/// <summary>Generator name (e.g., "StellaOps Scanner").</summary>
public string Generator { get; init; } = "StellaOps";
/// <summary>Generator version.</summary>
public string? GeneratorVersion { get; init; }
/// <summary>Scan/run ID.</summary>
public string? RunId { get; init; }
}
/// <summary>
/// Knowledge inputs for verdict assembly.
/// </summary>
public sealed record VerdictKnowledgeInputs
{
/// <summary>Advisory sources consulted.</summary>
public ImmutableArray<AdvisorySourceInput> AdvisorySources { get; init; } = ImmutableArray<AdvisorySourceInput>.Empty;
/// <summary>VEX statements considered.</summary>
public ImmutableArray<VexStatementInput> VexStatements { get; init; } = ImmutableArray<VexStatementInput>.Empty;
/// <summary>CVSS scores used.</summary>
public ImmutableArray<CvssScoreInput> CvssScores { get; init; } = ImmutableArray<CvssScoreInput>.Empty;
/// <summary>EPSS data.</summary>
public EpssInput? Epss { get; init; }
/// <summary>KEV status.</summary>
public KevInput? Kev { get; init; }
/// <summary>Reachability analysis.</summary>
public ReachabilityInput? Reachability { get; init; }
}
/// <summary>Advisory source input record.</summary>
public sealed record AdvisorySourceInput(string Source, string AdvisoryId, DateTimeOffset? FetchedAt = null, string? ContentHash = null);
/// <summary>VEX statement input record.</summary>
public sealed record VexStatementInput(string VexId, string Issuer, string Status, string? Justification = null, DateTimeOffset? Timestamp = null);
/// <summary>CVSS score input record.</summary>
public sealed record CvssScoreInput(string Version, string Vector, double BaseScore, double? TemporalScore = null, double? EnvironmentalScore = null, string? Source = null);
/// <summary>EPSS input record.</summary>
public sealed record EpssInput(double Probability, double Percentile, DateOnly Date);
/// <summary>KEV input record.</summary>
public sealed record KevInput(bool InKev, DateOnly? DateAdded = null, DateOnly? DueDate = null);
/// <summary>Reachability input record.</summary>
public sealed record ReachabilityInput(bool IsReachable, double Confidence, string? Method = null, ImmutableArray<string>? CallPath = null);
/// <summary>
/// Default implementation of the verdict assembly service.
/// </summary>
public sealed class VerdictAssemblyService : IVerdictAssemblyService
{
public StellaVerdict AssembleVerdict(VerdictAssemblyContext context)
{
var subject = BuildSubject(context);
var claim = BuildClaim(context.PolicyVerdict);
var inputs = BuildInputs(context.Knowledge);
var evidenceGraph = BuildEvidenceGraph(context.ProofBundle);
var policyPath = BuildPolicyPath(context.ProofBundle);
var result = BuildResult(context.PolicyVerdict, context.ProofBundle);
var provenance = BuildProvenance(context);
var verdict = new StellaVerdict
{
VerdictId = string.Empty, // Will be computed
Subject = subject,
Claim = claim,
Inputs = inputs,
EvidenceGraph = evidenceGraph,
PolicyPath = policyPath,
Result = result,
Provenance = provenance,
};
return verdict.WithComputedId();
}
public ImmutableArray<StellaVerdict> AssembleVerdicts(IEnumerable<VerdictAssemblyContext> contexts)
{
return contexts.Select(AssembleVerdict).ToImmutableArray();
}
private static VerdictSubject BuildSubject(VerdictAssemblyContext context)
{
return new VerdictSubject
{
VulnerabilityId = context.VulnerabilityId,
Purl = context.Purl,
ComponentName = context.ComponentName,
ComponentVersion = context.ComponentVersion,
ImageDigest = context.ImageDigest,
SubjectDigest = ComputeSubjectDigest(context.VulnerabilityId, context.Purl),
};
}
private static string ComputeSubjectDigest(string vulnId, string purl)
{
var input = $"{vulnId}|{purl}";
var hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private static VerdictClaim BuildClaim(PolicyVerdict policyVerdict)
{
return new VerdictClaim
{
Status = MapPolicyStatus(policyVerdict.Status),
Confidence = policyVerdict.UnknownConfidence ?? 1.0,
ConfidenceBand = policyVerdict.ConfidenceBand,
Reason = policyVerdict.Notes,
VexStatus = null, // Populated from VEX inputs if available
VexJustification = null,
};
}
private static VerdictStatus MapPolicyStatus(PolicyVerdictStatus status)
{
return status switch
{
PolicyVerdictStatus.Pass => VerdictStatus.Pass,
PolicyVerdictStatus.Blocked => VerdictStatus.Blocked,
PolicyVerdictStatus.Ignored => VerdictStatus.Ignored,
PolicyVerdictStatus.Warned => VerdictStatus.Warned,
PolicyVerdictStatus.Deferred => VerdictStatus.Deferred,
PolicyVerdictStatus.Escalated => VerdictStatus.Escalated,
PolicyVerdictStatus.RequiresVex => VerdictStatus.RequiresVex,
_ => VerdictStatus.Pass,
};
}
private static VerdictInputs BuildInputs(VerdictKnowledgeInputs? knowledge)
{
if (knowledge is null)
{
return new VerdictInputs();
}
return new VerdictInputs
{
AdvisorySources = knowledge.AdvisorySources
.Select(a => new VerdictAdvisorySource
{
Source = a.Source,
AdvisoryId = a.AdvisoryId,
FetchedAt = a.FetchedAt?.ToString("o"),
ContentHash = a.ContentHash,
})
.ToImmutableArray(),
VexStatements = knowledge.VexStatements
.Select(v => new VerdictVexInput
{
VexId = v.VexId,
Issuer = v.Issuer,
Status = v.Status,
Justification = v.Justification,
Timestamp = v.Timestamp?.ToString("o"),
})
.ToImmutableArray(),
CvssScores = knowledge.CvssScores
.Select(c => new VerdictCvssInput
{
Version = c.Version,
Vector = c.Vector,
BaseScore = c.BaseScore,
TemporalScore = c.TemporalScore,
EnvironmentalScore = c.EnvironmentalScore,
Source = c.Source,
})
.ToImmutableArray(),
Epss = knowledge.Epss is { } epss
? new VerdictEpssInput
{
Probability = epss.Probability,
Percentile = epss.Percentile,
Date = epss.Date.ToString("yyyy-MM-dd"),
}
: null,
Kev = knowledge.Kev is { } kev
? new VerdictKevInput
{
InKev = kev.InKev,
DateAdded = kev.DateAdded?.ToString("yyyy-MM-dd"),
DueDate = kev.DueDate?.ToString("yyyy-MM-dd"),
}
: null,
Reachability = knowledge.Reachability is { } reach
? new VerdictReachabilityInput
{
IsReachable = reach.IsReachable,
Confidence = reach.Confidence,
Method = reach.Method,
CallPath = reach.CallPath ?? ImmutableArray<string>.Empty,
}
: null,
};
}
private static VerdictEvidenceGraph? BuildEvidenceGraph(ProofBundle? proofBundle)
{
if (proofBundle is null)
{
return null;
}
var nodes = new List<VerdictEvidenceNode>();
var edges = new List<VerdictEvidenceEdge>();
// Add input nodes
foreach (var input in proofBundle.Inputs)
{
nodes.Add(new VerdictEvidenceNode
{
Id = input.Digest,
Type = input.Type,
Label = $"{input.Type}: {input.Source ?? input.Digest}",
CapturedAt = input.IngestedAt.ToString("o"),
Uri = input.Source,
});
}
// Add claim nodes from proof bundle
foreach (var claim in proofBundle.Claims)
{
var claimId = claim.Id ?? claim.ComputeId();
var assertionSummary = claim.Assertions.Count > 0
? string.Join(", ", claim.Assertions.Select(a => $"{a.Atom}={a.Value}"))
: "No assertions";
nodes.Add(new VerdictEvidenceNode
{
Id = claimId,
Type = "claim",
Label = $"Claim: {assertionSummary}",
CapturedAt = claim.Time.IssuedAt.ToString("o"),
});
}
// Add decision nodes
foreach (var decision in proofBundle.Decisions)
{
var decisionId = $"decision:{decision.SubjectDigest}";
nodes.Add(new VerdictEvidenceNode
{
Id = decisionId,
Type = "decision",
Label = $"Decision: {decision.Disposition}",
Metadata = ImmutableDictionary<string, string>.Empty
.Add("disposition", decision.Disposition.ToString())
.Add("matchedRule", decision.MatchedRule),
});
// Connect subject to decision
edges.Add(new VerdictEvidenceEdge
{
From = decision.SubjectDigest,
To = decisionId,
Relationship = "derives_from",
});
}
// Add policy bundle as root
var rootId = $"policy:{proofBundle.PolicyBundleId}";
nodes.Add(new VerdictEvidenceNode
{
Id = rootId,
Type = "policy_bundle",
Label = $"Policy: {proofBundle.PolicyBundleId}",
Metadata = proofBundle.PolicyBundleVersion is not null
? ImmutableDictionary<string, string>.Empty.Add("version", proofBundle.PolicyBundleVersion)
: null,
});
return new VerdictEvidenceGraph
{
Nodes = nodes.ToImmutableArray(),
Edges = edges.ToImmutableArray(),
Root = rootId,
};
}
private static ImmutableArray<VerdictPolicyStep> BuildPolicyPath(ProofBundle? proofBundle)
{
if (proofBundle is null)
{
return ImmutableArray<VerdictPolicyStep>.Empty;
}
var steps = new List<VerdictPolicyStep>();
var order = 0;
foreach (var decision in proofBundle.Decisions)
{
foreach (var traceStep in decision.Trace)
{
steps.Add(new VerdictPolicyStep
{
RuleId = traceStep.RuleName, // Use RuleName as RuleId
RuleName = traceStep.RuleName,
Matched = traceStep.Matched,
Action = traceStep.Matched ? decision.Disposition.ToString() : null,
Reason = traceStep.Condition,
Order = order++,
});
}
}
return steps.ToImmutableArray();
}
private static VerdictResult BuildResult(PolicyVerdict policyVerdict, ProofBundle? proofBundle)
{
var disposition = proofBundle?.Decisions.FirstOrDefault()?.Disposition.ToString() ?? policyVerdict.Status.ToString();
return new VerdictResult
{
Disposition = disposition,
Score = policyVerdict.Score,
MatchedRule = policyVerdict.RuleName,
RuleAction = policyVerdict.RuleAction,
Quiet = policyVerdict.Quiet,
QuietedBy = policyVerdict.QuietedBy,
ExpiresAt = null, // Can be set based on policy
};
}
private static VerdictProvenance BuildProvenance(VerdictAssemblyContext context)
{
return new VerdictProvenance
{
Generator = context.Generator,
GeneratorVersion = context.GeneratorVersion,
RunId = context.RunId,
CreatedAt = DateTimeOffset.UtcNow.ToString("o"),
PolicyBundleId = context.ProofBundle?.PolicyBundleId,
PolicyBundleVersion = context.ProofBundle?.PolicyBundleVersion,
};
}
}

View File

@@ -0,0 +1,302 @@
using System.Collections.Immutable;
using System.Text;
using StellaOps.Attestor.Envelope;
using StellaOps.Verdict.Schema;
namespace StellaOps.Verdict.Services;
/// <summary>
/// Service for DSSE signing of StellaVerdict artifacts.
/// </summary>
public interface IVerdictSigningService
{
/// <summary>
/// Signs a verdict using the provided key.
/// </summary>
Task<VerdictSigningResult> SignAsync(StellaVerdict verdict, EnvelopeKey key, CancellationToken cancellationToken = default);
/// <summary>
/// Verifies signatures on a verdict.
/// </summary>
Task<VerdictVerificationResult> VerifyAsync(StellaVerdict verdict, IEnumerable<EnvelopeKey> trustedKeys, CancellationToken cancellationToken = default);
/// <summary>
/// Adds a signature to an existing verdict.
/// </summary>
StellaVerdict AddSignature(StellaVerdict verdict, VerdictSignature signature);
}
/// <summary>
/// Result of signing a verdict.
/// </summary>
public sealed record VerdictSigningResult
{
/// <summary>Whether signing was successful.</summary>
public required bool Success { get; init; }
/// <summary>The signed verdict with signature attached.</summary>
public StellaVerdict? SignedVerdict { get; init; }
/// <summary>The DSSE envelope for external verification.</summary>
public DsseEnvelope? Envelope { get; init; }
/// <summary>Error message if signing failed.</summary>
public string? Error { get; init; }
public static VerdictSigningResult Succeeded(StellaVerdict verdict, DsseEnvelope envelope) =>
new() { Success = true, SignedVerdict = verdict, Envelope = envelope };
public static VerdictSigningResult Failed(string error) =>
new() { Success = false, Error = error };
}
/// <summary>
/// Result of verifying verdict signatures.
/// </summary>
public sealed record VerdictVerificationResult
{
/// <summary>Whether all signatures are valid.</summary>
public required bool Valid { get; init; }
/// <summary>Number of valid signatures.</summary>
public int ValidSignatureCount { get; init; }
/// <summary>Number of invalid or unverifiable signatures.</summary>
public int InvalidSignatureCount { get; init; }
/// <summary>Details of each signature verification.</summary>
public ImmutableArray<SignatureVerificationDetail> Details { get; init; } = ImmutableArray<SignatureVerificationDetail>.Empty;
/// <summary>Error message if verification failed.</summary>
public string? Error { get; init; }
public static VerdictVerificationResult Verified(ImmutableArray<SignatureVerificationDetail> details)
{
var validCount = details.Count(d => d.Valid);
var invalidCount = details.Count(d => !d.Valid);
return new()
{
Valid = invalidCount == 0 && validCount > 0,
ValidSignatureCount = validCount,
InvalidSignatureCount = invalidCount,
Details = details,
};
}
public static VerdictVerificationResult Failed(string error) =>
new() { Valid = false, Error = error };
}
/// <summary>
/// Detail of a single signature verification.
/// </summary>
public sealed record SignatureVerificationDetail
{
/// <summary>Key ID of the signature.</summary>
public required string KeyId { get; init; }
/// <summary>Whether the signature is valid.</summary>
public required bool Valid { get; init; }
/// <summary>Error message if verification failed.</summary>
public string? Error { get; init; }
}
/// <summary>
/// Default implementation of verdict signing service.
/// </summary>
public sealed class VerdictSigningService : IVerdictSigningService
{
private const string VerdictPayloadType = "application/vnd.stella-ops.verdict+json";
private readonly EnvelopeSignatureService _signatureService;
public VerdictSigningService()
: this(new EnvelopeSignatureService())
{
}
public VerdictSigningService(EnvelopeSignatureService signatureService)
{
_signatureService = signatureService;
}
public Task<VerdictSigningResult> SignAsync(StellaVerdict verdict, EnvelopeKey key, CancellationToken cancellationToken = default)
{
try
{
// Get the canonical payload for signing (excludes signatures)
var canonicalPayload = verdict.GetCanonicalPayload();
var payloadBytes = Encoding.UTF8.GetBytes(canonicalPayload);
// Compute Pre-Authentication Encoding (PAE) for DSSE
var paeBytes = ComputePae(VerdictPayloadType, payloadBytes);
// Sign using the envelope service
var signResult = _signatureService.Sign(paeBytes, key, cancellationToken);
if (!signResult.IsSuccess)
{
return Task.FromResult(VerdictSigningResult.Failed(
signResult.Error?.Message ?? "Signing failed"));
}
var envelopeSignature = signResult.Value;
// Create the verdict signature
var verdictSignature = new VerdictSignature
{
KeyId = envelopeSignature.KeyId ?? key.KeyId,
Sig = Convert.ToBase64String(envelopeSignature.Value.ToArray()),
Cert = null, // Certificate chain can be added if available
};
// Add the signature to the verdict
var signedVerdict = AddSignature(verdict, verdictSignature);
// Create DSSE envelope for external verification
var dsseSignature = DsseSignature.FromBytes(envelopeSignature.Value.Span, key.KeyId);
var envelope = new DsseEnvelope(
VerdictPayloadType,
payloadBytes,
new[] { dsseSignature });
return Task.FromResult(VerdictSigningResult.Succeeded(signedVerdict, envelope));
}
catch (Exception ex)
{
return Task.FromResult(VerdictSigningResult.Failed($"Signing failed: {ex.Message}"));
}
}
public Task<VerdictVerificationResult> VerifyAsync(StellaVerdict verdict, IEnumerable<EnvelopeKey> trustedKeys, CancellationToken cancellationToken = default)
{
try
{
if (verdict.Signatures.IsDefaultOrEmpty)
{
return Task.FromResult(VerdictVerificationResult.Failed("Verdict has no signatures"));
}
var keysByKeyId = trustedKeys.ToDictionary(k => k.KeyId, StringComparer.Ordinal);
var details = new List<SignatureVerificationDetail>();
// Get the canonical payload
var canonicalPayload = verdict.GetCanonicalPayload();
var payloadBytes = Encoding.UTF8.GetBytes(canonicalPayload);
var paeBytes = ComputePae(VerdictPayloadType, payloadBytes);
foreach (var signature in verdict.Signatures)
{
if (!keysByKeyId.TryGetValue(signature.KeyId, out var key))
{
details.Add(new SignatureVerificationDetail
{
KeyId = signature.KeyId,
Valid = false,
Error = "No trusted key found for signature",
});
continue;
}
try
{
var signatureBytes = Convert.FromBase64String(signature.Sig);
var envelopeSignature = new EnvelopeSignature(key.KeyId, key.AlgorithmId, signatureBytes);
var verifyResult = _signatureService.Verify(paeBytes, envelopeSignature, key, cancellationToken);
if (verifyResult.IsSuccess && verifyResult.Value)
{
details.Add(new SignatureVerificationDetail
{
KeyId = signature.KeyId,
Valid = true,
});
}
else
{
details.Add(new SignatureVerificationDetail
{
KeyId = signature.KeyId,
Valid = false,
Error = verifyResult.Error?.Message ?? "Signature verification failed",
});
}
}
catch (Exception ex)
{
details.Add(new SignatureVerificationDetail
{
KeyId = signature.KeyId,
Valid = false,
Error = $"Verification error: {ex.Message}",
});
}
}
return Task.FromResult(VerdictVerificationResult.Verified(details.ToImmutableArray()));
}
catch (Exception ex)
{
return Task.FromResult(VerdictVerificationResult.Failed($"Verification failed: {ex.Message}"));
}
}
public StellaVerdict AddSignature(StellaVerdict verdict, VerdictSignature signature)
{
var existingSignatures = verdict.Signatures.IsDefaultOrEmpty
? ImmutableArray<VerdictSignature>.Empty
: verdict.Signatures;
// Check for duplicate key ID
if (existingSignatures.Any(s => s.KeyId == signature.KeyId))
{
// Replace existing signature from same key
var updated = existingSignatures
.Where(s => s.KeyId != signature.KeyId)
.Append(signature)
.ToImmutableArray();
return verdict with { Signatures = updated };
}
return verdict with { Signatures = existingSignatures.Add(signature) };
}
/// <summary>
/// Computes the DSSE Pre-Authentication Encoding (PAE).
/// PAE(type, payload) = "DSSEv1" + SP + LEN(type) + SP + type + SP + LEN(payload) + SP + payload
/// </summary>
private static byte[] ComputePae(string payloadType, byte[] payload)
{
const string dssePrefix = "DSSEv1";
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
using var ms = new MemoryStream();
// Write prefix
ms.Write(Encoding.UTF8.GetBytes(dssePrefix));
ms.WriteByte((byte)' ');
// Write type length
WriteLength(ms, typeBytes.Length);
ms.WriteByte((byte)' ');
// Write type
ms.Write(typeBytes);
ms.WriteByte((byte)' ');
// Write payload length
WriteLength(ms, payload.Length);
ms.WriteByte((byte)' ');
// Write payload
ms.Write(payload);
return ms.ToArray();
}
private static void WriteLength(MemoryStream ms, int length)
{
var lengthBytes = Encoding.UTF8.GetBytes(length.ToString());
ms.Write(lengthBytes);
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\StellaOps.Replay.Core\StellaOps.Replay.Core.csproj" />
<ProjectReference Include="..\..\Policy\__Libraries\StellaOps.Policy\StellaOps.Policy.csproj" />
<ProjectReference Include="..\..\Attestor\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Contexts\verdict-1.0.jsonld" LogicalName="StellaOps.Verdict.Contexts.verdict-1.0.jsonld" Condition="Exists('Contexts\verdict-1.0.jsonld')" />
</ItemGroup>
</Project>