Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -9,8 +9,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="BouncyCastle.Cryptography" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="BouncyCastle.Cryptography" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>StellaOps.Attestor.Bundling</RootNamespace>
|
||||
<Description>Attestation bundle aggregation and rotation for long-term verification in air-gapped environments.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Bundle\StellaOps.Attestor.Bundle.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -9,12 +9,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>StellaOps.Attestor.GraphRoot</RootNamespace>
|
||||
<Description>Graph root attestation service for creating and verifying DSSE attestations of Merkle graph roots.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Evidence.Core\StellaOps.Evidence.Core.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,299 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IOciAttestationAttacher.cs
|
||||
// Sprint: SPRINT_20251228_002_BE_oci_attestation_attach (T1)
|
||||
// Task: Create OciAttestationAttacher service interface
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Attestor.Envelope;
|
||||
|
||||
namespace StellaOps.Attestor.Oci.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for attaching and retrieving DSSE attestations from OCI registries.
|
||||
/// Implements OCI Distribution Spec 1.1 referrers API for cosign compatibility.
|
||||
/// </summary>
|
||||
public interface IOciAttestationAttacher
|
||||
{
|
||||
/// <summary>
|
||||
/// Attaches a DSSE attestation to an OCI artifact.
|
||||
/// </summary>
|
||||
/// <param name="imageRef">Reference to the OCI artifact.</param>
|
||||
/// <param name="attestation">DSSE envelope containing the attestation.</param>
|
||||
/// <param name="options">Attachment options.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Result of the attachment operation.</returns>
|
||||
Task<AttachmentResult> AttachAsync(
|
||||
OciReference imageRef,
|
||||
DsseEnvelope attestation,
|
||||
AttachmentOptions? options = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all attestations attached to an OCI artifact.
|
||||
/// </summary>
|
||||
/// <param name="imageRef">Reference to the OCI artifact.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of attached attestations.</returns>
|
||||
Task<IReadOnlyList<AttachedAttestation>> ListAsync(
|
||||
OciReference imageRef,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a specific attestation by predicate type.
|
||||
/// </summary>
|
||||
/// <param name="imageRef">Reference to the OCI artifact.</param>
|
||||
/// <param name="predicateType">Predicate type URI to filter by.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The DSSE envelope if found, null otherwise.</returns>
|
||||
Task<DsseEnvelope?> FetchAsync(
|
||||
OciReference imageRef,
|
||||
string predicateType,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes an attestation from an OCI artifact.
|
||||
/// </summary>
|
||||
/// <param name="imageRef">Reference to the OCI artifact.</param>
|
||||
/// <param name="attestationDigest">Digest of the attestation to remove.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>True if removed, false if not found.</returns>
|
||||
Task<bool> RemoveAsync(
|
||||
OciReference imageRef,
|
||||
string attestationDigest,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to an OCI artifact.
|
||||
/// </summary>
|
||||
public sealed record OciReference
|
||||
{
|
||||
/// <summary>
|
||||
/// Registry hostname (e.g., "registry.example.com").
|
||||
/// </summary>
|
||||
public required string Registry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Repository name (e.g., "myorg/myapp").
|
||||
/// </summary>
|
||||
public required string Repository { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressable digest (e.g., "sha256:abc123...").
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional tag (e.g., "v1.0.0").
|
||||
/// </summary>
|
||||
public string? Tag { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the full reference string.
|
||||
/// </summary>
|
||||
public string FullReference => Tag is not null
|
||||
? $"{Registry}/{Repository}:{Tag}"
|
||||
: $"{Registry}/{Repository}@{Digest}";
|
||||
|
||||
/// <summary>
|
||||
/// Parses an OCI reference string.
|
||||
/// </summary>
|
||||
public static OciReference Parse(string reference)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(reference);
|
||||
|
||||
// Handle digest references: registry/repo@sha256:...
|
||||
var digestIndex = reference.IndexOf('@');
|
||||
if (digestIndex > 0)
|
||||
{
|
||||
var beforeDigest = reference[..digestIndex];
|
||||
var digest = reference[(digestIndex + 1)..];
|
||||
var (registry, repo) = ParseRegistryAndRepo(beforeDigest);
|
||||
return new OciReference
|
||||
{
|
||||
Registry = registry,
|
||||
Repository = repo,
|
||||
Digest = digest
|
||||
};
|
||||
}
|
||||
|
||||
// Handle tag references: registry/repo:tag
|
||||
var tagIndex = reference.LastIndexOf(':');
|
||||
if (tagIndex > 0)
|
||||
{
|
||||
var beforeTag = reference[..tagIndex];
|
||||
var tag = reference[(tagIndex + 1)..];
|
||||
|
||||
// Check if this is actually a port number
|
||||
if (!beforeTag.Contains('/') || tag.Contains('/'))
|
||||
{
|
||||
throw new ArgumentException($"Invalid OCI reference: {reference}", nameof(reference));
|
||||
}
|
||||
|
||||
var (registry, repo) = ParseRegistryAndRepo(beforeTag);
|
||||
return new OciReference
|
||||
{
|
||||
Registry = registry,
|
||||
Repository = repo,
|
||||
Digest = string.Empty, // Will be resolved
|
||||
Tag = tag
|
||||
};
|
||||
}
|
||||
|
||||
throw new ArgumentException($"Invalid OCI reference: {reference}", nameof(reference));
|
||||
}
|
||||
|
||||
private static (string Registry, string Repo) ParseRegistryAndRepo(string reference)
|
||||
{
|
||||
var firstSlash = reference.IndexOf('/');
|
||||
if (firstSlash < 0)
|
||||
{
|
||||
throw new ArgumentException($"Invalid OCI reference: {reference}");
|
||||
}
|
||||
|
||||
var registry = reference[..firstSlash];
|
||||
var repo = reference[(firstSlash + 1)..];
|
||||
|
||||
return (registry, repo);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for attestation attachment.
|
||||
/// </summary>
|
||||
public sealed record AttachmentOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Media type for the attestation. Default: DSSE envelope.
|
||||
/// </summary>
|
||||
public string MediaType { get; init; } = MediaTypes.DsseEnvelope;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to replace existing attestations with the same predicate type.
|
||||
/// </summary>
|
||||
public bool ReplaceExisting { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Additional OCI annotations to attach.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to record in Sigstore Rekor transparency log.
|
||||
/// </summary>
|
||||
public bool RecordInRekor { get; init; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of an attestation attachment operation.
|
||||
/// </summary>
|
||||
public sealed record AttachmentResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Content-addressable digest of the attestation blob.
|
||||
/// </summary>
|
||||
public required string AttestationDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Full OCI reference to the attached attestation manifest.
|
||||
/// </summary>
|
||||
public required string AttestationRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when attachment completed.
|
||||
/// </summary>
|
||||
public required DateTimeOffset AttachedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log entry ID if recorded in transparency log.
|
||||
/// </summary>
|
||||
public string? RekorLogId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about an attestation attached to an OCI artifact.
|
||||
/// </summary>
|
||||
public sealed record AttachedAttestation
|
||||
{
|
||||
/// <summary>
|
||||
/// Content-addressable digest of the attestation.
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Predicate type URI (e.g., "https://in-toto.io/attestation/vulns/v0.1").
|
||||
/// </summary>
|
||||
public required string PredicateType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when attestation was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// OCI annotations on the attestation manifest.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size of the attestation blob in bytes.
|
||||
/// </summary>
|
||||
public long Size { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Standard media types for attestation artifacts.
|
||||
/// </summary>
|
||||
public static class MediaTypes
|
||||
{
|
||||
/// <summary>
|
||||
/// DSSE envelope media type.
|
||||
/// </summary>
|
||||
public const string DsseEnvelope = "application/vnd.dsse.envelope.v1+json";
|
||||
|
||||
/// <summary>
|
||||
/// In-toto attestation bundle media type.
|
||||
/// </summary>
|
||||
public const string InTotoBundle = "application/vnd.in-toto+json";
|
||||
|
||||
/// <summary>
|
||||
/// Sigstore bundle media type.
|
||||
/// </summary>
|
||||
public const string SigstoreBundle = "application/vnd.dev.sigstore.bundle.v0.3+json";
|
||||
|
||||
/// <summary>
|
||||
/// OCI image manifest media type.
|
||||
/// </summary>
|
||||
public const string OciManifest = "application/vnd.oci.image.manifest.v1+json";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Standard annotation keys for attestation metadata.
|
||||
/// </summary>
|
||||
public static class AnnotationKeys
|
||||
{
|
||||
/// <summary>
|
||||
/// OCI standard: creation timestamp.
|
||||
/// </summary>
|
||||
public const string Created = "org.opencontainers.image.created";
|
||||
|
||||
/// <summary>
|
||||
/// StellaOps: predicate type.
|
||||
/// </summary>
|
||||
public const string PredicateType = "dev.stellaops/predicate-type";
|
||||
|
||||
/// <summary>
|
||||
/// StellaOps: signer identity.
|
||||
/// </summary>
|
||||
public const string SignerIdentity = "dev.stellaops/signer-identity";
|
||||
|
||||
/// <summary>
|
||||
/// Cosign compatibility: signature placeholder.
|
||||
/// </summary>
|
||||
public const string CosignSignature = "dev.sigstore.cosign/signature";
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log index.
|
||||
/// </summary>
|
||||
public const string RekorLogIndex = "dev.sigstore.rekor/logIndex";
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IOciRegistryClient.cs
|
||||
// Sprint: SPRINT_20251228_002_BE_oci_attestation_attach (T2)
|
||||
// Task: Define OCI registry client interface for ORAS operations
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Attestor.Oci.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for OCI registry operations using OCI Distribution Spec 1.1.
|
||||
/// </summary>
|
||||
public interface IOciRegistryClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Pushes a blob to the registry.
|
||||
/// </summary>
|
||||
/// <param name="registry">Registry hostname.</param>
|
||||
/// <param name="repository">Repository name.</param>
|
||||
/// <param name="content">Blob content.</param>
|
||||
/// <param name="digest">Expected content digest.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task PushBlobAsync(
|
||||
string registry,
|
||||
string repository,
|
||||
ReadOnlyMemory<byte> content,
|
||||
string digest,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a blob from the registry.
|
||||
/// </summary>
|
||||
/// <param name="registry">Registry hostname.</param>
|
||||
/// <param name="repository">Repository name.</param>
|
||||
/// <param name="digest">Blob digest.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Blob content.</returns>
|
||||
Task<ReadOnlyMemory<byte>> FetchBlobAsync(
|
||||
string registry,
|
||||
string repository,
|
||||
string digest,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Pushes a manifest to the registry.
|
||||
/// </summary>
|
||||
/// <param name="registry">Registry hostname.</param>
|
||||
/// <param name="repository">Repository name.</param>
|
||||
/// <param name="manifest">OCI manifest.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Manifest digest.</returns>
|
||||
Task<string> PushManifestAsync(
|
||||
string registry,
|
||||
string repository,
|
||||
OciManifest manifest,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a manifest from the registry.
|
||||
/// </summary>
|
||||
/// <param name="registry">Registry hostname.</param>
|
||||
/// <param name="repository">Repository name.</param>
|
||||
/// <param name="reference">Digest or tag.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>OCI manifest.</returns>
|
||||
Task<OciManifest> FetchManifestAsync(
|
||||
string registry,
|
||||
string repository,
|
||||
string reference,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists referrers to an artifact using OCI Distribution Spec 1.1 referrers API.
|
||||
/// </summary>
|
||||
/// <param name="registry">Registry hostname.</param>
|
||||
/// <param name="repository">Repository name.</param>
|
||||
/// <param name="digest">Subject artifact digest.</param>
|
||||
/// <param name="artifactType">Optional artifact type filter.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of referrer descriptors.</returns>
|
||||
Task<IReadOnlyList<OciDescriptor>> ListReferrersAsync(
|
||||
string registry,
|
||||
string repository,
|
||||
string digest,
|
||||
string? artifactType = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a manifest from the registry.
|
||||
/// </summary>
|
||||
/// <param name="registry">Registry hostname.</param>
|
||||
/// <param name="repository">Repository name.</param>
|
||||
/// <param name="digest">Manifest digest.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>True if deleted, false if not found.</returns>
|
||||
Task<bool> DeleteManifestAsync(
|
||||
string registry,
|
||||
string repository,
|
||||
string digest,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a tag to a digest.
|
||||
/// </summary>
|
||||
/// <param name="registry">Registry hostname.</param>
|
||||
/// <param name="repository">Repository name.</param>
|
||||
/// <param name="tag">Tag to resolve.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Content digest.</returns>
|
||||
Task<string> ResolveTagAsync(
|
||||
string registry,
|
||||
string repository,
|
||||
string tag,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OCI manifest structure per OCI Image Spec.
|
||||
/// </summary>
|
||||
public sealed record OciManifest
|
||||
{
|
||||
/// <summary>
|
||||
/// Schema version (always 2).
|
||||
/// </summary>
|
||||
public int SchemaVersion { get; init; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Media type of this manifest.
|
||||
/// </summary>
|
||||
public string MediaType { get; init; } = MediaTypes.OciManifest;
|
||||
|
||||
/// <summary>
|
||||
/// Optional artifact type for OCI 1.1 artifacts.
|
||||
/// </summary>
|
||||
public string? ArtifactType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Config descriptor.
|
||||
/// </summary>
|
||||
public required OciDescriptor Config { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Layer descriptors.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<OciDescriptor> Layers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject descriptor for OCI 1.1 referrers.
|
||||
/// </summary>
|
||||
public OciDescriptor? Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Annotations on this manifest.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OCI content descriptor.
|
||||
/// </summary>
|
||||
public sealed record OciDescriptor
|
||||
{
|
||||
/// <summary>
|
||||
/// Media type of the referenced content.
|
||||
/// </summary>
|
||||
public required string MediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressable digest.
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size in bytes.
|
||||
/// </summary>
|
||||
public required long Size { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional annotations.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional artifact type (for OCI 1.1).
|
||||
/// </summary>
|
||||
public string? ArtifactType { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// OrasAttestationAttacher.cs
|
||||
// Sprint: SPRINT_20251228_002_BE_oci_attestation_attach (T2)
|
||||
// Task: Implement OCI registry attachment via ORAS
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
|
||||
namespace StellaOps.Attestor.Oci.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of <see cref="IOciAttestationAttacher"/> using OCI Distribution Spec 1.1.
|
||||
/// Stores attestations as OCI artifacts with subject references for cosign compatibility.
|
||||
/// </summary>
|
||||
public sealed class OrasAttestationAttacher : IOciAttestationAttacher
|
||||
{
|
||||
private readonly IOciRegistryClient _registryClient;
|
||||
private readonly ILogger<OrasAttestationAttacher> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public OrasAttestationAttacher(
|
||||
IOciRegistryClient registryClient,
|
||||
ILogger<OrasAttestationAttacher> logger)
|
||||
{
|
||||
_registryClient = registryClient ?? throw new ArgumentNullException(nameof(registryClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<AttachmentResult> AttachAsync(
|
||||
OciReference imageRef,
|
||||
DsseEnvelope attestation,
|
||||
AttachmentOptions? options = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(imageRef);
|
||||
ArgumentNullException.ThrowIfNull(attestation);
|
||||
|
||||
options ??= new AttachmentOptions();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Attaching attestation to {Registry}/{Repository}@{Digest}",
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
TruncateDigest(imageRef.Digest));
|
||||
|
||||
// 1. Serialize DSSE envelope to canonical JSON
|
||||
var attestationBytes = SerializeCanonical(attestation);
|
||||
var attestationDigest = ComputeDigest(attestationBytes);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Attestation serialized: {Size} bytes, digest {Digest}",
|
||||
attestationBytes.Length,
|
||||
TruncateDigest(attestationDigest));
|
||||
|
||||
// 2. Check for existing attestation if ReplaceExisting=false
|
||||
if (!options.ReplaceExisting)
|
||||
{
|
||||
var existing = await FindExistingAttestationAsync(
|
||||
imageRef,
|
||||
attestation.PayloadType,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Attestation with predicate type {PredicateType} already exists at {Digest}",
|
||||
attestation.PayloadType,
|
||||
TruncateDigest(existing.Digest));
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Attestation with predicate type '{attestation.PayloadType}' already exists. " +
|
||||
"Use ReplaceExisting=true to overwrite.");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Push attestation blob
|
||||
await _registryClient.PushBlobAsync(
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
attestationBytes,
|
||||
attestationDigest,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug("Pushed attestation blob {Digest}", TruncateDigest(attestationDigest));
|
||||
|
||||
// 4. Create empty config blob (required by OCI spec)
|
||||
var emptyConfig = "{}"u8.ToArray();
|
||||
var emptyConfigDigest = ComputeDigest(emptyConfig);
|
||||
|
||||
await _registryClient.PushBlobAsync(
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
emptyConfig,
|
||||
emptyConfigDigest,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
// 5. Build manifest with subject reference
|
||||
var annotations = BuildAnnotations(attestation, options);
|
||||
var manifest = new OciManifest
|
||||
{
|
||||
SchemaVersion = 2,
|
||||
MediaType = MediaTypes.OciManifest,
|
||||
ArtifactType = options.MediaType,
|
||||
Subject = new OciDescriptor
|
||||
{
|
||||
MediaType = MediaTypes.OciManifest,
|
||||
Digest = imageRef.Digest,
|
||||
Size = 0 // Referrer doesn't need subject size
|
||||
},
|
||||
Config = new OciDescriptor
|
||||
{
|
||||
MediaType = "application/vnd.oci.empty.v1+json",
|
||||
Digest = emptyConfigDigest,
|
||||
Size = emptyConfig.Length
|
||||
},
|
||||
Layers =
|
||||
[
|
||||
new OciDescriptor
|
||||
{
|
||||
MediaType = options.MediaType,
|
||||
Digest = attestationDigest,
|
||||
Size = attestationBytes.Length,
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
[AnnotationKeys.PredicateType] = attestation.PayloadType
|
||||
}
|
||||
}
|
||||
],
|
||||
Annotations = annotations
|
||||
};
|
||||
|
||||
// 6. Push manifest
|
||||
var manifestDigest = await _registryClient.PushManifestAsync(
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
manifest,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Attached attestation {PredicateType} to {Registry}/{Repository}@{ImageDigest} as {ManifestDigest}",
|
||||
attestation.PayloadType,
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
TruncateDigest(imageRef.Digest),
|
||||
TruncateDigest(manifestDigest));
|
||||
|
||||
return new AttachmentResult
|
||||
{
|
||||
AttestationDigest = attestationDigest,
|
||||
AttestationRef = $"{imageRef.Registry}/{imageRef.Repository}@{manifestDigest}",
|
||||
AttachedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<AttachedAttestation>> ListAsync(
|
||||
OciReference imageRef,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(imageRef);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Listing attestations for {Registry}/{Repository}@{Digest}",
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
TruncateDigest(imageRef.Digest));
|
||||
|
||||
var referrers = await _registryClient.ListReferrersAsync(
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
imageRef.Digest,
|
||||
artifactType: null, // Get all types
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
var attestations = new List<AttachedAttestation>();
|
||||
|
||||
foreach (var referrer in referrers)
|
||||
{
|
||||
// Filter to DSSE envelope types
|
||||
if (referrer.MediaType != MediaTypes.DsseEnvelope &&
|
||||
referrer.ArtifactType != MediaTypes.DsseEnvelope)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var predicateType = referrer.Annotations?.GetValueOrDefault(AnnotationKeys.PredicateType)
|
||||
?? "unknown";
|
||||
|
||||
var createdAtStr = referrer.Annotations?.GetValueOrDefault(AnnotationKeys.Created);
|
||||
var createdAt = DateTimeOffset.TryParse(createdAtStr, out var dt)
|
||||
? dt
|
||||
: DateTimeOffset.MinValue;
|
||||
|
||||
attestations.Add(new AttachedAttestation
|
||||
{
|
||||
Digest = referrer.Digest,
|
||||
PredicateType = predicateType,
|
||||
CreatedAt = createdAt,
|
||||
Annotations = referrer.Annotations,
|
||||
Size = referrer.Size
|
||||
});
|
||||
}
|
||||
|
||||
// Deterministic ordering: by predicate type, then by creation time (newest first)
|
||||
return attestations
|
||||
.OrderBy(a => a.PredicateType, StringComparer.Ordinal)
|
||||
.ThenByDescending(a => a.CreatedAt)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<DsseEnvelope?> FetchAsync(
|
||||
OciReference imageRef,
|
||||
string predicateType,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(imageRef);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(predicateType);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Fetching attestation {PredicateType} from {Registry}/{Repository}@{Digest}",
|
||||
predicateType,
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
TruncateDigest(imageRef.Digest));
|
||||
|
||||
var attestations = await ListAsync(imageRef, ct).ConfigureAwait(false);
|
||||
var target = attestations.FirstOrDefault(a => a.PredicateType == predicateType);
|
||||
|
||||
if (target is null)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"No attestation with predicate type {PredicateType} found",
|
||||
predicateType);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch the attestation manifest to get the layer digest
|
||||
var manifest = await _registryClient.FetchManifestAsync(
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
target.Digest,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (manifest.Layers.Count == 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Attestation manifest {Digest} has no layers",
|
||||
TruncateDigest(target.Digest));
|
||||
return null;
|
||||
}
|
||||
|
||||
var layerDigest = manifest.Layers[0].Digest;
|
||||
|
||||
// Fetch the attestation blob
|
||||
var blobBytes = await _registryClient.FetchBlobAsync(
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
layerDigest,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
return DeserializeEnvelope(blobBytes);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<bool> RemoveAsync(
|
||||
OciReference imageRef,
|
||||
string attestationDigest,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(imageRef);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(attestationDigest);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Removing attestation {AttestationDigest} from {Registry}/{Repository}@{Digest}",
|
||||
TruncateDigest(attestationDigest),
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
TruncateDigest(imageRef.Digest));
|
||||
|
||||
return await _registryClient.DeleteManifestAsync(
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
attestationDigest,
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<AttachedAttestation?> FindExistingAttestationAsync(
|
||||
OciReference imageRef,
|
||||
string predicateType,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var attestations = await ListAsync(imageRef, ct).ConfigureAwait(false);
|
||||
return attestations.FirstOrDefault(a => a.PredicateType == predicateType);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildAnnotations(
|
||||
DsseEnvelope envelope,
|
||||
AttachmentOptions options)
|
||||
{
|
||||
var annotations = new Dictionary<string, string>
|
||||
{
|
||||
[AnnotationKeys.Created] = DateTimeOffset.UtcNow.ToString("O"),
|
||||
[AnnotationKeys.PredicateType] = envelope.PayloadType,
|
||||
[AnnotationKeys.CosignSignature] = "" // Cosign compatibility placeholder
|
||||
};
|
||||
|
||||
// Add signer identity if available
|
||||
var firstSignature = envelope.Signatures.FirstOrDefault();
|
||||
if (firstSignature?.KeyId is not null)
|
||||
{
|
||||
annotations[AnnotationKeys.SignerIdentity] = firstSignature.KeyId;
|
||||
}
|
||||
|
||||
// Merge user-provided annotations
|
||||
if (options.Annotations is not null)
|
||||
{
|
||||
foreach (var (key, value) in options.Annotations)
|
||||
{
|
||||
annotations[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return annotations;
|
||||
}
|
||||
|
||||
private static byte[] SerializeCanonical(DsseEnvelope envelope)
|
||||
{
|
||||
// Use the serializer from StellaOps.Attestor.Envelope
|
||||
var options = new DsseEnvelopeSerializationOptions
|
||||
{
|
||||
EmitCompactJson = true,
|
||||
EmitExpandedJson = false
|
||||
};
|
||||
|
||||
var result = DsseEnvelopeSerializer.Serialize(envelope, options);
|
||||
|
||||
return result.CompactJson ?? throw new InvalidOperationException(
|
||||
"Failed to serialize DSSE envelope to compact JSON");
|
||||
}
|
||||
|
||||
private static DsseEnvelope DeserializeEnvelope(ReadOnlyMemory<byte> bytes)
|
||||
{
|
||||
// Parse the compact DSSE envelope format
|
||||
var json = JsonDocument.Parse(bytes);
|
||||
var root = json.RootElement;
|
||||
|
||||
var payloadType = root.GetProperty("payloadType").GetString()
|
||||
?? throw new InvalidOperationException("Missing payloadType");
|
||||
|
||||
var payloadBase64 = root.GetProperty("payload").GetString()
|
||||
?? throw new InvalidOperationException("Missing payload");
|
||||
|
||||
var payload = Convert.FromBase64String(payloadBase64);
|
||||
|
||||
var signatures = new List<DsseSignature>();
|
||||
if (root.TryGetProperty("signatures", out var sigsElement))
|
||||
{
|
||||
foreach (var sigElement in sigsElement.EnumerateArray())
|
||||
{
|
||||
var keyId = sigElement.TryGetProperty("keyid", out var keyIdProp)
|
||||
? keyIdProp.GetString()
|
||||
: null;
|
||||
|
||||
var sig = sigElement.GetProperty("sig").GetString()
|
||||
?? throw new InvalidOperationException("Missing sig");
|
||||
|
||||
signatures.Add(new DsseSignature(signature: sig, keyId: keyId));
|
||||
}
|
||||
}
|
||||
|
||||
return new DsseEnvelope(payloadType, payload, signatures);
|
||||
}
|
||||
|
||||
private static string ComputeDigest(ReadOnlySpan<byte> content)
|
||||
{
|
||||
var hash = SHA256.HashData(content);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
private static string TruncateDigest(string digest)
|
||||
{
|
||||
if (string.IsNullOrEmpty(digest))
|
||||
{
|
||||
return digest;
|
||||
}
|
||||
|
||||
var colonIndex = digest.IndexOf(':');
|
||||
if (colonIndex < 0 || digest.Length < colonIndex + 13)
|
||||
{
|
||||
return digest;
|
||||
}
|
||||
|
||||
return digest[..(colonIndex + 13)] + "...";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RootNamespace>StellaOps.Attestor.Oci</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -284,7 +284,7 @@ public sealed class FileSystemRootStore : IOfflineRootStore
|
||||
.Trim();
|
||||
|
||||
var certBytes = Convert.FromBase64String(base64Content);
|
||||
collection.Add(new X509Certificate2(certBytes));
|
||||
collection.Add(X509CertificateLoader.LoadCertificate(certBytes));
|
||||
|
||||
startIndex = end + endMarker.Length;
|
||||
}
|
||||
|
||||
@@ -689,7 +689,7 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
{
|
||||
// Try as raw base64
|
||||
var certBytes = Convert.FromBase64String(pem.Trim());
|
||||
return new X509Certificate2(certBytes);
|
||||
return X509CertificateLoader.LoadCertificate(certBytes);
|
||||
}
|
||||
|
||||
var base64Start = begin + beginMarker.Length;
|
||||
@@ -699,7 +699,7 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
.Trim();
|
||||
|
||||
var bytes = Convert.FromBase64String(base64Content);
|
||||
return new X509Certificate2(bytes);
|
||||
return X509CertificateLoader.LoadCertificate(bytes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="BouncyCastle.Cryptography" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>StellaOps.Attestor.Offline</RootNamespace>
|
||||
<Description>Offline verification of attestation bundles for air-gapped environments.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Bundle\StellaOps.Attestor.Bundle.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Bundling\StellaOps.Attestor.Bundling.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Verify\StellaOps.Attestor.Verify.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,245 @@
|
||||
-- Attestor Schema Migration 001: Initial Schema (Compacted)
|
||||
-- Consolidated from 20251214000001_AddProofChainSchema.sql and 20251216_001_create_rekor_submission_queue.sql
|
||||
-- for 1.0.0 release
|
||||
-- Creates the proofchain schema for proof chain persistence and attestor schema for Rekor queue
|
||||
|
||||
-- ============================================================================
|
||||
-- Extensions
|
||||
-- ============================================================================
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
-- ============================================================================
|
||||
-- Schema Creation
|
||||
-- ============================================================================
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS proofchain;
|
||||
CREATE SCHEMA IF NOT EXISTS attestor;
|
||||
|
||||
-- ============================================================================
|
||||
-- Enum Types
|
||||
-- ============================================================================
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'verification_result' AND typnamespace = 'proofchain'::regnamespace) THEN
|
||||
CREATE TYPE proofchain.verification_result AS ENUM ('pass', 'fail', 'pending');
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ============================================================================
|
||||
-- ProofChain Schema Tables
|
||||
-- ============================================================================
|
||||
|
||||
-- Trust anchors table (create first - no dependencies)
|
||||
CREATE TABLE IF NOT EXISTS proofchain.trust_anchors (
|
||||
anchor_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
purl_pattern TEXT NOT NULL,
|
||||
allowed_keyids TEXT[] NOT NULL,
|
||||
allowed_predicate_types TEXT[],
|
||||
policy_ref TEXT,
|
||||
policy_version TEXT,
|
||||
revoked_keys TEXT[] DEFAULT '{}',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_trust_anchors_pattern ON proofchain.trust_anchors(purl_pattern);
|
||||
CREATE INDEX IF NOT EXISTS idx_trust_anchors_active ON proofchain.trust_anchors(is_active) WHERE is_active = TRUE;
|
||||
|
||||
COMMENT ON TABLE proofchain.trust_anchors IS 'Trust anchor configurations for dependency verification';
|
||||
COMMENT ON COLUMN proofchain.trust_anchors.purl_pattern IS 'PURL glob pattern (e.g., pkg:npm/*)';
|
||||
COMMENT ON COLUMN proofchain.trust_anchors.revoked_keys IS 'Key IDs that have been revoked but may appear in old proofs';
|
||||
|
||||
-- SBOM entries table
|
||||
CREATE TABLE IF NOT EXISTS proofchain.sbom_entries (
|
||||
entry_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
bom_digest VARCHAR(64) NOT NULL,
|
||||
purl TEXT NOT NULL,
|
||||
version TEXT,
|
||||
artifact_digest VARCHAR(64),
|
||||
trust_anchor_id UUID REFERENCES proofchain.trust_anchors(anchor_id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT uq_sbom_entry UNIQUE (bom_digest, purl, version)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sbom_entries_bom_digest ON proofchain.sbom_entries(bom_digest);
|
||||
CREATE INDEX IF NOT EXISTS idx_sbom_entries_purl ON proofchain.sbom_entries(purl);
|
||||
CREATE INDEX IF NOT EXISTS idx_sbom_entries_artifact ON proofchain.sbom_entries(artifact_digest);
|
||||
CREATE INDEX IF NOT EXISTS idx_sbom_entries_anchor ON proofchain.sbom_entries(trust_anchor_id);
|
||||
|
||||
COMMENT ON TABLE proofchain.sbom_entries IS 'SBOM component entries with content-addressed identifiers';
|
||||
COMMENT ON COLUMN proofchain.sbom_entries.bom_digest IS 'SHA-256 hash of the parent SBOM document';
|
||||
COMMENT ON COLUMN proofchain.sbom_entries.purl IS 'Package URL (PURL) of the component';
|
||||
COMMENT ON COLUMN proofchain.sbom_entries.artifact_digest IS 'SHA-256 hash of the component artifact if available';
|
||||
|
||||
-- DSSE envelopes table
|
||||
CREATE TABLE IF NOT EXISTS proofchain.dsse_envelopes (
|
||||
env_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
entry_id UUID NOT NULL REFERENCES proofchain.sbom_entries(entry_id) ON DELETE CASCADE,
|
||||
predicate_type TEXT NOT NULL,
|
||||
signer_keyid TEXT NOT NULL,
|
||||
body_hash VARCHAR(64) NOT NULL,
|
||||
envelope_blob_ref TEXT NOT NULL,
|
||||
signed_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT uq_dsse_envelope UNIQUE (entry_id, predicate_type, body_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dsse_entry_predicate ON proofchain.dsse_envelopes(entry_id, predicate_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_dsse_signer ON proofchain.dsse_envelopes(signer_keyid);
|
||||
CREATE INDEX IF NOT EXISTS idx_dsse_body_hash ON proofchain.dsse_envelopes(body_hash);
|
||||
|
||||
COMMENT ON TABLE proofchain.dsse_envelopes IS 'Signed DSSE envelopes for proof chain statements';
|
||||
COMMENT ON COLUMN proofchain.dsse_envelopes.predicate_type IS 'Predicate type URI (e.g., evidence.stella/v1)';
|
||||
COMMENT ON COLUMN proofchain.dsse_envelopes.envelope_blob_ref IS 'Reference to blob storage (OCI, S3, file)';
|
||||
|
||||
-- Spines table
|
||||
CREATE TABLE IF NOT EXISTS proofchain.spines (
|
||||
entry_id UUID PRIMARY KEY REFERENCES proofchain.sbom_entries(entry_id) ON DELETE CASCADE,
|
||||
bundle_id VARCHAR(64) NOT NULL,
|
||||
evidence_ids TEXT[] NOT NULL,
|
||||
reasoning_id VARCHAR(64) NOT NULL,
|
||||
vex_id VARCHAR(64) NOT NULL,
|
||||
anchor_id UUID REFERENCES proofchain.trust_anchors(anchor_id) ON DELETE SET NULL,
|
||||
policy_version TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT uq_spine_bundle UNIQUE (bundle_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_spines_bundle ON proofchain.spines(bundle_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_spines_anchor ON proofchain.spines(anchor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_spines_policy ON proofchain.spines(policy_version);
|
||||
|
||||
COMMENT ON TABLE proofchain.spines IS 'Proof spines linking evidence to verdicts via merkle aggregation';
|
||||
COMMENT ON COLUMN proofchain.spines.bundle_id IS 'ProofBundleID (merkle root of all components)';
|
||||
COMMENT ON COLUMN proofchain.spines.evidence_ids IS 'Array of EvidenceIDs in sorted order';
|
||||
|
||||
-- Rekor entries table
|
||||
CREATE TABLE IF NOT EXISTS proofchain.rekor_entries (
|
||||
dsse_sha256 VARCHAR(64) PRIMARY KEY,
|
||||
log_index BIGINT NOT NULL,
|
||||
log_id TEXT NOT NULL,
|
||||
uuid TEXT NOT NULL,
|
||||
integrated_time BIGINT NOT NULL,
|
||||
inclusion_proof JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
env_id UUID REFERENCES proofchain.dsse_envelopes(env_id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rekor_log_index ON proofchain.rekor_entries(log_index);
|
||||
CREATE INDEX IF NOT EXISTS idx_rekor_log_id ON proofchain.rekor_entries(log_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_rekor_uuid ON proofchain.rekor_entries(uuid);
|
||||
CREATE INDEX IF NOT EXISTS idx_rekor_env ON proofchain.rekor_entries(env_id);
|
||||
|
||||
COMMENT ON TABLE proofchain.rekor_entries IS 'Rekor transparency log entries for verification';
|
||||
COMMENT ON COLUMN proofchain.rekor_entries.inclusion_proof IS 'Merkle inclusion proof from Rekor';
|
||||
|
||||
-- Audit log table
|
||||
CREATE TABLE IF NOT EXISTS proofchain.audit_log (
|
||||
log_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
operation TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
actor TEXT,
|
||||
details JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_entity ON proofchain.audit_log(entity_type, entity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_created ON proofchain.audit_log(created_at DESC);
|
||||
|
||||
COMMENT ON TABLE proofchain.audit_log IS 'Audit log for proof chain operations';
|
||||
|
||||
-- ============================================================================
|
||||
-- Attestor Schema Tables
|
||||
-- ============================================================================
|
||||
|
||||
-- Rekor submission queue table
|
||||
CREATE TABLE IF NOT EXISTS attestor.rekor_submission_queue (
|
||||
id UUID PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
bundle_sha256 TEXT NOT NULL,
|
||||
dsse_payload BYTEA NOT NULL,
|
||||
backend TEXT NOT NULL DEFAULT 'primary',
|
||||
|
||||
-- Status lifecycle: pending -> submitting -> submitted | retrying -> dead_letter
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'submitting', 'retrying', 'submitted', 'dead_letter')),
|
||||
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||
max_attempts INTEGER NOT NULL DEFAULT 5,
|
||||
next_retry_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
-- Populated on success
|
||||
rekor_uuid TEXT,
|
||||
rekor_index BIGINT,
|
||||
|
||||
-- Populated on failure
|
||||
last_error TEXT
|
||||
);
|
||||
|
||||
COMMENT ON TABLE attestor.rekor_submission_queue IS
|
||||
'Durable retry queue for Rekor transparency log submissions';
|
||||
COMMENT ON COLUMN attestor.rekor_submission_queue.status IS
|
||||
'Submission lifecycle: pending -> submitting -> (submitted | retrying -> dead_letter)';
|
||||
COMMENT ON COLUMN attestor.rekor_submission_queue.backend IS
|
||||
'Target Rekor backend (primary or mirror)';
|
||||
COMMENT ON COLUMN attestor.rekor_submission_queue.dsse_payload IS
|
||||
'Serialized DSSE envelope to submit';
|
||||
|
||||
-- Index for dequeue operations (status + next_retry_at for SKIP LOCKED queries)
|
||||
CREATE INDEX IF NOT EXISTS idx_rekor_queue_dequeue
|
||||
ON attestor.rekor_submission_queue (status, next_retry_at)
|
||||
WHERE status IN ('pending', 'retrying');
|
||||
|
||||
-- Index for tenant-scoped queries
|
||||
CREATE INDEX IF NOT EXISTS idx_rekor_queue_tenant
|
||||
ON attestor.rekor_submission_queue (tenant_id);
|
||||
|
||||
-- Index for bundle lookup (deduplication check)
|
||||
CREATE INDEX IF NOT EXISTS idx_rekor_queue_bundle
|
||||
ON attestor.rekor_submission_queue (tenant_id, bundle_sha256);
|
||||
|
||||
-- Index for dead letter management
|
||||
CREATE INDEX IF NOT EXISTS idx_rekor_queue_dead_letter
|
||||
ON attestor.rekor_submission_queue (status, updated_at)
|
||||
WHERE status = 'dead_letter';
|
||||
|
||||
-- Index for cleanup of completed submissions
|
||||
CREATE INDEX IF NOT EXISTS idx_rekor_queue_completed
|
||||
ON attestor.rekor_submission_queue (status, updated_at)
|
||||
WHERE status = 'submitted';
|
||||
|
||||
-- ============================================================================
|
||||
-- Trigger Functions
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION proofchain.update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Apply updated_at trigger to trust_anchors
|
||||
DROP TRIGGER IF EXISTS update_trust_anchors_updated_at ON proofchain.trust_anchors;
|
||||
CREATE TRIGGER update_trust_anchors_updated_at
|
||||
BEFORE UPDATE ON proofchain.trust_anchors
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION proofchain.update_updated_at_column();
|
||||
|
||||
-- Apply updated_at trigger to rekor_submission_queue
|
||||
DROP TRIGGER IF EXISTS update_rekor_queue_updated_at ON attestor.rekor_submission_queue;
|
||||
CREATE TRIGGER update_rekor_queue_updated_at
|
||||
BEFORE UPDATE ON attestor.rekor_submission_queue
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION proofchain.update_updated_at_column();
|
||||
@@ -0,0 +1,22 @@
|
||||
# Archived Pre-1.0 Migrations
|
||||
|
||||
This directory contains the original migrations that were compacted into `001_initial_schema.sql`
|
||||
for the 1.0.0 release.
|
||||
|
||||
## Original Files
|
||||
- `20251214000001_AddProofChainSchema.sql` - ProofChain schema (trust_anchors, sbom_entries, dsse_envelopes, spines, rekor_entries, audit_log)
|
||||
- `20251214000002_RollbackProofChainSchema.sql` - Rollback script (reference only)
|
||||
|
||||
## Why Archived
|
||||
Pre-1.0, the schema evolved incrementally. For 1.0.0, migrations were compacted into a single
|
||||
initial schema to:
|
||||
- Simplify new deployments
|
||||
- Reduce startup time
|
||||
- Provide cleaner upgrade path
|
||||
|
||||
## For Existing Deployments
|
||||
If upgrading from pre-1.0, run the reset script directly with psql:
|
||||
```bash
|
||||
psql -h <host> -U <user> -d <db> -f devops/scripts/migrations-reset-pre-1.0.sql
|
||||
```
|
||||
This updates `schema_migrations` to recognize the compacted schema.
|
||||
@@ -10,8 +10,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -161,6 +161,16 @@ public sealed class PredicateSchemaValidator : IJsonSchemaValidator
|
||||
case "reachability-subgraph.stella/v1":
|
||||
errors.AddRange(ValidateReachabilitySubgraphPredicate(root));
|
||||
break;
|
||||
// Delta predicate types for lineage comparison (Sprint 20251228_007)
|
||||
case "stella.ops/vex-delta@v1":
|
||||
errors.AddRange(ValidateVexDeltaPredicate(root));
|
||||
break;
|
||||
case "stella.ops/sbom-delta@v1":
|
||||
errors.AddRange(ValidateSbomDeltaPredicate(root));
|
||||
break;
|
||||
case "stella.ops/verdict-delta@v1":
|
||||
errors.AddRange(ValidateVerdictDeltaPredicate(root));
|
||||
break;
|
||||
}
|
||||
|
||||
return errors.Count > 0
|
||||
@@ -200,6 +210,10 @@ public sealed class PredicateSchemaValidator : IJsonSchemaValidator
|
||||
"https://stella-ops.org/predicates/sbom-linkage/v1" => true,
|
||||
"delta-verdict.stella/v1" => true,
|
||||
"reachability-subgraph.stella/v1" => true,
|
||||
// Delta predicate types for lineage comparison (Sprint 20251228_007)
|
||||
"stella.ops/vex-delta@v1" => true,
|
||||
"stella.ops/sbom-delta@v1" => true,
|
||||
"stella.ops/verdict-delta@v1" => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
@@ -282,4 +296,61 @@ public sealed class PredicateSchemaValidator : IJsonSchemaValidator
|
||||
if (!root.TryGetProperty("analysis", out _))
|
||||
yield return new() { Path = "/analysis", Message = "Required property missing", Keyword = "required" };
|
||||
}
|
||||
|
||||
private static IEnumerable<SchemaValidationError> ValidateVexDeltaPredicate(JsonElement root)
|
||||
{
|
||||
// Required: fromDigest, toDigest, tenantId, summary, comparedAt
|
||||
if (!root.TryGetProperty("fromDigest", out _))
|
||||
yield return new() { Path = "/fromDigest", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("toDigest", out _))
|
||||
yield return new() { Path = "/toDigest", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("tenantId", out _))
|
||||
yield return new() { Path = "/tenantId", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("summary", out _))
|
||||
yield return new() { Path = "/summary", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("comparedAt", out _))
|
||||
yield return new() { Path = "/comparedAt", Message = "Required property missing", Keyword = "required" };
|
||||
}
|
||||
|
||||
private static IEnumerable<SchemaValidationError> ValidateSbomDeltaPredicate(JsonElement root)
|
||||
{
|
||||
// Required: fromDigest, toDigest, fromSbomDigest, toSbomDigest, tenantId, summary, comparedAt
|
||||
if (!root.TryGetProperty("fromDigest", out _))
|
||||
yield return new() { Path = "/fromDigest", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("toDigest", out _))
|
||||
yield return new() { Path = "/toDigest", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("fromSbomDigest", out _))
|
||||
yield return new() { Path = "/fromSbomDigest", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("toSbomDigest", out _))
|
||||
yield return new() { Path = "/toSbomDigest", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("tenantId", out _))
|
||||
yield return new() { Path = "/tenantId", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("summary", out _))
|
||||
yield return new() { Path = "/summary", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("comparedAt", out _))
|
||||
yield return new() { Path = "/comparedAt", Message = "Required property missing", Keyword = "required" };
|
||||
}
|
||||
|
||||
private static IEnumerable<SchemaValidationError> ValidateVerdictDeltaPredicate(JsonElement root)
|
||||
{
|
||||
// Required: fromDigest, toDigest, tenantId, fromPolicyVersion, toPolicyVersion, fromVerdict, toVerdict, summary, comparedAt
|
||||
if (!root.TryGetProperty("fromDigest", out _))
|
||||
yield return new() { Path = "/fromDigest", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("toDigest", out _))
|
||||
yield return new() { Path = "/toDigest", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("tenantId", out _))
|
||||
yield return new() { Path = "/tenantId", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("fromPolicyVersion", out _))
|
||||
yield return new() { Path = "/fromPolicyVersion", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("toPolicyVersion", out _))
|
||||
yield return new() { Path = "/toPolicyVersion", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("fromVerdict", out _))
|
||||
yield return new() { Path = "/fromVerdict", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("toVerdict", out _))
|
||||
yield return new() { Path = "/toVerdict", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("summary", out _))
|
||||
yield return new() { Path = "/summary", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("comparedAt", out _))
|
||||
yield return new() { Path = "/comparedAt", Message = "Required property missing", Keyword = "required" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ public sealed class Rfc8785JsonCanonicalizer : IJsonCanonicalizer
|
||||
|
||||
foreach (var (name, value) in properties)
|
||||
{
|
||||
writer.WritePropertyName(NormalizeString(name));
|
||||
writer.WritePropertyName(NormalizeString(name)!);
|
||||
WriteCanonical(writer, value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
@@ -159,7 +159,7 @@ public sealed class Rfc8785JsonCanonicalizer : IJsonCanonicalizer
|
||||
writer.WriteStartObject();
|
||||
foreach (var (name, value) in properties)
|
||||
{
|
||||
writer.WritePropertyName(NormalizeString(name));
|
||||
writer.WritePropertyName(NormalizeString(name)!);
|
||||
WriteCanonical(writer, value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SbomDeltaPredicate.cs
|
||||
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii
|
||||
// Task: LIN-BE-024-DELTA-PREDICATES
|
||||
// Description: DSSE predicate for SBOM delta attestations between artifact versions.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Predicates;
|
||||
|
||||
/// <summary>
|
||||
/// DSSE predicate for SBOM delta attestation between two artifact versions.
|
||||
/// predicateType: stella.ops/sbom-delta@v1
|
||||
/// </summary>
|
||||
public sealed record SbomDeltaPredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// The predicate type URI for SBOM delta attestations.
|
||||
/// </summary>
|
||||
public const string PredicateType = "stella.ops/sbom-delta@v1";
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the source (baseline) artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fromDigest")]
|
||||
public required string FromDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the target (current) artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("toDigest")]
|
||||
public required string ToDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the source SBOM.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fromSbomDigest")]
|
||||
public required string FromSbomDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the target SBOM.
|
||||
/// </summary>
|
||||
[JsonPropertyName("toSbomDigest")]
|
||||
public required string ToSbomDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tenantId")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Components added in the target SBOM.
|
||||
/// </summary>
|
||||
[JsonPropertyName("added")]
|
||||
public ImmutableArray<SbomDeltaComponent> Added { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Components removed from the baseline SBOM.
|
||||
/// </summary>
|
||||
[JsonPropertyName("removed")]
|
||||
public ImmutableArray<SbomDeltaComponent> Removed { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Components that changed version between SBOMs.
|
||||
/// </summary>
|
||||
[JsonPropertyName("versionChanged")]
|
||||
public ImmutableArray<SbomDeltaVersionChange> VersionChanged { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Summary counts for the delta.
|
||||
/// </summary>
|
||||
[JsonPropertyName("summary")]
|
||||
public required SbomDeltaSummary Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the comparison was performed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("comparedAt")]
|
||||
public required DateTimeOffset ComparedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the delta computation algorithm.
|
||||
/// </summary>
|
||||
[JsonPropertyName("algorithmVersion")]
|
||||
public string AlgorithmVersion { get; init; } = "1.0";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A component included in an SBOM delta.
|
||||
/// </summary>
|
||||
public sealed record SbomDeltaComponent
|
||||
{
|
||||
/// <summary>
|
||||
/// Package URL (PURL) of the component.
|
||||
/// </summary>
|
||||
[JsonPropertyName("purl")]
|
||||
public required string Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component type (library, framework, application, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ecosystem (npm, nuget, maven, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("ecosystem")]
|
||||
public string? Ecosystem { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Known vulnerabilities associated with this component.
|
||||
/// </summary>
|
||||
[JsonPropertyName("knownVulnerabilities")]
|
||||
public ImmutableArray<string> KnownVulnerabilities { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// License identifiers for this component.
|
||||
/// </summary>
|
||||
[JsonPropertyName("licenses")]
|
||||
public ImmutableArray<string> Licenses { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A component version change in SBOM delta.
|
||||
/// </summary>
|
||||
public sealed record SbomDeltaVersionChange
|
||||
{
|
||||
/// <summary>
|
||||
/// Package URL (PURL) of the component (without version).
|
||||
/// </summary>
|
||||
[JsonPropertyName("purl")]
|
||||
public required string Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("previousVersion")]
|
||||
public required string PreviousVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("currentVersion")]
|
||||
public required string CurrentVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of version change (major, minor, patch, unknown).
|
||||
/// </summary>
|
||||
[JsonPropertyName("changeType")]
|
||||
public required string ChangeType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerabilities fixed by the upgrade.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerabilitiesFixed")]
|
||||
public ImmutableArray<string> VulnerabilitiesFixed { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerabilities introduced by the change.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerabilitiesIntroduced")]
|
||||
public ImmutableArray<string> VulnerabilitiesIntroduced { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of SBOM delta counts.
|
||||
/// </summary>
|
||||
public sealed record SbomDeltaSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of components added.
|
||||
/// </summary>
|
||||
[JsonPropertyName("addedCount")]
|
||||
public required int AddedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of components removed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("removedCount")]
|
||||
public required int RemovedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of components with version changes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("versionChangedCount")]
|
||||
public required int VersionChangedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of unchanged components.
|
||||
/// </summary>
|
||||
[JsonPropertyName("unchangedCount")]
|
||||
public required int UnchangedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total component count in baseline SBOM.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fromTotalCount")]
|
||||
public required int FromTotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total component count in target SBOM.
|
||||
/// </summary>
|
||||
[JsonPropertyName("toTotalCount")]
|
||||
public required int ToTotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of vulnerabilities fixed by changes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerabilitiesFixedCount")]
|
||||
public required int VulnerabilitiesFixedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of vulnerabilities introduced by changes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerabilitiesIntroducedCount")]
|
||||
public required int VulnerabilitiesIntroducedCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VerdictDeltaPredicate.cs
|
||||
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii
|
||||
// Task: LIN-BE-024-DELTA-PREDICATES
|
||||
// Description: DSSE predicate for policy verdict delta attestations between artifact versions.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Predicates;
|
||||
|
||||
/// <summary>
|
||||
/// DSSE predicate for policy verdict delta attestation between two artifact versions.
|
||||
/// predicateType: stella.ops/verdict-delta@v1
|
||||
/// </summary>
|
||||
public sealed record VerdictDeltaPredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// The predicate type URI for verdict delta attestations.
|
||||
/// </summary>
|
||||
public const string PredicateType = "stella.ops/verdict-delta@v1";
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the source (baseline) artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fromDigest")]
|
||||
public required string FromDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the target (current) artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("toDigest")]
|
||||
public required string ToDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tenantId")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy pack version used for baseline evaluation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fromPolicyVersion")]
|
||||
public required string FromPolicyVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy pack version used for target evaluation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("toPolicyVersion")]
|
||||
public required string ToPolicyVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overall verdict for the baseline artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fromVerdict")]
|
||||
public required VerdictSummary FromVerdict { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overall verdict for the target artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("toVerdict")]
|
||||
public required VerdictSummary ToVerdict { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Individual finding verdicts that changed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("findingChanges")]
|
||||
public ImmutableArray<VerdictFindingChange> FindingChanges { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Rule evaluations that changed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ruleChanges")]
|
||||
public ImmutableArray<VerdictRuleChange> RuleChanges { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Summary of the verdict delta.
|
||||
/// </summary>
|
||||
[JsonPropertyName("summary")]
|
||||
public required VerdictDeltaSummary Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the comparison was performed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("comparedAt")]
|
||||
public required DateTimeOffset ComparedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the delta computation algorithm.
|
||||
/// </summary>
|
||||
[JsonPropertyName("algorithmVersion")]
|
||||
public string AlgorithmVersion { get; init; } = "1.0";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a policy verdict.
|
||||
/// </summary>
|
||||
public sealed record VerdictSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Overall verdict (pass, fail, warn).
|
||||
/// </summary>
|
||||
[JsonPropertyName("outcome")]
|
||||
public required string Outcome { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score (0.0 to 1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Risk score.
|
||||
/// </summary>
|
||||
[JsonPropertyName("riskScore")]
|
||||
public required double RiskScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the verdict attestation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verdictDigest")]
|
||||
public string? VerdictDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Count of passing rules.
|
||||
/// </summary>
|
||||
[JsonPropertyName("passingRules")]
|
||||
public required int PassingRules { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Count of failing rules.
|
||||
/// </summary>
|
||||
[JsonPropertyName("failingRules")]
|
||||
public required int FailingRules { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Count of warning rules.
|
||||
/// </summary>
|
||||
[JsonPropertyName("warningRules")]
|
||||
public required int WarningRules { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A finding verdict change between versions.
|
||||
/// </summary>
|
||||
public sealed record VerdictFindingChange
|
||||
{
|
||||
/// <summary>
|
||||
/// Vulnerability identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component PURL.
|
||||
/// </summary>
|
||||
[JsonPropertyName("purl")]
|
||||
public required string Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous verdict for this finding.
|
||||
/// </summary>
|
||||
[JsonPropertyName("previousVerdict")]
|
||||
public required string PreviousVerdict { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current verdict for this finding.
|
||||
/// </summary>
|
||||
[JsonPropertyName("currentVerdict")]
|
||||
public required string CurrentVerdict { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for the change.
|
||||
/// </summary>
|
||||
[JsonPropertyName("changeReason")]
|
||||
public required string ChangeReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Direction of risk change.
|
||||
/// </summary>
|
||||
[JsonPropertyName("riskDirection")]
|
||||
public required string RiskDirection { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A rule evaluation change between versions.
|
||||
/// </summary>
|
||||
public sealed record VerdictRuleChange
|
||||
{
|
||||
/// <summary>
|
||||
/// Rule identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ruleId")]
|
||||
public required string RuleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ruleName")]
|
||||
public required string RuleName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous rule result (pass, fail, warn, skip).
|
||||
/// </summary>
|
||||
[JsonPropertyName("previousResult")]
|
||||
public required string PreviousResult { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current rule result.
|
||||
/// </summary>
|
||||
[JsonPropertyName("currentResult")]
|
||||
public required string CurrentResult { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous rule message.
|
||||
/// </summary>
|
||||
[JsonPropertyName("previousMessage")]
|
||||
public string? PreviousMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current rule message.
|
||||
/// </summary>
|
||||
[JsonPropertyName("currentMessage")]
|
||||
public string? CurrentMessage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of verdict delta.
|
||||
/// </summary>
|
||||
public sealed record VerdictDeltaSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the overall verdict changed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verdictChanged")]
|
||||
public required bool VerdictChanged { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Direction of overall risk change (increased, decreased, neutral).
|
||||
/// </summary>
|
||||
[JsonPropertyName("riskDirection")]
|
||||
public required string RiskDirection { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Change in risk score.
|
||||
/// </summary>
|
||||
[JsonPropertyName("riskScoreDelta")]
|
||||
public required double RiskScoreDelta { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Change in confidence score.
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidenceDelta")]
|
||||
public required double ConfidenceDelta { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of findings with improved verdicts.
|
||||
/// </summary>
|
||||
[JsonPropertyName("findingsImproved")]
|
||||
public required int FindingsImproved { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of findings with worsened verdicts.
|
||||
/// </summary>
|
||||
[JsonPropertyName("findingsWorsened")]
|
||||
public required int FindingsWorsened { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of new findings.
|
||||
/// </summary>
|
||||
[JsonPropertyName("findingsNew")]
|
||||
public required int FindingsNew { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of resolved findings.
|
||||
/// </summary>
|
||||
[JsonPropertyName("findingsResolved")]
|
||||
public required int FindingsResolved { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of rules that changed result.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rulesChanged")]
|
||||
public required int RulesChanged { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VexDeltaPredicate.cs
|
||||
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii
|
||||
// Task: LIN-BE-024-DELTA-PREDICATES
|
||||
// Description: DSSE predicate for VEX delta attestations between artifact versions.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Predicates;
|
||||
|
||||
/// <summary>
|
||||
/// DSSE predicate for VEX delta attestation between two artifact versions.
|
||||
/// predicateType: stella.ops/vex-delta@v1
|
||||
/// </summary>
|
||||
public sealed record VexDeltaPredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// The predicate type URI for VEX delta attestations.
|
||||
/// </summary>
|
||||
public const string PredicateType = "stella.ops/vex-delta@v1";
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the source (baseline) artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fromDigest")]
|
||||
public required string FromDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the target (current) artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("toDigest")]
|
||||
public required string ToDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tenantId")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX statements added in the target artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("added")]
|
||||
public ImmutableArray<VexDeltaStatement> Added { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// VEX statements removed from the baseline artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("removed")]
|
||||
public ImmutableArray<VexDeltaStatement> Removed { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// VEX statements that changed status between versions.
|
||||
/// </summary>
|
||||
[JsonPropertyName("changed")]
|
||||
public ImmutableArray<VexDeltaChange> Changed { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Summary counts for the delta.
|
||||
/// </summary>
|
||||
[JsonPropertyName("summary")]
|
||||
public required VexDeltaSummary Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the comparison was performed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("comparedAt")]
|
||||
public required DateTimeOffset ComparedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the delta computation algorithm.
|
||||
/// </summary>
|
||||
[JsonPropertyName("algorithmVersion")]
|
||||
public string AlgorithmVersion { get; init; } = "1.0";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A VEX statement included in a delta.
|
||||
/// </summary>
|
||||
public sealed record VexDeltaStatement
|
||||
{
|
||||
/// <summary>
|
||||
/// Vulnerability identifier (CVE, GHSA, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Product identifier affected.
|
||||
/// </summary>
|
||||
[JsonPropertyName("productId")]
|
||||
public required string ProductId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX status (not_affected, affected, fixed, under_investigation).
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Justification for the status.
|
||||
/// </summary>
|
||||
[JsonPropertyName("justification")]
|
||||
public string? Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Issuer of the VEX statement.
|
||||
/// </summary>
|
||||
[JsonPropertyName("issuer")]
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the VEX statement was issued.
|
||||
/// </summary>
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A VEX statement that changed between versions.
|
||||
/// </summary>
|
||||
public sealed record VexDeltaChange
|
||||
{
|
||||
/// <summary>
|
||||
/// Vulnerability identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Product identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("productId")]
|
||||
public required string ProductId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous VEX status.
|
||||
/// </summary>
|
||||
[JsonPropertyName("previousStatus")]
|
||||
public required string PreviousStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current VEX status.
|
||||
/// </summary>
|
||||
[JsonPropertyName("currentStatus")]
|
||||
public required string CurrentStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous justification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("previousJustification")]
|
||||
public string? PreviousJustification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current justification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("currentJustification")]
|
||||
public string? CurrentJustification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Direction of risk change (increased, decreased, neutral).
|
||||
/// </summary>
|
||||
[JsonPropertyName("riskDirection")]
|
||||
public required string RiskDirection { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of VEX delta counts.
|
||||
/// </summary>
|
||||
public sealed record VexDeltaSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of VEX statements added.
|
||||
/// </summary>
|
||||
[JsonPropertyName("addedCount")]
|
||||
public required int AddedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of VEX statements removed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("removedCount")]
|
||||
public required int RemovedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of VEX statements that changed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("changedCount")]
|
||||
public required int ChangedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of VEX statements unchanged.
|
||||
/// </summary>
|
||||
[JsonPropertyName("unchangedCount")]
|
||||
public required int UnchangedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Net risk direction (increased, decreased, neutral).
|
||||
/// </summary>
|
||||
[JsonPropertyName("netRiskDirection")]
|
||||
public required string NetRiskDirection { get; init; }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
@@ -7,7 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -10,9 +10,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="System.Text.Json" Version="10.0.0" />
|
||||
<PackageReference Include="JsonSchema.Net" Version="7.2.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="JsonSchema.Net" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RootNamespace>StellaOps.Attestor.TrustVerdict.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Attestor.TrustVerdict\StellaOps.Attestor.TrustVerdict.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,355 @@
|
||||
// TrustEvidenceMerkleBuilderTests - Unit tests for Merkle tree operations
|
||||
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.TrustVerdict.Evidence;
|
||||
using StellaOps.Attestor.TrustVerdict.Predicates;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.TrustVerdict.Tests;
|
||||
|
||||
public class TrustEvidenceMerkleBuilderTests
|
||||
{
|
||||
private readonly TrustEvidenceMerkleBuilder _builder = new();
|
||||
|
||||
[Fact]
|
||||
public void Build_WithEmptyItems_ReturnsEmptyTreeWithRoot()
|
||||
{
|
||||
// Act
|
||||
var tree = _builder.Build([]);
|
||||
|
||||
// Assert
|
||||
tree.Root.Should().StartWith("sha256:");
|
||||
tree.LeafCount.Should().Be(0);
|
||||
tree.Height.Should().Be(0);
|
||||
tree.NodeCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithSingleItem_ReturnsTreeWithOneLeaf()
|
||||
{
|
||||
// Arrange
|
||||
var items = new[]
|
||||
{
|
||||
CreateEvidenceItem("sha256:item1")
|
||||
};
|
||||
|
||||
// Act
|
||||
var tree = _builder.Build(items);
|
||||
|
||||
// Assert
|
||||
tree.LeafCount.Should().Be(1);
|
||||
tree.Height.Should().Be(0);
|
||||
tree.Root.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithTwoItems_ReturnsCorrectTree()
|
||||
{
|
||||
// Arrange
|
||||
var items = new[]
|
||||
{
|
||||
CreateEvidenceItem("sha256:item1"),
|
||||
CreateEvidenceItem("sha256:item2")
|
||||
};
|
||||
|
||||
// Act
|
||||
var tree = _builder.Build(items);
|
||||
|
||||
// Assert
|
||||
tree.LeafCount.Should().Be(2);
|
||||
tree.Height.Should().Be(1);
|
||||
tree.LeafHashes.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_SortsItemsByDigest()
|
||||
{
|
||||
// Arrange - Items in reverse order
|
||||
var items = new[]
|
||||
{
|
||||
CreateEvidenceItem("sha256:zzz"),
|
||||
CreateEvidenceItem("sha256:aaa"),
|
||||
CreateEvidenceItem("sha256:mmm")
|
||||
};
|
||||
|
||||
// Act
|
||||
var tree = _builder.Build(items);
|
||||
|
||||
// Assert
|
||||
tree.LeafCount.Should().Be(3);
|
||||
// First leaf should correspond to "sha256:aaa"
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var items = new[]
|
||||
{
|
||||
CreateEvidenceItem("sha256:item1"),
|
||||
CreateEvidenceItem("sha256:item2"),
|
||||
CreateEvidenceItem("sha256:item3")
|
||||
};
|
||||
|
||||
// Act
|
||||
var tree1 = _builder.Build(items);
|
||||
var tree2 = _builder.Build(items);
|
||||
|
||||
// Assert
|
||||
tree1.Root.Should().Be(tree2.Root);
|
||||
tree1.LeafHashes.Should().BeEquivalentTo(tree2.LeafHashes, opts => opts.WithStrictOrdering());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_DifferentOrderSameRoot()
|
||||
{
|
||||
// Arrange
|
||||
var items1 = new[]
|
||||
{
|
||||
CreateEvidenceItem("sha256:aaa"),
|
||||
CreateEvidenceItem("sha256:bbb")
|
||||
};
|
||||
|
||||
var items2 = new[]
|
||||
{
|
||||
CreateEvidenceItem("sha256:bbb"),
|
||||
CreateEvidenceItem("sha256:aaa")
|
||||
};
|
||||
|
||||
// Act
|
||||
var tree1 = _builder.Build(items1);
|
||||
var tree2 = _builder.Build(items2);
|
||||
|
||||
// Assert - Same root because items are sorted by digest
|
||||
tree1.Root.Should().Be(tree2.Root);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateProof_ForValidIndex_ReturnsProof()
|
||||
{
|
||||
// Arrange
|
||||
var items = new[]
|
||||
{
|
||||
CreateEvidenceItem("sha256:aaa"),
|
||||
CreateEvidenceItem("sha256:bbb"),
|
||||
CreateEvidenceItem("sha256:ccc"),
|
||||
CreateEvidenceItem("sha256:ddd")
|
||||
};
|
||||
var tree = _builder.Build(items);
|
||||
|
||||
// Act
|
||||
var proof = tree.GenerateProof(0);
|
||||
|
||||
// Assert
|
||||
proof.LeafIndex.Should().Be(0);
|
||||
proof.Root.Should().Be(tree.Root);
|
||||
proof.Siblings.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateProof_ForInvalidIndex_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var items = new[] { CreateEvidenceItem("sha256:item1") };
|
||||
var tree = _builder.Build(items);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => tree.GenerateProof(-1));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => tree.GenerateProof(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyProof_WithValidProof_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var items = new[]
|
||||
{
|
||||
CreateEvidenceItem("sha256:aaa"),
|
||||
CreateEvidenceItem("sha256:bbb"),
|
||||
CreateEvidenceItem("sha256:ccc"),
|
||||
CreateEvidenceItem("sha256:ddd")
|
||||
};
|
||||
var tree = _builder.Build(items);
|
||||
var proof = tree.GenerateProof(1);
|
||||
|
||||
// Get the item at sorted index 1 (should be "sha256:bbb")
|
||||
var sortedItems = items.OrderBy(i => i.Digest).ToList();
|
||||
var item = sortedItems[1];
|
||||
|
||||
// Act
|
||||
var valid = _builder.VerifyProof(item, proof, tree.Root);
|
||||
|
||||
// Assert
|
||||
valid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyProof_WithWrongItem_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var items = new[]
|
||||
{
|
||||
CreateEvidenceItem("sha256:aaa"),
|
||||
CreateEvidenceItem("sha256:bbb")
|
||||
};
|
||||
var tree = _builder.Build(items);
|
||||
var proof = tree.GenerateProof(0);
|
||||
|
||||
var wrongItem = CreateEvidenceItem("sha256:wrong");
|
||||
|
||||
// Act
|
||||
var valid = _builder.VerifyProof(wrongItem, proof, tree.Root);
|
||||
|
||||
// Assert
|
||||
valid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyProof_WithWrongRoot_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var items = new[] { CreateEvidenceItem("sha256:aaa") };
|
||||
var tree = _builder.Build(items);
|
||||
var proof = tree.GenerateProof(0);
|
||||
|
||||
// Act
|
||||
var valid = _builder.VerifyProof(items[0], proof, "sha256:wrongroot");
|
||||
|
||||
// Assert
|
||||
valid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeLeafHash_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var item = CreateEvidenceItem("sha256:test", "vex-doc", "https://example.com");
|
||||
|
||||
// Act
|
||||
var hash1 = _builder.ComputeLeafHash(item);
|
||||
var hash2 = _builder.ComputeLeafHash(item);
|
||||
|
||||
// Assert
|
||||
hash1.Should().BeEquivalentTo(hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeLeafHash_DifferentItemsProduceDifferentHashes()
|
||||
{
|
||||
// Arrange
|
||||
var item1 = CreateEvidenceItem("sha256:item1");
|
||||
var item2 = CreateEvidenceItem("sha256:item2");
|
||||
|
||||
// Act
|
||||
var hash1 = _builder.ComputeLeafHash(item1);
|
||||
var hash2 = _builder.ComputeLeafHash(item2);
|
||||
|
||||
// Assert
|
||||
hash1.Should().NotBeEquivalentTo(hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateChain_WithMatchingRoot_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var items = new[]
|
||||
{
|
||||
CreateEvidenceItem("sha256:aaa"),
|
||||
CreateEvidenceItem("sha256:bbb")
|
||||
};
|
||||
var tree = _builder.Build(items);
|
||||
var chain = tree.ToEvidenceChain(items.OrderBy(i => i.Digest).ToList());
|
||||
|
||||
// Act
|
||||
var valid = _builder.ValidateChain(chain);
|
||||
|
||||
// Assert
|
||||
valid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateChain_WithMismatchedRoot_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var items = new[] { CreateEvidenceItem("sha256:aaa") };
|
||||
var chain = new TrustEvidenceChain
|
||||
{
|
||||
MerkleRoot = "sha256:wrongroot",
|
||||
Items = items.ToList()
|
||||
};
|
||||
|
||||
// Act
|
||||
var valid = _builder.ValidateChain(chain);
|
||||
|
||||
// Assert
|
||||
valid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
[InlineData(7)]
|
||||
[InlineData(8)]
|
||||
[InlineData(15)]
|
||||
[InlineData(16)]
|
||||
public void Build_WithVariousItemCounts_ProducesValidTree(int count)
|
||||
{
|
||||
// Arrange
|
||||
var items = Enumerable.Range(1, count)
|
||||
.Select(i => CreateEvidenceItem($"sha256:{i:D8}"))
|
||||
.ToArray();
|
||||
|
||||
// Act
|
||||
var tree = _builder.Build(items);
|
||||
|
||||
// Assert
|
||||
tree.LeafCount.Should().Be(count);
|
||||
tree.Root.Should().StartWith("sha256:");
|
||||
tree.NodeCount.Should().BeGreaterThan(0);
|
||||
|
||||
// Verify all proofs work
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var proof = tree.GenerateProof(i);
|
||||
var sortedItems = items.OrderBy(x => x.Digest).ToList();
|
||||
var valid = _builder.VerifyProof(sortedItems[i], proof, tree.Root);
|
||||
valid.Should().BeTrue($"proof for index {i} should be valid");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToEvidenceChain_PreservesItems()
|
||||
{
|
||||
// Arrange
|
||||
var items = new[]
|
||||
{
|
||||
CreateEvidenceItem("sha256:aaa"),
|
||||
CreateEvidenceItem("sha256:bbb")
|
||||
};
|
||||
var tree = _builder.Build(items);
|
||||
|
||||
// Act
|
||||
var chain = tree.ToEvidenceChain(items.ToList());
|
||||
|
||||
// Assert
|
||||
chain.MerkleRoot.Should().Be(tree.Root);
|
||||
chain.Items.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
private static TrustEvidenceItem CreateEvidenceItem(
|
||||
string digest,
|
||||
string type = TrustEvidenceTypes.VexDocument,
|
||||
string? uri = null)
|
||||
{
|
||||
return new TrustEvidenceItem
|
||||
{
|
||||
Type = type,
|
||||
Digest = digest,
|
||||
Uri = uri,
|
||||
CollectedAt = new DateTimeOffset(2025, 1, 15, 12, 0, 0, TimeSpan.Zero)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
// TrustVerdictCacheTests - Unit tests for verdict caching
|
||||
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Attestor.TrustVerdict.Caching;
|
||||
using StellaOps.Attestor.TrustVerdict.Predicates;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.TrustVerdict.Tests;
|
||||
|
||||
public class TrustVerdictCacheTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly IOptionsMonitor<TrustVerdictCacheOptions> _options;
|
||||
private readonly InMemoryTrustVerdictCache _cache;
|
||||
|
||||
public TrustVerdictCacheTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 12, 0, 0, TimeSpan.Zero));
|
||||
_options = CreateOptions(new TrustVerdictCacheOptions
|
||||
{
|
||||
MaxEntries = 100,
|
||||
DefaultTtl = TimeSpan.FromHours(1)
|
||||
});
|
||||
_cache = new InMemoryTrustVerdictCache(_options, _timeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAndGet_ReturnsStoredEntry()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateCacheEntry("sha256:verdict1", "sha256:vex1", "tenant1");
|
||||
|
||||
// Act
|
||||
await _cache.SetAsync(entry);
|
||||
var result = await _cache.GetAsync("sha256:verdict1");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.VerdictDigest.Should().Be("sha256:verdict1");
|
||||
result.VexDigest.Should().Be("sha256:vex1");
|
||||
result.TenantId.Should().Be("tenant1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_NonexistentKey_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = await _cache.GetAsync("sha256:nonexistent");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByVexDigest_ReturnsMatchingEntry()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateCacheEntry("sha256:verdict1", "sha256:vex1", "tenant1");
|
||||
await _cache.SetAsync(entry);
|
||||
|
||||
// Act
|
||||
var result = await _cache.GetByVexDigestAsync("sha256:vex1", "tenant1");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.VerdictDigest.Should().Be("sha256:verdict1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByVexDigest_WrongTenant_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateCacheEntry("sha256:verdict1", "sha256:vex1", "tenant1");
|
||||
await _cache.SetAsync(entry);
|
||||
|
||||
// Act
|
||||
var result = await _cache.GetByVexDigestAsync("sha256:vex1", "tenant2");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_ExpiredEntry_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateCacheEntry("sha256:verdict1", "sha256:vex1", "tenant1",
|
||||
expiresAt: _timeProvider.GetUtcNow().AddMinutes(30));
|
||||
await _cache.SetAsync(entry);
|
||||
|
||||
// Advance time past expiration
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(31));
|
||||
|
||||
// Act
|
||||
var result = await _cache.GetAsync("sha256:verdict1");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invalidate_RemovesEntry()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateCacheEntry("sha256:verdict1", "sha256:vex1", "tenant1");
|
||||
await _cache.SetAsync(entry);
|
||||
|
||||
// Act
|
||||
await _cache.InvalidateAsync("sha256:verdict1");
|
||||
var result = await _cache.GetAsync("sha256:verdict1");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidateByVexDigest_RemovesEntry()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateCacheEntry("sha256:verdict1", "sha256:vex1", "tenant1");
|
||||
await _cache.SetAsync(entry);
|
||||
|
||||
// Act
|
||||
await _cache.InvalidateByVexDigestAsync("sha256:vex1", "tenant1");
|
||||
var result = await _cache.GetByVexDigestAsync("sha256:vex1", "tenant1");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBatch_ReturnsAllCachedEntries()
|
||||
{
|
||||
// Arrange
|
||||
await _cache.SetAsync(CreateCacheEntry("sha256:v1", "sha256:vex1", "tenant1"));
|
||||
await _cache.SetAsync(CreateCacheEntry("sha256:v2", "sha256:vex2", "tenant1"));
|
||||
await _cache.SetAsync(CreateCacheEntry("sha256:v3", "sha256:vex3", "tenant1"));
|
||||
|
||||
// Act
|
||||
var results = await _cache.GetBatchAsync(
|
||||
["sha256:vex1", "sha256:vex2", "sha256:vex4"],
|
||||
"tenant1");
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(2);
|
||||
results.Should().ContainKey("sha256:vex1");
|
||||
results.Should().ContainKey("sha256:vex2");
|
||||
results.Should().NotContainKey("sha256:vex4");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Set_EvictsOldestWhenFull()
|
||||
{
|
||||
// Arrange - Options with max 3 entries
|
||||
var options = CreateOptions(new TrustVerdictCacheOptions { MaxEntries = 3 });
|
||||
var cache = new InMemoryTrustVerdictCache(options, _timeProvider);
|
||||
|
||||
await cache.SetAsync(CreateCacheEntry("sha256:v1", "sha256:vex1", "tenant1",
|
||||
cachedAt: _timeProvider.GetUtcNow()));
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await cache.SetAsync(CreateCacheEntry("sha256:v2", "sha256:vex2", "tenant1",
|
||||
cachedAt: _timeProvider.GetUtcNow()));
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await cache.SetAsync(CreateCacheEntry("sha256:v3", "sha256:vex3", "tenant1",
|
||||
cachedAt: _timeProvider.GetUtcNow()));
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
|
||||
// Act - Add 4th entry, should evict oldest (v1)
|
||||
await cache.SetAsync(CreateCacheEntry("sha256:v4", "sha256:vex4", "tenant1",
|
||||
cachedAt: _timeProvider.GetUtcNow()));
|
||||
|
||||
// Assert
|
||||
var result1 = await cache.GetAsync("sha256:v1");
|
||||
var result4 = await cache.GetAsync("sha256:v4");
|
||||
|
||||
result1.Should().BeNull("oldest entry should be evicted");
|
||||
result4.Should().NotBeNull("new entry should be cached");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStats_ReturnsAccurateStats()
|
||||
{
|
||||
// Arrange
|
||||
await _cache.SetAsync(CreateCacheEntry("sha256:v1", "sha256:vex1", "tenant1"));
|
||||
await _cache.SetAsync(CreateCacheEntry("sha256:v2", "sha256:vex2", "tenant1"));
|
||||
|
||||
// Generate hits and misses
|
||||
await _cache.GetAsync("sha256:v1"); // hit
|
||||
await _cache.GetAsync("sha256:v1"); // hit
|
||||
await _cache.GetAsync("sha256:missing"); // miss
|
||||
|
||||
// Act
|
||||
var stats = await _cache.GetStatsAsync();
|
||||
|
||||
// Assert
|
||||
stats.TotalEntries.Should().Be(2);
|
||||
stats.TotalHits.Should().Be(2);
|
||||
stats.TotalMisses.Should().Be(1);
|
||||
stats.HitRatio.Should().BeApproximately(0.666, 0.01);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Set_UpdatesExistingEntry()
|
||||
{
|
||||
// Arrange
|
||||
var entry1 = CreateCacheEntry("sha256:verdict1", "sha256:vex1", "tenant1");
|
||||
await _cache.SetAsync(entry1);
|
||||
|
||||
// Create updated entry with same key
|
||||
var entry2 = entry1 with
|
||||
{
|
||||
Predicate = entry1.Predicate with
|
||||
{
|
||||
Composite = entry1.Predicate.Composite with { Score = 0.99m }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
await _cache.SetAsync(entry2);
|
||||
var result = await _cache.GetAsync("sha256:verdict1");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Predicate.Composite.Score.Should().Be(0.99m);
|
||||
}
|
||||
|
||||
private TrustVerdictCacheEntry CreateCacheEntry(
|
||||
string verdictDigest,
|
||||
string vexDigest,
|
||||
string tenantId,
|
||||
DateTimeOffset? cachedAt = null,
|
||||
DateTimeOffset? expiresAt = null)
|
||||
{
|
||||
var now = cachedAt ?? _timeProvider.GetUtcNow();
|
||||
return new TrustVerdictCacheEntry
|
||||
{
|
||||
VerdictDigest = verdictDigest,
|
||||
VexDigest = vexDigest,
|
||||
TenantId = tenantId,
|
||||
CachedAt = now,
|
||||
ExpiresAt = expiresAt ?? now.AddHours(1),
|
||||
Predicate = new TrustVerdictPredicate
|
||||
{
|
||||
SchemaVersion = "1.0.0",
|
||||
Subject = new TrustVerdictSubject
|
||||
{
|
||||
VexDigest = vexDigest,
|
||||
VexFormat = "openvex",
|
||||
ProviderId = "test-provider",
|
||||
StatementId = "stmt-1",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
ProductKey = "pkg:npm/test@1.0.0"
|
||||
},
|
||||
Origin = new OriginVerification { Valid = true, Method = "dsse", Score = 1.0m },
|
||||
Freshness = new FreshnessEvaluation
|
||||
{
|
||||
Status = "fresh",
|
||||
IssuedAt = now,
|
||||
AgeInDays = 0,
|
||||
Score = 1.0m
|
||||
},
|
||||
Reputation = new ReputationScore
|
||||
{
|
||||
Composite = 0.8m,
|
||||
Authority = 0.8m, Accuracy = 0.8m, Timeliness = 0.8m,
|
||||
Coverage = 0.8m, Verification = 0.8m,
|
||||
ComputedAt = now, SampleCount = 100
|
||||
},
|
||||
Composite = new TrustComposite
|
||||
{
|
||||
Score = 0.9m,
|
||||
Tier = "high",
|
||||
Reasons = ["Test reason"],
|
||||
Formula = "test"
|
||||
},
|
||||
Evidence = new TrustEvidenceChain { MerkleRoot = "sha256:root", Items = [] },
|
||||
Metadata = new TrustEvaluationMetadata
|
||||
{
|
||||
EvaluatedAt = now,
|
||||
EvaluatorVersion = "1.0.0",
|
||||
CryptoProfile = "world",
|
||||
TenantId = tenantId
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static IOptionsMonitor<TrustVerdictCacheOptions> CreateOptions(TrustVerdictCacheOptions options)
|
||||
{
|
||||
var monitor = new Moq.Mock<IOptionsMonitor<TrustVerdictCacheOptions>>();
|
||||
monitor.Setup(m => m.CurrentValue).Returns(options);
|
||||
return monitor.Object;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
// TrustVerdictServiceTests - Unit tests for TrustVerdictService
|
||||
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Attestor.TrustVerdict.Predicates;
|
||||
using StellaOps.Attestor.TrustVerdict.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.TrustVerdict.Tests;
|
||||
|
||||
public class TrustVerdictServiceTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly IOptionsMonitor<TrustVerdictServiceOptions> _options;
|
||||
private readonly TrustVerdictService _service;
|
||||
|
||||
public TrustVerdictServiceTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 12, 0, 0, TimeSpan.Zero));
|
||||
_options = CreateOptions(new TrustVerdictServiceOptions { EvaluatorVersion = "1.0.0-test" });
|
||||
_service = new TrustVerdictService(_options, NullLogger<TrustVerdictService>.Instance, _timeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateVerdictAsync_WithValidInput_ReturnsSuccessResult()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidRequest();
|
||||
|
||||
// Act
|
||||
var result = await _service.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Predicate.Should().NotBeNull();
|
||||
result.VerdictDigest.Should().StartWith("sha256:");
|
||||
result.ErrorMessage.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateVerdictAsync_SetsCorrectPredicateType()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidRequest();
|
||||
|
||||
// Act
|
||||
var result = await _service.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
TrustVerdictPredicate.PredicateType.Should().Be("https://stellaops.dev/predicates/trust-verdict@v1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateVerdictAsync_CopiesSubjectFields()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidRequest();
|
||||
|
||||
// Act
|
||||
var result = await _service.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
var subject = result.Predicate!.Subject;
|
||||
subject.VexDigest.Should().Be(request.VexDigest);
|
||||
subject.VexFormat.Should().Be(request.VexFormat);
|
||||
subject.ProviderId.Should().Be(request.ProviderId);
|
||||
subject.StatementId.Should().Be(request.StatementId);
|
||||
subject.VulnerabilityId.Should().Be(request.VulnerabilityId);
|
||||
subject.ProductKey.Should().Be(request.ProductKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateVerdictAsync_WithVerifiedSignature_SetsOriginScoreToOne()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidRequest() with
|
||||
{
|
||||
Origin = new TrustVerdictOriginInput
|
||||
{
|
||||
Valid = true,
|
||||
Method = VerificationMethods.Dsse,
|
||||
KeyId = "key-123"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Predicate!.Origin.Score.Should().Be(1.0m);
|
||||
result.Predicate.Origin.Valid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateVerdictAsync_WithUnverifiedSignature_SetsOriginScoreToZero()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidRequest() with
|
||||
{
|
||||
Origin = new TrustVerdictOriginInput
|
||||
{
|
||||
Valid = false,
|
||||
Method = VerificationMethods.Dsse,
|
||||
FailureReason = "Invalid signature"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Predicate!.Origin.Score.Should().Be(0.0m);
|
||||
result.Predicate.Origin.Valid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(FreshnessStatuses.Fresh, 0, 1.0)]
|
||||
[InlineData(FreshnessStatuses.Stale, 0, 0.6)]
|
||||
[InlineData(FreshnessStatuses.Superseded, 0, 0.3)]
|
||||
[InlineData(FreshnessStatuses.Expired, 0, 0.1)]
|
||||
public async Task GenerateVerdictAsync_ComputesFreshnessScoreCorrectly(
|
||||
string status,
|
||||
int ageInDays,
|
||||
double expectedBaseScore)
|
||||
{
|
||||
// Arrange
|
||||
var issuedAt = _timeProvider.GetUtcNow().AddDays(-ageInDays);
|
||||
var request = CreateValidRequest() with
|
||||
{
|
||||
Freshness = new TrustVerdictFreshnessInput
|
||||
{
|
||||
Status = status,
|
||||
IssuedAt = issuedAt
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Predicate!.Freshness.Status.Should().Be(status);
|
||||
result.Predicate.Freshness.Score.Should().BeApproximately((decimal)expectedBaseScore, 0.001m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateVerdictAsync_AppliesAgeDecayToFreshnessScore()
|
||||
{
|
||||
// Arrange - 90 days old should decay to ~50% of base score
|
||||
var issuedAt = _timeProvider.GetUtcNow().AddDays(-90);
|
||||
var request = CreateValidRequest() with
|
||||
{
|
||||
Freshness = new TrustVerdictFreshnessInput
|
||||
{
|
||||
Status = FreshnessStatuses.Fresh,
|
||||
IssuedAt = issuedAt
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
// Fresh base score (1.0) * decay(90 days, 90-day half-life) ≈ 0.368
|
||||
result.Predicate!.Freshness.Score.Should().BeLessThan(0.5m);
|
||||
result.Predicate.Freshness.AgeInDays.Should().Be(90);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateVerdictAsync_ComputesReputationComposite()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidRequest() with
|
||||
{
|
||||
Reputation = new TrustVerdictReputationInput
|
||||
{
|
||||
Authority = 0.9m,
|
||||
Accuracy = 0.85m,
|
||||
Timeliness = 0.8m,
|
||||
Coverage = 0.75m,
|
||||
Verification = 0.7m,
|
||||
ComputedAt = _timeProvider.GetUtcNow(),
|
||||
SampleCount = 100
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
// Weighted: 0.25*0.9 + 0.30*0.85 + 0.15*0.8 + 0.15*0.75 + 0.15*0.7
|
||||
// = 0.225 + 0.255 + 0.12 + 0.1125 + 0.105 = 0.8175
|
||||
result.Predicate!.Reputation.Composite.Should().BeApproximately(0.818m, 0.001m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateVerdictAsync_ComputesCompositeScore()
|
||||
{
|
||||
// Arrange - All factors at max
|
||||
var request = CreateValidRequest() with
|
||||
{
|
||||
Origin = new TrustVerdictOriginInput { Valid = true, Method = VerificationMethods.Dsse },
|
||||
Freshness = new TrustVerdictFreshnessInput
|
||||
{
|
||||
Status = FreshnessStatuses.Fresh,
|
||||
IssuedAt = _timeProvider.GetUtcNow()
|
||||
},
|
||||
Reputation = new TrustVerdictReputationInput
|
||||
{
|
||||
Authority = 1.0m,
|
||||
Accuracy = 1.0m,
|
||||
Timeliness = 1.0m,
|
||||
Coverage = 1.0m,
|
||||
Verification = 1.0m,
|
||||
ComputedAt = _timeProvider.GetUtcNow(),
|
||||
SampleCount = 100
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
// Formula: 0.50*Origin + 0.30*Freshness + 0.20*Reputation
|
||||
// = 0.50*1.0 + 0.30*1.0 + 0.20*1.0 = 1.0
|
||||
result.Predicate!.Composite.Score.Should().Be(1.0m);
|
||||
result.Predicate.Composite.Tier.Should().Be(TrustTiers.VeryHigh);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.95, TrustTiers.VeryHigh)]
|
||||
[InlineData(0.85, TrustTiers.High)]
|
||||
[InlineData(0.65, TrustTiers.Medium)]
|
||||
[InlineData(0.45, TrustTiers.Low)]
|
||||
[InlineData(0.15, TrustTiers.VeryLow)]
|
||||
public void TrustTiers_FromScore_ReturnsCorrectTier(double score, string expectedTier)
|
||||
{
|
||||
// Act
|
||||
var tier = TrustTiers.FromScore((decimal)score);
|
||||
|
||||
// Assert
|
||||
tier.Should().Be(expectedTier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateVerdictAsync_SetsMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidRequest() with
|
||||
{
|
||||
Options = new TrustVerdictOptions
|
||||
{
|
||||
TenantId = "tenant-123",
|
||||
CryptoProfile = "fips",
|
||||
Environment = "production",
|
||||
PolicyDigest = "sha256:abc123",
|
||||
CorrelationId = "corr-456"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
var metadata = result.Predicate!.Metadata;
|
||||
metadata.TenantId.Should().Be("tenant-123");
|
||||
metadata.CryptoProfile.Should().Be("fips");
|
||||
metadata.Environment.Should().Be("production");
|
||||
metadata.PolicyDigest.Should().Be("sha256:abc123");
|
||||
metadata.CorrelationId.Should().Be("corr-456");
|
||||
metadata.EvaluatorVersion.Should().Be("1.0.0-test");
|
||||
metadata.EvaluatedAt.Should().Be(_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateVerdictAsync_BuildsEvidenceChain()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidRequest() with
|
||||
{
|
||||
EvidenceItems =
|
||||
[
|
||||
new TrustVerdictEvidenceInput
|
||||
{
|
||||
Type = TrustEvidenceTypes.VexDocument,
|
||||
Digest = "sha256:vex123",
|
||||
Uri = "https://example.com/vex/123"
|
||||
},
|
||||
new TrustVerdictEvidenceInput
|
||||
{
|
||||
Type = TrustEvidenceTypes.Signature,
|
||||
Digest = "sha256:sig456",
|
||||
Description = "DSSE signature bundle"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Predicate!.Evidence.Items.Should().HaveCount(2);
|
||||
result.Predicate.Evidence.MerkleRoot.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateVerdictAsync_EvidenceIsSortedByDigest()
|
||||
{
|
||||
// Arrange - Items in reverse digest order
|
||||
var request = CreateValidRequest() with
|
||||
{
|
||||
EvidenceItems =
|
||||
[
|
||||
new TrustVerdictEvidenceInput { Type = "type1", Digest = "sha256:zzz" },
|
||||
new TrustVerdictEvidenceInput { Type = "type2", Digest = "sha256:aaa" },
|
||||
new TrustVerdictEvidenceInput { Type = "type3", Digest = "sha256:mmm" }
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
var digests = result.Predicate!.Evidence.Items.Select(i => i.Digest).ToList();
|
||||
digests.Should().BeInAscendingOrder();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateVerdictAsync_ChecksPolicyThreshold()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidRequest() with
|
||||
{
|
||||
Origin = new TrustVerdictOriginInput { Valid = true, Method = VerificationMethods.Dsse },
|
||||
Freshness = new TrustVerdictFreshnessInput
|
||||
{
|
||||
Status = FreshnessStatuses.Fresh,
|
||||
IssuedAt = _timeProvider.GetUtcNow()
|
||||
},
|
||||
Reputation = new TrustVerdictReputationInput
|
||||
{
|
||||
Authority = 0.8m, Accuracy = 0.8m, Timeliness = 0.8m,
|
||||
Coverage = 0.8m, Verification = 0.8m,
|
||||
ComputedAt = _timeProvider.GetUtcNow(), SampleCount = 50
|
||||
},
|
||||
Options = new TrustVerdictOptions
|
||||
{
|
||||
TenantId = "test",
|
||||
CryptoProfile = "world",
|
||||
PolicyThreshold = 0.7m
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Predicate!.Composite.MeetsPolicyThreshold.Should().BeTrue();
|
||||
result.Predicate.Composite.PolicyThreshold.Should().Be(0.7m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateBatchAsync_ProcessesMultipleRequests()
|
||||
{
|
||||
// Arrange
|
||||
var requests = Enumerable.Range(1, 5)
|
||||
.Select(i => CreateValidRequest() with
|
||||
{
|
||||
VexDigest = $"sha256:vex{i}",
|
||||
StatementId = $"stmt-{i}"
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Act
|
||||
var results = await _service.GenerateBatchAsync(requests);
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(5);
|
||||
results.Should().OnlyContain(r => r.Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeVerdictDigest_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var predicate = new TrustVerdictPredicate
|
||||
{
|
||||
SchemaVersion = "1.0.0",
|
||||
Subject = new TrustVerdictSubject
|
||||
{
|
||||
VexDigest = "sha256:test",
|
||||
VexFormat = "openvex",
|
||||
ProviderId = "provider-1",
|
||||
StatementId = "stmt-1",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
ProductKey = "pkg:npm/example@1.0.0"
|
||||
},
|
||||
Origin = new OriginVerification { Valid = true, Method = "dsse", Score = 1.0m },
|
||||
Freshness = new FreshnessEvaluation
|
||||
{
|
||||
Status = "fresh",
|
||||
IssuedAt = new DateTimeOffset(2025, 1, 15, 0, 0, 0, TimeSpan.Zero),
|
||||
AgeInDays = 0,
|
||||
Score = 1.0m
|
||||
},
|
||||
Reputation = new ReputationScore
|
||||
{
|
||||
Composite = 0.8m,
|
||||
Authority = 0.8m, Accuracy = 0.8m, Timeliness = 0.8m,
|
||||
Coverage = 0.8m, Verification = 0.8m,
|
||||
ComputedAt = new DateTimeOffset(2025, 1, 15, 0, 0, 0, TimeSpan.Zero),
|
||||
SampleCount = 100
|
||||
},
|
||||
Composite = new TrustComposite
|
||||
{
|
||||
Score = 0.9m,
|
||||
Tier = "high",
|
||||
Reasons = ["Verified signature"],
|
||||
Formula = "test"
|
||||
},
|
||||
Evidence = new TrustEvidenceChain { MerkleRoot = "sha256:root", Items = [] },
|
||||
Metadata = new TrustEvaluationMetadata
|
||||
{
|
||||
EvaluatedAt = new DateTimeOffset(2025, 1, 15, 0, 0, 0, TimeSpan.Zero),
|
||||
EvaluatorVersion = "1.0.0",
|
||||
CryptoProfile = "world",
|
||||
TenantId = "tenant-1"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var digest1 = _service.ComputeVerdictDigest(predicate);
|
||||
var digest2 = _service.ComputeVerdictDigest(predicate);
|
||||
|
||||
// Assert
|
||||
digest1.Should().Be(digest2);
|
||||
digest1.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
private TrustVerdictRequest CreateValidRequest() => new()
|
||||
{
|
||||
VexDigest = "sha256:abc123def456",
|
||||
VexFormat = "openvex",
|
||||
ProviderId = "github-security-advisories",
|
||||
StatementId = "stmt-2024-001",
|
||||
VulnerabilityId = "CVE-2024-12345",
|
||||
ProductKey = "pkg:npm/example@1.0.0",
|
||||
VexStatus = "not_affected",
|
||||
Origin = new TrustVerdictOriginInput
|
||||
{
|
||||
Valid = true,
|
||||
Method = VerificationMethods.Dsse,
|
||||
KeyId = "key-123",
|
||||
IssuerName = "GitHub Security"
|
||||
},
|
||||
Freshness = new TrustVerdictFreshnessInput
|
||||
{
|
||||
Status = FreshnessStatuses.Fresh,
|
||||
IssuedAt = _timeProvider.GetUtcNow()
|
||||
},
|
||||
Reputation = new TrustVerdictReputationInput
|
||||
{
|
||||
Authority = 0.9m,
|
||||
Accuracy = 0.85m,
|
||||
Timeliness = 0.8m,
|
||||
Coverage = 0.75m,
|
||||
Verification = 0.8m,
|
||||
ComputedAt = _timeProvider.GetUtcNow(),
|
||||
SampleCount = 500
|
||||
},
|
||||
EvidenceItems = [],
|
||||
Options = new TrustVerdictOptions
|
||||
{
|
||||
TenantId = "test-tenant",
|
||||
CryptoProfile = "world"
|
||||
}
|
||||
};
|
||||
|
||||
private static IOptionsMonitor<TrustVerdictServiceOptions> CreateOptions(TrustVerdictServiceOptions options)
|
||||
{
|
||||
var monitor = new Moq.Mock<IOptionsMonitor<TrustVerdictServiceOptions>>();
|
||||
monitor.Setup(m => m.CurrentValue).Returns(options);
|
||||
return monitor.Object;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
// TrustVerdictCache - Valkey-backed cache for TrustVerdict lookups
|
||||
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.TrustVerdict.Predicates;
|
||||
|
||||
namespace StellaOps.Attestor.TrustVerdict.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Cache for TrustVerdict predicates, enabling fast lookups by digest.
|
||||
/// </summary>
|
||||
public interface ITrustVerdictCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Get a cached verdict by its digest.
|
||||
/// </summary>
|
||||
/// <param name="verdictDigest">Deterministic verdict digest.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Cached verdict or null if not found.</returns>
|
||||
Task<TrustVerdictCacheEntry?> GetAsync(string verdictDigest, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get a verdict by VEX digest (content-addressed lookup).
|
||||
/// </summary>
|
||||
/// <param name="vexDigest">VEX document digest.</param>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Cached verdict or null if not found.</returns>
|
||||
Task<TrustVerdictCacheEntry?> GetByVexDigestAsync(
|
||||
string vexDigest,
|
||||
string tenantId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Store a verdict in cache.
|
||||
/// </summary>
|
||||
/// <param name="entry">The cache entry to store.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task SetAsync(TrustVerdictCacheEntry entry, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Invalidate a cached verdict.
|
||||
/// </summary>
|
||||
/// <param name="verdictDigest">Verdict digest to invalidate.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task InvalidateAsync(string verdictDigest, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Invalidate all verdicts for a VEX document.
|
||||
/// </summary>
|
||||
/// <param name="vexDigest">VEX document digest.</param>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task InvalidateByVexDigestAsync(string vexDigest, string tenantId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Batch get verdicts by VEX digests.
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<string, TrustVerdictCacheEntry>> GetBatchAsync(
|
||||
IEnumerable<string> vexDigests,
|
||||
string tenantId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get cache statistics.
|
||||
/// </summary>
|
||||
Task<TrustVerdictCacheStats> GetStatsAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A cached TrustVerdict entry.
|
||||
/// </summary>
|
||||
public sealed record TrustVerdictCacheEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Deterministic verdict digest.
|
||||
/// </summary>
|
||||
public required string VerdictDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX document digest.
|
||||
/// </summary>
|
||||
public required string VexDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The cached predicate.
|
||||
/// </summary>
|
||||
public required TrustVerdictPredicate Predicate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signed envelope if available (base64).
|
||||
/// </summary>
|
||||
public string? EnvelopeBase64 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the entry was cached.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CachedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the entry expires.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hit count for analytics.
|
||||
/// </summary>
|
||||
public int HitCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cache statistics.
|
||||
/// </summary>
|
||||
public sealed record TrustVerdictCacheStats
|
||||
{
|
||||
public long TotalEntries { get; init; }
|
||||
public long TotalHits { get; init; }
|
||||
public long TotalMisses { get; init; }
|
||||
public long TotalEvictions { get; init; }
|
||||
public double HitRatio => TotalHits + TotalMisses > 0
|
||||
? (double)TotalHits / (TotalHits + TotalMisses)
|
||||
: 0;
|
||||
public long MemoryUsedBytes { get; init; }
|
||||
public DateTimeOffset CollectedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of ITrustVerdictCache for development/testing.
|
||||
/// Production should use ValkeyTrustVerdictCache.
|
||||
/// </summary>
|
||||
public sealed class InMemoryTrustVerdictCache : ITrustVerdictCache
|
||||
{
|
||||
private readonly Dictionary<string, TrustVerdictCacheEntry> _byVerdictDigest = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, string> _vexToVerdictIndex = new(StringComparer.Ordinal);
|
||||
private readonly object _lock = new();
|
||||
private readonly IOptionsMonitor<TrustVerdictCacheOptions> _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private long _hitCount;
|
||||
private long _missCount;
|
||||
private long _evictionCount;
|
||||
|
||||
public InMemoryTrustVerdictCache(
|
||||
IOptionsMonitor<TrustVerdictCacheOptions> options,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<TrustVerdictCacheEntry?> GetAsync(string verdictDigest, CancellationToken ct = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_byVerdictDigest.TryGetValue(verdictDigest, out var entry))
|
||||
{
|
||||
if (_timeProvider.GetUtcNow() < entry.ExpiresAt)
|
||||
{
|
||||
Interlocked.Increment(ref _hitCount);
|
||||
return Task.FromResult<TrustVerdictCacheEntry?>(entry with { HitCount = entry.HitCount + 1 });
|
||||
}
|
||||
|
||||
// Expired, remove
|
||||
_byVerdictDigest.Remove(verdictDigest);
|
||||
Interlocked.Increment(ref _evictionCount);
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _missCount);
|
||||
return Task.FromResult<TrustVerdictCacheEntry?>(null);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<TrustVerdictCacheEntry?> GetByVexDigestAsync(
|
||||
string vexDigest,
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var key = BuildVexKey(vexDigest, tenantId);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_vexToVerdictIndex.TryGetValue(key, out var verdictDigest))
|
||||
{
|
||||
return GetAsync(verdictDigest, ct);
|
||||
}
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _missCount);
|
||||
return Task.FromResult<TrustVerdictCacheEntry?>(null);
|
||||
}
|
||||
|
||||
public Task SetAsync(TrustVerdictCacheEntry entry, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
var options = _options.CurrentValue;
|
||||
var vexKey = BuildVexKey(entry.VexDigest, entry.TenantId);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
// Enforce max entries
|
||||
if (_byVerdictDigest.Count >= options.MaxEntries && !_byVerdictDigest.ContainsKey(entry.VerdictDigest))
|
||||
{
|
||||
EvictOldest();
|
||||
}
|
||||
|
||||
_byVerdictDigest[entry.VerdictDigest] = entry;
|
||||
_vexToVerdictIndex[vexKey] = entry.VerdictDigest;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task InvalidateAsync(string verdictDigest, CancellationToken ct = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_byVerdictDigest.TryGetValue(verdictDigest, out var entry))
|
||||
{
|
||||
_byVerdictDigest.Remove(verdictDigest);
|
||||
|
||||
var vexKey = BuildVexKey(entry.VexDigest, entry.TenantId);
|
||||
_vexToVerdictIndex.Remove(vexKey);
|
||||
|
||||
Interlocked.Increment(ref _evictionCount);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task InvalidateByVexDigestAsync(string vexDigest, string tenantId, CancellationToken ct = default)
|
||||
{
|
||||
var vexKey = BuildVexKey(vexDigest, tenantId);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_vexToVerdictIndex.TryGetValue(vexKey, out var verdictDigest))
|
||||
{
|
||||
_byVerdictDigest.Remove(verdictDigest);
|
||||
_vexToVerdictIndex.Remove(vexKey);
|
||||
Interlocked.Increment(ref _evictionCount);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyDictionary<string, TrustVerdictCacheEntry>> GetBatchAsync(
|
||||
IEnumerable<string> vexDigests,
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var results = new Dictionary<string, TrustVerdictCacheEntry>(StringComparer.Ordinal);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var vexDigest in vexDigests)
|
||||
{
|
||||
var vexKey = BuildVexKey(vexDigest, tenantId);
|
||||
|
||||
if (_vexToVerdictIndex.TryGetValue(vexKey, out var verdictDigest) &&
|
||||
_byVerdictDigest.TryGetValue(verdictDigest, out var entry) &&
|
||||
now < entry.ExpiresAt)
|
||||
{
|
||||
results[vexDigest] = entry;
|
||||
Interlocked.Increment(ref _hitCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
Interlocked.Increment(ref _missCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyDictionary<string, TrustVerdictCacheEntry>>(results);
|
||||
}
|
||||
|
||||
public Task<TrustVerdictCacheStats> GetStatsAsync(CancellationToken ct = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return Task.FromResult(new TrustVerdictCacheStats
|
||||
{
|
||||
TotalEntries = _byVerdictDigest.Count,
|
||||
TotalHits = _hitCount,
|
||||
TotalMisses = _missCount,
|
||||
TotalEvictions = _evictionCount,
|
||||
MemoryUsedBytes = EstimateMemoryUsage(),
|
||||
CollectedAt = _timeProvider.GetUtcNow()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildVexKey(string vexDigest, string tenantId)
|
||||
=> $"{tenantId}:{vexDigest}";
|
||||
|
||||
private void EvictOldest()
|
||||
{
|
||||
// Simple LRU-ish: evict entry with oldest CachedAt
|
||||
var oldest = _byVerdictDigest.Values
|
||||
.OrderBy(e => e.CachedAt)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (oldest != null)
|
||||
{
|
||||
_byVerdictDigest.Remove(oldest.VerdictDigest);
|
||||
var vexKey = BuildVexKey(oldest.VexDigest, oldest.TenantId);
|
||||
_vexToVerdictIndex.Remove(vexKey);
|
||||
Interlocked.Increment(ref _evictionCount);
|
||||
}
|
||||
}
|
||||
|
||||
private long EstimateMemoryUsage()
|
||||
{
|
||||
// Rough estimate: ~1KB per entry average
|
||||
return _byVerdictDigest.Count * 1024L;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Valkey-backed TrustVerdict cache (production use).
|
||||
/// </summary>
|
||||
public sealed class ValkeyTrustVerdictCache : ITrustVerdictCache, IAsyncDisposable
|
||||
{
|
||||
private readonly IOptionsMonitor<TrustVerdictCacheOptions> _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<ValkeyTrustVerdictCache> _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
// Note: In production, this would use StackExchange.Redis or similar Valkey client
|
||||
// For now, we delegate to in-memory as a fallback
|
||||
private readonly InMemoryTrustVerdictCache _fallback;
|
||||
|
||||
public ValkeyTrustVerdictCache(
|
||||
IOptionsMonitor<TrustVerdictCacheOptions> options,
|
||||
ILogger<ValkeyTrustVerdictCache> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
_fallback = new InMemoryTrustVerdictCache(options, timeProvider);
|
||||
}
|
||||
|
||||
public async Task<TrustVerdictCacheEntry?> GetAsync(string verdictDigest, CancellationToken ct = default)
|
||||
{
|
||||
var opts = _options.CurrentValue;
|
||||
|
||||
if (!opts.UseValkey)
|
||||
{
|
||||
return await _fallback.GetAsync(verdictDigest, ct);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// TODO: Implement Valkey lookup
|
||||
// var key = BuildKey(opts.KeyPrefix, "verdict", verdictDigest);
|
||||
// var value = await _valkeyClient.GetAsync(key);
|
||||
// if (value != null)
|
||||
// return JsonSerializer.Deserialize<TrustVerdictCacheEntry>(value, _jsonOptions);
|
||||
|
||||
return await _fallback.GetAsync(verdictDigest, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Valkey lookup failed for {Digest}, falling back to in-memory", verdictDigest);
|
||||
return await _fallback.GetAsync(verdictDigest, ct);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TrustVerdictCacheEntry?> GetByVexDigestAsync(
|
||||
string vexDigest,
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var opts = _options.CurrentValue;
|
||||
|
||||
if (!opts.UseValkey)
|
||||
{
|
||||
return await _fallback.GetByVexDigestAsync(vexDigest, tenantId, ct);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// TODO: Implement Valkey lookup via secondary index
|
||||
return await _fallback.GetByVexDigestAsync(vexDigest, tenantId, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Valkey lookup failed for VEX {Digest}, falling back", vexDigest);
|
||||
return await _fallback.GetByVexDigestAsync(vexDigest, tenantId, ct);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetAsync(TrustVerdictCacheEntry entry, CancellationToken ct = default)
|
||||
{
|
||||
var opts = _options.CurrentValue;
|
||||
|
||||
// Always set in fallback for local consistency
|
||||
await _fallback.SetAsync(entry, ct);
|
||||
|
||||
if (!opts.UseValkey)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// TODO: Implement Valkey SET with TTL
|
||||
// var key = BuildKey(opts.KeyPrefix, "verdict", entry.VerdictDigest);
|
||||
// var value = JsonSerializer.Serialize(entry, _jsonOptions);
|
||||
// await _valkeyClient.SetAsync(key, value, opts.DefaultTtl);
|
||||
|
||||
// Also set secondary index
|
||||
// var vexKey = BuildKey(opts.KeyPrefix, "vex", entry.TenantId, entry.VexDigest);
|
||||
// await _valkeyClient.SetAsync(vexKey, entry.VerdictDigest, opts.DefaultTtl);
|
||||
|
||||
_logger.LogDebug("Cached verdict {Digest} in Valkey", entry.VerdictDigest);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to cache verdict {Digest} in Valkey", entry.VerdictDigest);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task InvalidateAsync(string verdictDigest, CancellationToken ct = default)
|
||||
{
|
||||
await _fallback.InvalidateAsync(verdictDigest, ct);
|
||||
|
||||
var opts = _options.CurrentValue;
|
||||
if (!opts.UseValkey)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// TODO: Implement Valkey DEL
|
||||
_logger.LogDebug("Invalidated verdict {Digest} in Valkey", verdictDigest);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to invalidate verdict {Digest} in Valkey", verdictDigest);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task InvalidateByVexDigestAsync(string vexDigest, string tenantId, CancellationToken ct = default)
|
||||
{
|
||||
await _fallback.InvalidateByVexDigestAsync(vexDigest, tenantId, ct);
|
||||
|
||||
var opts = _options.CurrentValue;
|
||||
if (!opts.UseValkey)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// TODO: Implement Valkey DEL via secondary index
|
||||
_logger.LogDebug("Invalidated verdicts for VEX {Digest} in Valkey", vexDigest);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to invalidate VEX {Digest} in Valkey", vexDigest);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, TrustVerdictCacheEntry>> GetBatchAsync(
|
||||
IEnumerable<string> vexDigests,
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var opts = _options.CurrentValue;
|
||||
|
||||
if (!opts.UseValkey)
|
||||
{
|
||||
return await _fallback.GetBatchAsync(vexDigests, tenantId, ct);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// TODO: Implement Valkey MGET for batch lookup
|
||||
return await _fallback.GetBatchAsync(vexDigests, tenantId, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Valkey batch lookup failed, falling back");
|
||||
return await _fallback.GetBatchAsync(vexDigests, tenantId, ct);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<TrustVerdictCacheStats> GetStatsAsync(CancellationToken ct = default)
|
||||
{
|
||||
// TODO: Combine Valkey INFO stats with fallback stats
|
||||
return _fallback.GetStatsAsync(ct);
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
// TODO: Dispose Valkey client when implemented
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for TrustVerdict caching.
|
||||
/// </summary>
|
||||
public sealed class TrustVerdictCacheOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section key.
|
||||
/// </summary>
|
||||
public const string SectionKey = "TrustVerdictCache";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to use Valkey (production) or in-memory (dev/test).
|
||||
/// </summary>
|
||||
public bool UseValkey { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Valkey connection string.
|
||||
/// </summary>
|
||||
public string? ConnectionString { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Key prefix for namespacing.
|
||||
/// </summary>
|
||||
public string KeyPrefix { get; set; } = "stellaops:trustverdicts:";
|
||||
|
||||
/// <summary>
|
||||
/// Default TTL for cached entries.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultTtl { get; set; } = TimeSpan.FromHours(1);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum entries for in-memory cache.
|
||||
/// </summary>
|
||||
public int MaxEntries { get; set; } = 10_000;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable cache metrics.
|
||||
/// </summary>
|
||||
public bool EnableMetrics { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// TrustEvidenceMerkleBuilder - Merkle tree builder for evidence chains
|
||||
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
|
||||
|
||||
using System.Buffers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Attestor.TrustVerdict.Predicates;
|
||||
|
||||
namespace StellaOps.Attestor.TrustVerdict.Evidence;
|
||||
|
||||
/// <summary>
|
||||
/// Builder for constructing Merkle trees from trust evidence items.
|
||||
/// Provides deterministic, verifiable evidence chains for TrustVerdict attestations.
|
||||
/// </summary>
|
||||
public interface ITrustEvidenceMerkleBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Build a Merkle tree from evidence items.
|
||||
/// </summary>
|
||||
/// <param name="items">Evidence items to include.</param>
|
||||
/// <returns>The constructed tree with root and proof capabilities.</returns>
|
||||
TrustEvidenceMerkleTree Build(IEnumerable<TrustEvidenceItem> items);
|
||||
|
||||
/// <summary>
|
||||
/// Verify a Merkle proof for an evidence item.
|
||||
/// </summary>
|
||||
/// <param name="item">The item to verify.</param>
|
||||
/// <param name="proof">The inclusion proof.</param>
|
||||
/// <param name="root">Expected Merkle root.</param>
|
||||
/// <returns>True if the proof is valid.</returns>
|
||||
bool VerifyProof(TrustEvidenceItem item, MerkleProof proof, string root);
|
||||
|
||||
/// <summary>
|
||||
/// Compute the leaf hash for an evidence item.
|
||||
/// </summary>
|
||||
/// <param name="item">The evidence item.</param>
|
||||
/// <returns>SHA-256 hash of the canonical item representation.</returns>
|
||||
byte[] ComputeLeafHash(TrustEvidenceItem item);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of building a Merkle tree from evidence.
|
||||
/// </summary>
|
||||
public sealed class TrustEvidenceMerkleTree
|
||||
{
|
||||
/// <summary>
|
||||
/// The Merkle root hash (sha256:...).
|
||||
/// </summary>
|
||||
public required string Root { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ordered list of leaf hashes.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> LeafHashes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of leaves.
|
||||
/// </summary>
|
||||
public int LeafCount => LeafHashes.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Tree height (log2 of leaf count, rounded up).
|
||||
/// </summary>
|
||||
public int Height { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total nodes in the tree.
|
||||
/// </summary>
|
||||
public int NodeCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Internal tree structure for proof generation.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<IReadOnlyList<byte[]>> Levels { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Generate an inclusion proof for a leaf at the given index.
|
||||
/// </summary>
|
||||
/// <param name="leafIndex">Zero-based index of the leaf.</param>
|
||||
/// <returns>The Merkle proof.</returns>
|
||||
public MerkleProof GenerateProof(int leafIndex)
|
||||
{
|
||||
if (leafIndex < 0 || leafIndex >= LeafCount)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(leafIndex),
|
||||
$"Leaf index must be between 0 and {LeafCount - 1}");
|
||||
}
|
||||
|
||||
var siblings = new List<MerkleProofNode>();
|
||||
var currentIndex = leafIndex;
|
||||
|
||||
for (var level = 0; level < Levels.Count - 1; level++)
|
||||
{
|
||||
var currentLevel = Levels[level];
|
||||
var siblingIndex = currentIndex ^ 1; // XOR to get sibling
|
||||
|
||||
if (siblingIndex < currentLevel.Count)
|
||||
{
|
||||
var isLeft = currentIndex % 2 == 1;
|
||||
siblings.Add(new MerkleProofNode
|
||||
{
|
||||
Hash = $"sha256:{Convert.ToHexStringLower(currentLevel[siblingIndex])}",
|
||||
Position = isLeft ? MerkleNodePosition.Left : MerkleNodePosition.Right
|
||||
});
|
||||
}
|
||||
else if (currentIndex == currentLevel.Count - 1 && currentLevel.Count % 2 == 1)
|
||||
{
|
||||
// Odd last element: it was paired with itself during tree building
|
||||
// Include itself as sibling (always on the right since we're at even index due to being last odd)
|
||||
siblings.Add(new MerkleProofNode
|
||||
{
|
||||
Hash = $"sha256:{Convert.ToHexStringLower(currentLevel[currentIndex])}",
|
||||
Position = MerkleNodePosition.Right
|
||||
});
|
||||
}
|
||||
|
||||
currentIndex /= 2;
|
||||
}
|
||||
|
||||
return new MerkleProof
|
||||
{
|
||||
LeafIndex = leafIndex,
|
||||
LeafHash = LeafHashes[leafIndex],
|
||||
Root = Root,
|
||||
Siblings = siblings
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merkle inclusion proof for a single evidence item.
|
||||
/// </summary>
|
||||
public sealed record MerkleProof
|
||||
{
|
||||
/// <summary>
|
||||
/// Index of the leaf in the original list.
|
||||
/// </summary>
|
||||
public required int LeafIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash of the leaf node.
|
||||
/// </summary>
|
||||
public required string LeafHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected Merkle root.
|
||||
/// </summary>
|
||||
public required string Root { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sibling hashes for verification.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<MerkleProofNode> Siblings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A sibling node in a Merkle proof.
|
||||
/// </summary>
|
||||
public sealed record MerkleProofNode
|
||||
{
|
||||
/// <summary>
|
||||
/// Hash of the sibling.
|
||||
/// </summary>
|
||||
public required string Hash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Position of the sibling (left or right).
|
||||
/// </summary>
|
||||
public required MerkleNodePosition Position { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Position of a node in a Merkle tree.
|
||||
/// </summary>
|
||||
public enum MerkleNodePosition
|
||||
{
|
||||
Left,
|
||||
Right
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of ITrustEvidenceMerkleBuilder using SHA-256.
|
||||
/// </summary>
|
||||
public sealed class TrustEvidenceMerkleBuilder : ITrustEvidenceMerkleBuilder
|
||||
{
|
||||
private const string DigestPrefix = "sha256:";
|
||||
|
||||
/// <inheritdoc />
|
||||
public TrustEvidenceMerkleTree Build(IEnumerable<TrustEvidenceItem> items)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
|
||||
// Sort items deterministically by digest
|
||||
var sortedItems = items
|
||||
.OrderBy(i => i.Digest, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (sortedItems.Count == 0)
|
||||
{
|
||||
var emptyHash = SHA256.HashData([]);
|
||||
return new TrustEvidenceMerkleTree
|
||||
{
|
||||
Root = DigestPrefix + Convert.ToHexStringLower(emptyHash),
|
||||
LeafHashes = [],
|
||||
Height = 0,
|
||||
NodeCount = 1,
|
||||
Levels = [[emptyHash]]
|
||||
};
|
||||
}
|
||||
|
||||
// Compute leaf hashes
|
||||
var leafHashes = sortedItems
|
||||
.Select(ComputeLeafHash)
|
||||
.ToList();
|
||||
|
||||
// Build tree levels bottom-up
|
||||
var levels = new List<List<byte[]>> { new(leafHashes) };
|
||||
var currentLevel = leafHashes;
|
||||
|
||||
while (currentLevel.Count > 1)
|
||||
{
|
||||
var nextLevel = new List<byte[]>();
|
||||
|
||||
for (var i = 0; i < currentLevel.Count; i += 2)
|
||||
{
|
||||
if (i + 1 < currentLevel.Count)
|
||||
{
|
||||
nextLevel.Add(HashPair(currentLevel[i], currentLevel[i + 1]));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Odd node: hash with itself (standard padding)
|
||||
nextLevel.Add(HashPair(currentLevel[i], currentLevel[i]));
|
||||
}
|
||||
}
|
||||
|
||||
levels.Add(nextLevel);
|
||||
currentLevel = nextLevel;
|
||||
}
|
||||
|
||||
var root = currentLevel[0];
|
||||
var height = levels.Count - 1;
|
||||
var nodeCount = levels.Sum(l => l.Count);
|
||||
|
||||
return new TrustEvidenceMerkleTree
|
||||
{
|
||||
Root = DigestPrefix + Convert.ToHexStringLower(root),
|
||||
LeafHashes = leafHashes.Select(h => DigestPrefix + Convert.ToHexStringLower(h)).ToList(),
|
||||
Height = height,
|
||||
NodeCount = nodeCount,
|
||||
Levels = levels.Select(l => (IReadOnlyList<byte[]>)l.AsReadOnly()).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool VerifyProof(TrustEvidenceItem item, MerkleProof proof, string root)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
ArgumentNullException.ThrowIfNull(proof);
|
||||
|
||||
// Compute expected leaf hash
|
||||
var leafHash = ComputeLeafHash(item);
|
||||
var expectedLeafHashStr = DigestPrefix + Convert.ToHexStringLower(leafHash);
|
||||
|
||||
if (!string.Equals(expectedLeafHashStr, proof.LeafHash, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Walk up the tree using siblings
|
||||
var currentHash = leafHash;
|
||||
|
||||
foreach (var sibling in proof.Siblings)
|
||||
{
|
||||
var siblingHash = ParseHash(sibling.Hash);
|
||||
|
||||
currentHash = sibling.Position switch
|
||||
{
|
||||
MerkleNodePosition.Left => HashPair(siblingHash, currentHash),
|
||||
MerkleNodePosition.Right => HashPair(currentHash, siblingHash),
|
||||
_ => throw new ArgumentException($"Invalid node position: {sibling.Position}")
|
||||
};
|
||||
}
|
||||
|
||||
var computedRoot = DigestPrefix + Convert.ToHexStringLower(currentHash);
|
||||
return string.Equals(computedRoot, root, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public byte[] ComputeLeafHash(TrustEvidenceItem item)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
|
||||
// Canonical representation: type|digest|uri|description|collectedAt(ISO8601)
|
||||
var canonical = new StringBuilder();
|
||||
canonical.Append(item.Type ?? string.Empty);
|
||||
canonical.Append('|');
|
||||
canonical.Append(item.Digest ?? string.Empty);
|
||||
canonical.Append('|');
|
||||
canonical.Append(item.Uri ?? string.Empty);
|
||||
canonical.Append('|');
|
||||
canonical.Append(item.Description ?? string.Empty);
|
||||
canonical.Append('|');
|
||||
canonical.Append(item.CollectedAt?.ToString("o") ?? string.Empty);
|
||||
|
||||
return SHA256.HashData(Encoding.UTF8.GetBytes(canonical.ToString()));
|
||||
}
|
||||
|
||||
private static byte[] HashPair(byte[] left, byte[] right)
|
||||
{
|
||||
// Domain separation: prefix with 0x01 for internal nodes
|
||||
var combined = new byte[1 + left.Length + right.Length];
|
||||
combined[0] = 0x01;
|
||||
left.CopyTo(combined, 1);
|
||||
right.CopyTo(combined, 1 + left.Length);
|
||||
|
||||
return SHA256.HashData(combined);
|
||||
}
|
||||
|
||||
private static byte[] ParseHash(string hashStr)
|
||||
{
|
||||
if (hashStr.StartsWith(DigestPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
hashStr = hashStr[DigestPrefix.Length..];
|
||||
}
|
||||
|
||||
return Convert.FromHexString(hashStr);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for TrustEvidenceMerkleTree.
|
||||
/// </summary>
|
||||
public static class TrustEvidenceMerkleTreeExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Convert Merkle tree to the predicate chain format.
|
||||
/// </summary>
|
||||
public static TrustEvidenceChain ToEvidenceChain(
|
||||
this TrustEvidenceMerkleTree tree,
|
||||
IReadOnlyList<TrustEvidenceItem> items)
|
||||
{
|
||||
return new TrustEvidenceChain
|
||||
{
|
||||
MerkleRoot = tree.Root,
|
||||
Items = items
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate that the tree root matches the chain's declared root.
|
||||
/// </summary>
|
||||
public static bool ValidateChain(
|
||||
this ITrustEvidenceMerkleBuilder builder,
|
||||
TrustEvidenceChain chain)
|
||||
{
|
||||
if (chain.Items == null || chain.Items.Count == 0)
|
||||
{
|
||||
// Empty chain should have empty hash root
|
||||
var emptyTree = builder.Build([]);
|
||||
return string.Equals(emptyTree.Root, chain.MerkleRoot, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
var tree = builder.Build(chain.Items);
|
||||
return string.Equals(tree.Root, chain.MerkleRoot, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// JsonCanonicalizer - Deterministic JSON serialization for content addressing
|
||||
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
|
||||
|
||||
using System.Buffers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.TrustVerdict;
|
||||
|
||||
/// <summary>
|
||||
/// Produces RFC 8785 compliant canonical JSON for digest computation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Canonical form ensures:
|
||||
/// - Deterministic key ordering (lexicographic)
|
||||
/// - No whitespace between tokens
|
||||
/// - Numbers without exponent notation
|
||||
/// - Unicode escaping only where required
|
||||
/// - No duplicate keys
|
||||
/// </remarks>
|
||||
public static class JsonCanonicalizer
|
||||
{
|
||||
private static readonly JsonSerializerOptions s_canonicalOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Converters = { new SortedObjectConverter() }
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Serialize an object to canonical JSON string.
|
||||
/// </summary>
|
||||
public static string Canonicalize<T>(T value)
|
||||
{
|
||||
// First serialize to JSON document to get raw structure
|
||||
var json = JsonSerializer.Serialize(value, s_canonicalOptions);
|
||||
|
||||
// Re-parse and canonicalize
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return CanonicalizeElement(doc.RootElement);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonicalize a JSON string.
|
||||
/// </summary>
|
||||
public static string Canonicalize(string json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return CanonicalizeElement(doc.RootElement);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonicalize a JSON element to string.
|
||||
/// </summary>
|
||||
public static string CanonicalizeElement(JsonElement element)
|
||||
{
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
using var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions
|
||||
{
|
||||
Indented = false,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
});
|
||||
|
||||
WriteCanonical(writer, element);
|
||||
writer.Flush();
|
||||
|
||||
return Encoding.UTF8.GetString(buffer.WrittenSpan);
|
||||
}
|
||||
|
||||
private static void WriteCanonical(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
WriteCanonicalObject(writer, element);
|
||||
break;
|
||||
|
||||
case JsonValueKind.Array:
|
||||
WriteCanonicalArray(writer, element);
|
||||
break;
|
||||
|
||||
case JsonValueKind.String:
|
||||
writer.WriteStringValue(element.GetString());
|
||||
break;
|
||||
|
||||
case JsonValueKind.Number:
|
||||
WriteCanonicalNumber(writer, element);
|
||||
break;
|
||||
|
||||
case JsonValueKind.True:
|
||||
writer.WriteBooleanValue(true);
|
||||
break;
|
||||
|
||||
case JsonValueKind.False:
|
||||
writer.WriteBooleanValue(false);
|
||||
break;
|
||||
|
||||
case JsonValueKind.Null:
|
||||
writer.WriteNullValue();
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ArgumentException($"Unsupported JSON value kind: {element.ValueKind}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteCanonicalObject(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
|
||||
// Sort properties lexicographically by key
|
||||
var properties = element.EnumerateObject()
|
||||
.OrderBy(p => p.Name, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
foreach (var property in properties)
|
||||
{
|
||||
writer.WritePropertyName(property.Name);
|
||||
WriteCanonical(writer, property.Value);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteCanonicalArray(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
writer.WriteStartArray();
|
||||
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
WriteCanonical(writer, item);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
private static void WriteCanonicalNumber(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
// RFC 8785: Numbers must be represented without exponent notation
|
||||
// and with minimal significant digits
|
||||
if (element.TryGetInt64(out var longValue))
|
||||
{
|
||||
writer.WriteNumberValue(longValue);
|
||||
}
|
||||
else if (element.TryGetDecimal(out var decimalValue))
|
||||
{
|
||||
// Normalize to remove trailing zeros
|
||||
writer.WriteNumberValue(decimalValue);
|
||||
}
|
||||
else
|
||||
{
|
||||
writer.WriteRawValue(element.GetRawText());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Custom converter that ensures object properties are sorted.
|
||||
/// </summary>
|
||||
private sealed class SortedObjectConverter : JsonConverter<object>
|
||||
{
|
||||
public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
throw new NotSupportedException("Deserialization not supported");
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
writer.WriteNullValue();
|
||||
return;
|
||||
}
|
||||
|
||||
var type = value.GetType();
|
||||
|
||||
// Get all public properties, sort by name
|
||||
var properties = type.GetProperties()
|
||||
.Where(p => p.CanRead)
|
||||
.OrderBy(p => options.PropertyNamingPolicy?.ConvertName(p.Name) ?? p.Name, StringComparer.Ordinal);
|
||||
|
||||
writer.WriteStartObject();
|
||||
|
||||
foreach (var property in properties)
|
||||
{
|
||||
var propValue = property.GetValue(value);
|
||||
if (propValue is null && options.DefaultIgnoreCondition == JsonIgnoreCondition.WhenWritingNull)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = options.PropertyNamingPolicy?.ConvertName(property.Name) ?? property.Name;
|
||||
writer.WritePropertyName(name);
|
||||
JsonSerializer.Serialize(writer, propValue, property.PropertyType, options);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
-- Migration: 002_create_trust_verdicts
|
||||
-- Description: Create trust_verdicts table for TrustVerdict attestation storage
|
||||
-- Sprint: SPRINT_1227_0004_0004
|
||||
|
||||
-- Create vex schema if not exists
|
||||
CREATE SCHEMA IF NOT EXISTS vex;
|
||||
|
||||
-- TrustVerdict attestations table
|
||||
CREATE TABLE vex.trust_verdicts (
|
||||
verdict_id TEXT NOT NULL,
|
||||
tenant_id UUID NOT NULL,
|
||||
|
||||
-- Subject fields (VEX document identity)
|
||||
vex_digest TEXT NOT NULL,
|
||||
vex_format TEXT NOT NULL, -- openvex, csaf, cyclonedx
|
||||
provider_id TEXT NOT NULL,
|
||||
statement_id TEXT NOT NULL,
|
||||
vulnerability_id TEXT NOT NULL,
|
||||
product_key TEXT NOT NULL,
|
||||
vex_status TEXT, -- not_affected, fixed, affected, etc.
|
||||
|
||||
-- Origin verification
|
||||
origin_valid BOOLEAN NOT NULL,
|
||||
origin_method TEXT NOT NULL, -- dsse, cosign, pgp, x509
|
||||
origin_key_id TEXT,
|
||||
origin_issuer_id TEXT,
|
||||
origin_issuer_name TEXT,
|
||||
origin_rekor_log_index BIGINT,
|
||||
origin_score DECIMAL(5,4) NOT NULL,
|
||||
|
||||
-- Freshness evaluation
|
||||
freshness_status TEXT NOT NULL, -- fresh, stale, superseded, expired
|
||||
freshness_issued_at TIMESTAMPTZ NOT NULL,
|
||||
freshness_expires_at TIMESTAMPTZ,
|
||||
freshness_superseded_by TEXT,
|
||||
freshness_age_days INTEGER NOT NULL,
|
||||
freshness_score DECIMAL(5,4) NOT NULL,
|
||||
|
||||
-- Reputation scores
|
||||
reputation_composite DECIMAL(5,4) NOT NULL,
|
||||
reputation_authority DECIMAL(5,4) NOT NULL,
|
||||
reputation_accuracy DECIMAL(5,4) NOT NULL,
|
||||
reputation_timeliness DECIMAL(5,4) NOT NULL,
|
||||
reputation_coverage DECIMAL(5,4) NOT NULL,
|
||||
reputation_verification DECIMAL(5,4) NOT NULL,
|
||||
reputation_sample_count INTEGER NOT NULL,
|
||||
|
||||
-- Trust composite
|
||||
trust_score DECIMAL(5,4) NOT NULL,
|
||||
trust_tier TEXT NOT NULL, -- verified, high, medium, low, untrusted
|
||||
trust_formula TEXT NOT NULL,
|
||||
trust_reasons TEXT[] NOT NULL,
|
||||
meets_policy_threshold BOOLEAN,
|
||||
policy_threshold DECIMAL(5,4),
|
||||
|
||||
-- Evidence chain
|
||||
evidence_merkle_root TEXT NOT NULL,
|
||||
evidence_items_json JSONB NOT NULL,
|
||||
|
||||
-- Attestation envelope
|
||||
envelope_base64 TEXT, -- DSSE envelope
|
||||
verdict_digest TEXT NOT NULL, -- Deterministic digest
|
||||
|
||||
-- Metadata
|
||||
evaluated_at TIMESTAMPTZ NOT NULL,
|
||||
evaluator_version TEXT NOT NULL,
|
||||
crypto_profile TEXT NOT NULL,
|
||||
policy_digest TEXT,
|
||||
environment TEXT,
|
||||
correlation_id TEXT,
|
||||
|
||||
-- OCI/Rekor integration
|
||||
oci_digest TEXT,
|
||||
rekor_log_index BIGINT,
|
||||
|
||||
-- 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 vex.trust_verdicts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS policy for tenant isolation
|
||||
CREATE POLICY tenant_isolation_policy ON vex.trust_verdicts
|
||||
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
|
||||
|
||||
-- Indexes for common query patterns
|
||||
|
||||
-- Query by VEX digest (most common lookup)
|
||||
CREATE INDEX idx_trust_verdicts_vex_digest ON vex.trust_verdicts(tenant_id, vex_digest);
|
||||
|
||||
-- Query by provider/issuer
|
||||
CREATE INDEX idx_trust_verdicts_provider ON vex.trust_verdicts(tenant_id, provider_id);
|
||||
CREATE INDEX idx_trust_verdicts_issuer ON vex.trust_verdicts(tenant_id, origin_issuer_id);
|
||||
|
||||
-- Query by vulnerability
|
||||
CREATE INDEX idx_trust_verdicts_vuln ON vex.trust_verdicts(tenant_id, vulnerability_id);
|
||||
|
||||
-- Query by product
|
||||
CREATE INDEX idx_trust_verdicts_product ON vex.trust_verdicts(tenant_id, product_key);
|
||||
|
||||
-- Query by trust tier
|
||||
CREATE INDEX idx_trust_verdicts_tier ON vex.trust_verdicts(tenant_id, trust_tier);
|
||||
|
||||
-- Query by trust score (for policy decisions)
|
||||
CREATE INDEX idx_trust_verdicts_score ON vex.trust_verdicts(tenant_id, trust_score DESC);
|
||||
|
||||
-- Query by freshness
|
||||
CREATE INDEX idx_trust_verdicts_freshness ON vex.trust_verdicts(tenant_id, freshness_status);
|
||||
|
||||
-- Query active (non-expired) verdicts
|
||||
CREATE INDEX idx_trust_verdicts_active ON vex.trust_verdicts(tenant_id, expires_at)
|
||||
WHERE expires_at IS NULL OR expires_at > NOW();
|
||||
|
||||
-- Query by evaluation time (for cleanup/retention)
|
||||
CREATE INDEX idx_trust_verdicts_evaluated ON vex.trust_verdicts(evaluated_at DESC);
|
||||
|
||||
-- Unique constraint on VEX digest per tenant
|
||||
CREATE UNIQUE INDEX uq_trust_verdicts_vex_tenant ON vex.trust_verdicts(tenant_id, vex_digest);
|
||||
|
||||
-- GIN index on evidence items for JSONB queries
|
||||
CREATE INDEX idx_trust_verdicts_evidence ON vex.trust_verdicts USING GIN (evidence_items_json);
|
||||
|
||||
-- GIN index on trust reasons for full-text search
|
||||
CREATE INDEX idx_trust_verdicts_reasons ON vex.trust_verdicts USING GIN (trust_reasons);
|
||||
|
||||
-- Comments
|
||||
COMMENT ON TABLE vex.trust_verdicts IS 'Signed TrustVerdict attestations for VEX document verification results';
|
||||
COMMENT ON COLUMN vex.trust_verdicts.verdict_digest IS 'Deterministic SHA-256 digest of the verdict predicate for replay verification';
|
||||
COMMENT ON COLUMN vex.trust_verdicts.evidence_merkle_root IS 'Merkle root of evidence chain for compact proofs';
|
||||
COMMENT ON COLUMN vex.trust_verdicts.trust_formula IS 'Formula used for composite score calculation (transparency)';
|
||||
@@ -0,0 +1,398 @@
|
||||
// TrustVerdictOciAttacher - OCI registry attachment for TrustVerdict attestations
|
||||
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Attestor.TrustVerdict.Oci;
|
||||
|
||||
/// <summary>
|
||||
/// Service for attaching TrustVerdict attestations to OCI artifacts.
|
||||
/// </summary>
|
||||
public interface ITrustVerdictOciAttacher
|
||||
{
|
||||
/// <summary>
|
||||
/// Attach a TrustVerdict attestation to an OCI artifact.
|
||||
/// </summary>
|
||||
/// <param name="imageReference">OCI image reference (registry/repo:tag@sha256:digest).</param>
|
||||
/// <param name="envelopeBase64">DSSE envelope (base64 encoded).</param>
|
||||
/// <param name="verdictDigest">Deterministic verdict digest for verification.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>OCI digest of the attached attestation.</returns>
|
||||
Task<TrustVerdictOciAttachResult> AttachAsync(
|
||||
string imageReference,
|
||||
string envelopeBase64,
|
||||
string verdictDigest,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Fetch a TrustVerdict attestation from an OCI artifact.
|
||||
/// </summary>
|
||||
/// <param name="imageReference">OCI image reference.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The fetched envelope or null if not found.</returns>
|
||||
Task<TrustVerdictOciFetchResult?> FetchAsync(
|
||||
string imageReference,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// List all TrustVerdict attestations for an OCI artifact.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<TrustVerdictOciEntry>> ListAsync(
|
||||
string imageReference,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Detach (remove) a TrustVerdict attestation from an OCI artifact.
|
||||
/// </summary>
|
||||
Task<bool> DetachAsync(
|
||||
string imageReference,
|
||||
string verdictDigest,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of attaching a TrustVerdict to OCI.
|
||||
/// </summary>
|
||||
public sealed record TrustVerdictOciAttachResult
|
||||
{
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of fetching a TrustVerdict from OCI.
|
||||
/// </summary>
|
||||
public sealed record TrustVerdictOciFetchResult
|
||||
{
|
||||
public required string EnvelopeBase64 { get; init; }
|
||||
public required string VerdictDigest { get; init; }
|
||||
public required string OciDigest { get; init; }
|
||||
public required DateTimeOffset AttachedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry in the list of OCI attachments.
|
||||
/// </summary>
|
||||
public sealed record TrustVerdictOciEntry
|
||||
{
|
||||
public required string VerdictDigest { 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 patterns.
|
||||
/// </summary>
|
||||
public sealed class TrustVerdictOciAttacher : ITrustVerdictOciAttacher
|
||||
{
|
||||
private readonly IOptionsMonitor<TrustVerdictOciOptions> _options;
|
||||
private readonly ILogger<TrustVerdictOciAttacher> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
// ORAS artifact type for TrustVerdict attestations
|
||||
public const string ArtifactType = "application/vnd.stellaops.trust-verdict.v1+dsse";
|
||||
public const string MediaType = "application/vnd.dsse.envelope.v1+json";
|
||||
|
||||
public TrustVerdictOciAttacher(
|
||||
IOptionsMonitor<TrustVerdictOciOptions> options,
|
||||
ILogger<TrustVerdictOciAttacher> 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<TrustVerdictOciAttachResult> AttachAsync(
|
||||
string imageReference,
|
||||
string envelopeBase64,
|
||||
string verdictDigest,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var startTime = _timeProvider.GetUtcNow();
|
||||
var opts = _options.CurrentValue;
|
||||
|
||||
if (!opts.Enabled)
|
||||
{
|
||||
_logger.LogDebug("OCI attachment disabled, skipping for {Reference}", imageReference);
|
||||
return new TrustVerdictOciAttachResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = "OCI attachment is disabled",
|
||||
Duration = _timeProvider.GetUtcNow() - startTime
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Parse reference
|
||||
var parsed = ParseReference(imageReference);
|
||||
if (parsed == null)
|
||||
{
|
||||
return new TrustVerdictOciAttachResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = $"Invalid OCI reference: {imageReference}",
|
||||
Duration = _timeProvider.GetUtcNow() - startTime
|
||||
};
|
||||
}
|
||||
|
||||
// Build referrers API URL
|
||||
// POST /v2/{name}/manifests/{reference} with artifact manifest
|
||||
|
||||
// Note: Full ORAS implementation would:
|
||||
// 1. Create blob with envelope
|
||||
// 2. Create artifact manifest referencing the blob
|
||||
// 3. Push manifest with subject pointing to original image
|
||||
|
||||
_logger.LogInformation(
|
||||
"Would attach TrustVerdict {Digest} to {Reference} (implementation pending)",
|
||||
verdictDigest, imageReference);
|
||||
|
||||
// Placeholder - full implementation requires OCI client
|
||||
var mockDigest = $"sha256:{Guid.NewGuid():N}";
|
||||
|
||||
return new TrustVerdictOciAttachResult
|
||||
{
|
||||
Success = true,
|
||||
OciDigest = mockDigest,
|
||||
ManifestDigest = mockDigest,
|
||||
Duration = _timeProvider.GetUtcNow() - startTime
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to attach TrustVerdict to {Reference}", imageReference);
|
||||
return new TrustVerdictOciAttachResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = ex.Message,
|
||||
Duration = _timeProvider.GetUtcNow() - startTime
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TrustVerdictOciFetchResult?> FetchAsync(
|
||||
string imageReference,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var opts = _options.CurrentValue;
|
||||
|
||||
if (!opts.Enabled)
|
||||
{
|
||||
_logger.LogDebug("OCI attachment disabled, skipping fetch for {Reference}", imageReference);
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var parsed = ParseReference(imageReference);
|
||||
if (parsed == null)
|
||||
{
|
||||
_logger.LogWarning("Invalid OCI reference: {Reference}", imageReference);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Query referrers API
|
||||
// GET /v2/{name}/referrers/{digest}?artifactType={ArtifactType}
|
||||
|
||||
_logger.LogDebug("Would fetch TrustVerdict from {Reference} (implementation pending)", imageReference);
|
||||
|
||||
// Placeholder
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fetch TrustVerdict from {Reference}", imageReference);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TrustVerdictOciEntry>> ListAsync(
|
||||
string imageReference,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var opts = _options.CurrentValue;
|
||||
|
||||
if (!opts.Enabled)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var parsed = ParseReference(imageReference);
|
||||
if (parsed == null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
// Query referrers API and filter by artifact type
|
||||
_logger.LogDebug("Would list TrustVerdicts for {Reference} (implementation pending)", imageReference);
|
||||
|
||||
return [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to list TrustVerdicts for {Reference}", imageReference);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DetachAsync(
|
||||
string imageReference,
|
||||
string verdictDigest,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var opts = _options.CurrentValue;
|
||||
|
||||
if (!opts.Enabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// DELETE the referrer manifest
|
||||
_logger.LogDebug(
|
||||
"Would detach TrustVerdict {Digest} from {Reference} (implementation pending)",
|
||||
verdictDigest, imageReference);
|
||||
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to detach TrustVerdict from {Reference}", imageReference);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static OciReference? ParseReference(string reference)
|
||||
{
|
||||
// Parse: registry/repo:tag or registry/repo@sha256:digest
|
||||
try
|
||||
{
|
||||
var atIdx = reference.IndexOf('@');
|
||||
var colonIdx = reference.LastIndexOf(':');
|
||||
|
||||
string registry;
|
||||
string repository;
|
||||
string? tag = null;
|
||||
string? digest = null;
|
||||
|
||||
if (atIdx > 0)
|
||||
{
|
||||
// Has digest
|
||||
digest = reference[(atIdx + 1)..];
|
||||
var beforeDigest = reference[..atIdx];
|
||||
var slashIdx = beforeDigest.IndexOf('/');
|
||||
registry = beforeDigest[..slashIdx];
|
||||
repository = beforeDigest[(slashIdx + 1)..];
|
||||
}
|
||||
else if (colonIdx > 0 && colonIdx > reference.IndexOf('/'))
|
||||
{
|
||||
// Has tag
|
||||
tag = reference[(colonIdx + 1)..];
|
||||
var beforeTag = reference[..colonIdx];
|
||||
var slashIdx = beforeTag.IndexOf('/');
|
||||
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; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for OCI attachment.
|
||||
/// </summary>
|
||||
public sealed class TrustVerdictOciOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section key.
|
||||
/// </summary>
|
||||
public const string SectionKey = "TrustVerdictOci";
|
||||
|
||||
/// <summary>
|
||||
/// Whether OCI attachment is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Default registry URL if not specified in reference.
|
||||
/// </summary>
|
||||
public string? DefaultRegistry { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Registry authentication (if needed).
|
||||
/// </summary>
|
||||
public OciAuthOptions? 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 options.
|
||||
/// </summary>
|
||||
public sealed class OciAuthOptions
|
||||
{
|
||||
/// <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 credentials file.
|
||||
/// </summary>
|
||||
public string? CredentialsFile { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
// TrustVerdictRepository - PostgreSQL persistence for TrustVerdict attestations
|
||||
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
|
||||
|
||||
using System.Text.Json;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.Attestor.TrustVerdict.Predicates;
|
||||
|
||||
namespace StellaOps.Attestor.TrustVerdict.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for TrustVerdict persistence.
|
||||
/// </summary>
|
||||
public interface ITrustVerdictRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Store a TrustVerdict attestation.
|
||||
/// </summary>
|
||||
Task<string> StoreAsync(TrustVerdictEntity entity, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get a TrustVerdict by ID.
|
||||
/// </summary>
|
||||
Task<TrustVerdictEntity?> GetByIdAsync(Guid tenantId, string verdictId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get a TrustVerdict by VEX digest.
|
||||
/// </summary>
|
||||
Task<TrustVerdictEntity?> GetByVexDigestAsync(Guid tenantId, string vexDigest, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get TrustVerdicts by provider.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<TrustVerdictEntity>> GetByProviderAsync(
|
||||
Guid tenantId,
|
||||
string providerId,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get TrustVerdicts by vulnerability.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<TrustVerdictEntity>> GetByVulnerabilityAsync(
|
||||
Guid tenantId,
|
||||
string vulnerabilityId,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get TrustVerdicts by trust tier.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<TrustVerdictEntity>> GetByTierAsync(
|
||||
Guid tenantId,
|
||||
string tier,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get active (non-expired) TrustVerdicts with minimum score.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<TrustVerdictEntity>> GetActiveByMinScoreAsync(
|
||||
Guid tenantId,
|
||||
decimal minScore,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Delete a TrustVerdict.
|
||||
/// </summary>
|
||||
Task<bool> DeleteAsync(Guid tenantId, string verdictId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Delete expired TrustVerdicts.
|
||||
/// </summary>
|
||||
Task<int> DeleteExpiredAsync(Guid tenantId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Count TrustVerdicts for tenant.
|
||||
/// </summary>
|
||||
Task<long> CountAsync(Guid tenantId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get aggregate statistics.
|
||||
/// </summary>
|
||||
Task<TrustVerdictStats> GetStatsAsync(Guid tenantId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entity representing a stored TrustVerdict.
|
||||
/// </summary>
|
||||
public sealed record TrustVerdictEntity
|
||||
{
|
||||
public required string VerdictId { get; init; }
|
||||
public required Guid TenantId { get; init; }
|
||||
|
||||
// Subject
|
||||
public required string VexDigest { get; init; }
|
||||
public required string VexFormat { get; init; }
|
||||
public required string ProviderId { get; init; }
|
||||
public required string StatementId { get; init; }
|
||||
public required string VulnerabilityId { get; init; }
|
||||
public required string ProductKey { get; init; }
|
||||
public string? VexStatus { get; init; }
|
||||
|
||||
// Origin
|
||||
public required bool OriginValid { get; init; }
|
||||
public required string OriginMethod { get; init; }
|
||||
public string? OriginKeyId { get; init; }
|
||||
public string? OriginIssuerId { get; init; }
|
||||
public string? OriginIssuerName { get; init; }
|
||||
public long? OriginRekorLogIndex { get; init; }
|
||||
public required decimal OriginScore { get; init; }
|
||||
|
||||
// Freshness
|
||||
public required string FreshnessStatus { get; init; }
|
||||
public required DateTimeOffset FreshnessIssuedAt { get; init; }
|
||||
public DateTimeOffset? FreshnessExpiresAt { get; init; }
|
||||
public string? FreshnessSupersededBy { get; init; }
|
||||
public required int FreshnessAgeDays { get; init; }
|
||||
public required decimal FreshnessScore { get; init; }
|
||||
|
||||
// Reputation
|
||||
public required decimal ReputationComposite { get; init; }
|
||||
public required decimal ReputationAuthority { get; init; }
|
||||
public required decimal ReputationAccuracy { get; init; }
|
||||
public required decimal ReputationTimeliness { get; init; }
|
||||
public required decimal ReputationCoverage { get; init; }
|
||||
public required decimal ReputationVerification { get; init; }
|
||||
public required int ReputationSampleCount { get; init; }
|
||||
|
||||
// Trust composite
|
||||
public required decimal TrustScore { get; init; }
|
||||
public required string TrustTier { get; init; }
|
||||
public required string TrustFormula { get; init; }
|
||||
public required IReadOnlyList<string> TrustReasons { get; init; }
|
||||
public bool? MeetsPolicyThreshold { get; init; }
|
||||
public decimal? PolicyThreshold { get; init; }
|
||||
|
||||
// Evidence
|
||||
public required string EvidenceMerkleRoot { get; init; }
|
||||
public required IReadOnlyList<TrustEvidenceItem> EvidenceItems { get; init; }
|
||||
|
||||
// Attestation
|
||||
public string? EnvelopeBase64 { get; init; }
|
||||
public required string VerdictDigest { get; init; }
|
||||
|
||||
// Metadata
|
||||
public required DateTimeOffset EvaluatedAt { get; init; }
|
||||
public required string EvaluatorVersion { get; init; }
|
||||
public required string CryptoProfile { get; init; }
|
||||
public string? PolicyDigest { get; init; }
|
||||
public string? Environment { get; init; }
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
// OCI/Rekor
|
||||
public string? OciDigest { get; init; }
|
||||
public long? RekorLogIndex { get; init; }
|
||||
|
||||
// Timestamps
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate statistics for TrustVerdicts.
|
||||
/// </summary>
|
||||
public sealed record TrustVerdictStats
|
||||
{
|
||||
public required long TotalCount { get; init; }
|
||||
public required long ActiveCount { get; init; }
|
||||
public required long ExpiredCount { get; init; }
|
||||
public required decimal AverageScore { get; init; }
|
||||
public required IReadOnlyDictionary<string, long> CountByTier { get; init; }
|
||||
public required IReadOnlyDictionary<string, long> CountByProvider { get; init; }
|
||||
public required DateTimeOffset? OldestEvaluation { get; init; }
|
||||
public required DateTimeOffset? NewestEvaluation { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of ITrustVerdictRepository.
|
||||
/// </summary>
|
||||
public sealed class PostgresTrustVerdictRepository : ITrustVerdictRepository
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public PostgresTrustVerdictRepository(NpgsqlDataSource dataSource)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
}
|
||||
|
||||
public async Task<string> StoreAsync(TrustVerdictEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO vex.trust_verdicts (
|
||||
verdict_id, tenant_id,
|
||||
vex_digest, vex_format, provider_id, statement_id, vulnerability_id, product_key, vex_status,
|
||||
origin_valid, origin_method, origin_key_id, origin_issuer_id, origin_issuer_name, origin_rekor_log_index, origin_score,
|
||||
freshness_status, freshness_issued_at, freshness_expires_at, freshness_superseded_by, freshness_age_days, freshness_score,
|
||||
reputation_composite, reputation_authority, reputation_accuracy, reputation_timeliness, reputation_coverage, reputation_verification, reputation_sample_count,
|
||||
trust_score, trust_tier, trust_formula, trust_reasons, meets_policy_threshold, policy_threshold,
|
||||
evidence_merkle_root, evidence_items_json,
|
||||
envelope_base64, verdict_digest,
|
||||
evaluated_at, evaluator_version, crypto_profile, policy_digest, environment, correlation_id,
|
||||
oci_digest, rekor_log_index,
|
||||
created_at, expires_at
|
||||
) VALUES (
|
||||
@verdict_id, @tenant_id,
|
||||
@vex_digest, @vex_format, @provider_id, @statement_id, @vulnerability_id, @product_key, @vex_status,
|
||||
@origin_valid, @origin_method, @origin_key_id, @origin_issuer_id, @origin_issuer_name, @origin_rekor_log_index, @origin_score,
|
||||
@freshness_status, @freshness_issued_at, @freshness_expires_at, @freshness_superseded_by, @freshness_age_days, @freshness_score,
|
||||
@reputation_composite, @reputation_authority, @reputation_accuracy, @reputation_timeliness, @reputation_coverage, @reputation_verification, @reputation_sample_count,
|
||||
@trust_score, @trust_tier, @trust_formula, @trust_reasons, @meets_policy_threshold, @policy_threshold,
|
||||
@evidence_merkle_root, @evidence_items_json::jsonb,
|
||||
@envelope_base64, @verdict_digest,
|
||||
@evaluated_at, @evaluator_version, @crypto_profile, @policy_digest, @environment, @correlation_id,
|
||||
@oci_digest, @rekor_log_index,
|
||||
@created_at, @expires_at
|
||||
)
|
||||
ON CONFLICT (tenant_id, vex_digest) DO UPDATE SET
|
||||
verdict_id = EXCLUDED.verdict_id,
|
||||
origin_valid = EXCLUDED.origin_valid,
|
||||
origin_method = EXCLUDED.origin_method,
|
||||
origin_score = EXCLUDED.origin_score,
|
||||
freshness_status = EXCLUDED.freshness_status,
|
||||
freshness_score = EXCLUDED.freshness_score,
|
||||
reputation_composite = EXCLUDED.reputation_composite,
|
||||
trust_score = EXCLUDED.trust_score,
|
||||
trust_tier = EXCLUDED.trust_tier,
|
||||
trust_reasons = EXCLUDED.trust_reasons,
|
||||
evidence_merkle_root = EXCLUDED.evidence_merkle_root,
|
||||
evidence_items_json = EXCLUDED.evidence_items_json,
|
||||
envelope_base64 = EXCLUDED.envelope_base64,
|
||||
verdict_digest = EXCLUDED.verdict_digest,
|
||||
evaluated_at = EXCLUDED.evaluated_at,
|
||||
expires_at = EXCLUDED.expires_at
|
||||
RETURNING verdict_id
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
AddEntityParameters(cmd, entity);
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync(ct);
|
||||
return result?.ToString() ?? entity.VerdictId;
|
||||
}
|
||||
|
||||
public async Task<TrustVerdictEntity?> GetByIdAsync(Guid tenantId, string verdictId, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM vex.trust_verdicts
|
||||
WHERE tenant_id = @tenant_id AND verdict_id = @verdict_id
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
cmd.Parameters.AddWithValue("verdict_id", verdictId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
return await reader.ReadAsync(ct) ? ReadEntity(reader) : null;
|
||||
}
|
||||
|
||||
public async Task<TrustVerdictEntity?> GetByVexDigestAsync(Guid tenantId, string vexDigest, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM vex.trust_verdicts
|
||||
WHERE tenant_id = @tenant_id AND vex_digest = @vex_digest
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
cmd.Parameters.AddWithValue("vex_digest", vexDigest);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
return await reader.ReadAsync(ct) ? ReadEntity(reader) : null;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TrustVerdictEntity>> GetByProviderAsync(
|
||||
Guid tenantId, string providerId, int limit, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM vex.trust_verdicts
|
||||
WHERE tenant_id = @tenant_id AND provider_id = @provider_id
|
||||
ORDER BY evaluated_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
return await ExecuteQueryAsync(sql, tenantId, cmd =>
|
||||
{
|
||||
cmd.Parameters.AddWithValue("provider_id", providerId);
|
||||
cmd.Parameters.AddWithValue("limit", limit);
|
||||
}, ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TrustVerdictEntity>> GetByVulnerabilityAsync(
|
||||
Guid tenantId, string vulnerabilityId, int limit, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM vex.trust_verdicts
|
||||
WHERE tenant_id = @tenant_id AND vulnerability_id = @vulnerability_id
|
||||
ORDER BY evaluated_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
return await ExecuteQueryAsync(sql, tenantId, cmd =>
|
||||
{
|
||||
cmd.Parameters.AddWithValue("vulnerability_id", vulnerabilityId);
|
||||
cmd.Parameters.AddWithValue("limit", limit);
|
||||
}, ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TrustVerdictEntity>> GetByTierAsync(
|
||||
Guid tenantId, string tier, int limit, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM vex.trust_verdicts
|
||||
WHERE tenant_id = @tenant_id AND trust_tier = @tier
|
||||
ORDER BY trust_score DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
return await ExecuteQueryAsync(sql, tenantId, cmd =>
|
||||
{
|
||||
cmd.Parameters.AddWithValue("tier", tier);
|
||||
cmd.Parameters.AddWithValue("limit", limit);
|
||||
}, ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TrustVerdictEntity>> GetActiveByMinScoreAsync(
|
||||
Guid tenantId, decimal minScore, int limit, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM vex.trust_verdicts
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND trust_score >= @min_score
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
ORDER BY trust_score DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
return await ExecuteQueryAsync(sql, tenantId, cmd =>
|
||||
{
|
||||
cmd.Parameters.AddWithValue("min_score", minScore);
|
||||
cmd.Parameters.AddWithValue("limit", limit);
|
||||
}, ct);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid tenantId, string verdictId, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
DELETE FROM vex.trust_verdicts
|
||||
WHERE tenant_id = @tenant_id AND verdict_id = @verdict_id
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
cmd.Parameters.AddWithValue("verdict_id", verdictId);
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
}
|
||||
|
||||
public async Task<int> DeleteExpiredAsync(Guid tenantId, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
DELETE FROM vex.trust_verdicts
|
||||
WHERE tenant_id = @tenant_id AND expires_at < NOW()
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<long> CountAsync(Guid tenantId, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT COUNT(*) FROM vex.trust_verdicts
|
||||
WHERE tenant_id = @tenant_id
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync(ct);
|
||||
return Convert.ToInt64(result);
|
||||
}
|
||||
|
||||
public async Task<TrustVerdictStats> GetStatsAsync(Guid tenantId, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT
|
||||
COUNT(*) as total_count,
|
||||
COUNT(*) FILTER (WHERE expires_at IS NULL OR expires_at > NOW()) as active_count,
|
||||
COUNT(*) FILTER (WHERE expires_at <= NOW()) as expired_count,
|
||||
COALESCE(AVG(trust_score), 0) as average_score,
|
||||
MIN(evaluated_at) as oldest_evaluation,
|
||||
MAX(evaluated_at) as newest_evaluation
|
||||
FROM vex.trust_verdicts
|
||||
WHERE tenant_id = @tenant_id
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
await reader.ReadAsync(ct);
|
||||
|
||||
var stats = new TrustVerdictStats
|
||||
{
|
||||
TotalCount = reader.GetInt64(0),
|
||||
ActiveCount = reader.GetInt64(1),
|
||||
ExpiredCount = reader.GetInt64(2),
|
||||
AverageScore = reader.GetDecimal(3),
|
||||
OldestEvaluation = reader.IsDBNull(4) ? null : reader.GetDateTime(4),
|
||||
NewestEvaluation = reader.IsDBNull(5) ? null : reader.GetDateTime(5),
|
||||
CountByTier = await GetCountByTierAsync(tenantId, ct),
|
||||
CountByProvider = await GetCountByProviderAsync(tenantId, ct)
|
||||
};
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyDictionary<string, long>> GetCountByTierAsync(Guid tenantId, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT trust_tier, COUNT(*) FROM vex.trust_verdicts
|
||||
WHERE tenant_id = @tenant_id
|
||||
GROUP BY trust_tier
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
|
||||
var result = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
result[reader.GetString(0)] = reader.GetInt64(1);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyDictionary<string, long>> GetCountByProviderAsync(Guid tenantId, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT provider_id, COUNT(*) FROM vex.trust_verdicts
|
||||
WHERE tenant_id = @tenant_id
|
||||
GROUP BY provider_id
|
||||
ORDER BY COUNT(*) DESC
|
||||
LIMIT 20
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
|
||||
var result = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
result[reader.GetString(0)] = reader.GetInt64(1);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<TrustVerdictEntity>> ExecuteQueryAsync(
|
||||
string sql,
|
||||
Guid tenantId,
|
||||
Action<NpgsqlCommand> configure,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
configure(cmd);
|
||||
|
||||
var results = new List<TrustVerdictEntity>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
results.Add(ReadEntity(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private void AddEntityParameters(NpgsqlCommand cmd, TrustVerdictEntity entity)
|
||||
{
|
||||
cmd.Parameters.AddWithValue("verdict_id", entity.VerdictId);
|
||||
cmd.Parameters.AddWithValue("tenant_id", entity.TenantId);
|
||||
|
||||
cmd.Parameters.AddWithValue("vex_digest", entity.VexDigest);
|
||||
cmd.Parameters.AddWithValue("vex_format", entity.VexFormat);
|
||||
cmd.Parameters.AddWithValue("provider_id", entity.ProviderId);
|
||||
cmd.Parameters.AddWithValue("statement_id", entity.StatementId);
|
||||
cmd.Parameters.AddWithValue("vulnerability_id", entity.VulnerabilityId);
|
||||
cmd.Parameters.AddWithValue("product_key", entity.ProductKey);
|
||||
cmd.Parameters.AddWithValue("vex_status", entity.VexStatus ?? (object)DBNull.Value);
|
||||
|
||||
cmd.Parameters.AddWithValue("origin_valid", entity.OriginValid);
|
||||
cmd.Parameters.AddWithValue("origin_method", entity.OriginMethod);
|
||||
cmd.Parameters.AddWithValue("origin_key_id", entity.OriginKeyId ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("origin_issuer_id", entity.OriginIssuerId ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("origin_issuer_name", entity.OriginIssuerName ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("origin_rekor_log_index", entity.OriginRekorLogIndex ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("origin_score", entity.OriginScore);
|
||||
|
||||
cmd.Parameters.AddWithValue("freshness_status", entity.FreshnessStatus);
|
||||
cmd.Parameters.AddWithValue("freshness_issued_at", entity.FreshnessIssuedAt);
|
||||
cmd.Parameters.AddWithValue("freshness_expires_at", entity.FreshnessExpiresAt ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("freshness_superseded_by", entity.FreshnessSupersededBy ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("freshness_age_days", entity.FreshnessAgeDays);
|
||||
cmd.Parameters.AddWithValue("freshness_score", entity.FreshnessScore);
|
||||
|
||||
cmd.Parameters.AddWithValue("reputation_composite", entity.ReputationComposite);
|
||||
cmd.Parameters.AddWithValue("reputation_authority", entity.ReputationAuthority);
|
||||
cmd.Parameters.AddWithValue("reputation_accuracy", entity.ReputationAccuracy);
|
||||
cmd.Parameters.AddWithValue("reputation_timeliness", entity.ReputationTimeliness);
|
||||
cmd.Parameters.AddWithValue("reputation_coverage", entity.ReputationCoverage);
|
||||
cmd.Parameters.AddWithValue("reputation_verification", entity.ReputationVerification);
|
||||
cmd.Parameters.AddWithValue("reputation_sample_count", entity.ReputationSampleCount);
|
||||
|
||||
cmd.Parameters.AddWithValue("trust_score", entity.TrustScore);
|
||||
cmd.Parameters.AddWithValue("trust_tier", entity.TrustTier);
|
||||
cmd.Parameters.AddWithValue("trust_formula", entity.TrustFormula);
|
||||
cmd.Parameters.AddWithValue("trust_reasons", entity.TrustReasons.ToArray());
|
||||
cmd.Parameters.AddWithValue("meets_policy_threshold", entity.MeetsPolicyThreshold ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("policy_threshold", entity.PolicyThreshold ?? (object)DBNull.Value);
|
||||
|
||||
cmd.Parameters.AddWithValue("evidence_merkle_root", entity.EvidenceMerkleRoot);
|
||||
cmd.Parameters.AddWithValue("evidence_items_json", JsonSerializer.Serialize(entity.EvidenceItems, _jsonOptions));
|
||||
|
||||
cmd.Parameters.AddWithValue("envelope_base64", entity.EnvelopeBase64 ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("verdict_digest", entity.VerdictDigest);
|
||||
|
||||
cmd.Parameters.AddWithValue("evaluated_at", entity.EvaluatedAt);
|
||||
cmd.Parameters.AddWithValue("evaluator_version", entity.EvaluatorVersion);
|
||||
cmd.Parameters.AddWithValue("crypto_profile", entity.CryptoProfile);
|
||||
cmd.Parameters.AddWithValue("policy_digest", entity.PolicyDigest ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("environment", entity.Environment ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("correlation_id", entity.CorrelationId ?? (object)DBNull.Value);
|
||||
|
||||
cmd.Parameters.AddWithValue("oci_digest", entity.OciDigest ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("rekor_log_index", entity.RekorLogIndex ?? (object)DBNull.Value);
|
||||
|
||||
cmd.Parameters.AddWithValue("created_at", entity.CreatedAt);
|
||||
cmd.Parameters.AddWithValue("expires_at", entity.ExpiresAt ?? (object)DBNull.Value);
|
||||
}
|
||||
|
||||
private TrustVerdictEntity ReadEntity(NpgsqlDataReader reader)
|
||||
{
|
||||
var evidenceJson = reader.GetString(reader.GetOrdinal("evidence_items_json"));
|
||||
var evidenceItems = JsonSerializer.Deserialize<List<TrustEvidenceItem>>(evidenceJson, _jsonOptions) ?? [];
|
||||
|
||||
return new TrustVerdictEntity
|
||||
{
|
||||
VerdictId = reader.GetString(reader.GetOrdinal("verdict_id")),
|
||||
TenantId = reader.GetGuid(reader.GetOrdinal("tenant_id")),
|
||||
|
||||
VexDigest = reader.GetString(reader.GetOrdinal("vex_digest")),
|
||||
VexFormat = reader.GetString(reader.GetOrdinal("vex_format")),
|
||||
ProviderId = reader.GetString(reader.GetOrdinal("provider_id")),
|
||||
StatementId = reader.GetString(reader.GetOrdinal("statement_id")),
|
||||
VulnerabilityId = reader.GetString(reader.GetOrdinal("vulnerability_id")),
|
||||
ProductKey = reader.GetString(reader.GetOrdinal("product_key")),
|
||||
VexStatus = reader.IsDBNull(reader.GetOrdinal("vex_status")) ? null : reader.GetString(reader.GetOrdinal("vex_status")),
|
||||
|
||||
OriginValid = reader.GetBoolean(reader.GetOrdinal("origin_valid")),
|
||||
OriginMethod = reader.GetString(reader.GetOrdinal("origin_method")),
|
||||
OriginKeyId = reader.IsDBNull(reader.GetOrdinal("origin_key_id")) ? null : reader.GetString(reader.GetOrdinal("origin_key_id")),
|
||||
OriginIssuerId = reader.IsDBNull(reader.GetOrdinal("origin_issuer_id")) ? null : reader.GetString(reader.GetOrdinal("origin_issuer_id")),
|
||||
OriginIssuerName = reader.IsDBNull(reader.GetOrdinal("origin_issuer_name")) ? null : reader.GetString(reader.GetOrdinal("origin_issuer_name")),
|
||||
OriginRekorLogIndex = reader.IsDBNull(reader.GetOrdinal("origin_rekor_log_index")) ? null : reader.GetInt64(reader.GetOrdinal("origin_rekor_log_index")),
|
||||
OriginScore = reader.GetDecimal(reader.GetOrdinal("origin_score")),
|
||||
|
||||
FreshnessStatus = reader.GetString(reader.GetOrdinal("freshness_status")),
|
||||
FreshnessIssuedAt = reader.GetDateTime(reader.GetOrdinal("freshness_issued_at")),
|
||||
FreshnessExpiresAt = reader.IsDBNull(reader.GetOrdinal("freshness_expires_at")) ? null : reader.GetDateTime(reader.GetOrdinal("freshness_expires_at")),
|
||||
FreshnessSupersededBy = reader.IsDBNull(reader.GetOrdinal("freshness_superseded_by")) ? null : reader.GetString(reader.GetOrdinal("freshness_superseded_by")),
|
||||
FreshnessAgeDays = reader.GetInt32(reader.GetOrdinal("freshness_age_days")),
|
||||
FreshnessScore = reader.GetDecimal(reader.GetOrdinal("freshness_score")),
|
||||
|
||||
ReputationComposite = reader.GetDecimal(reader.GetOrdinal("reputation_composite")),
|
||||
ReputationAuthority = reader.GetDecimal(reader.GetOrdinal("reputation_authority")),
|
||||
ReputationAccuracy = reader.GetDecimal(reader.GetOrdinal("reputation_accuracy")),
|
||||
ReputationTimeliness = reader.GetDecimal(reader.GetOrdinal("reputation_timeliness")),
|
||||
ReputationCoverage = reader.GetDecimal(reader.GetOrdinal("reputation_coverage")),
|
||||
ReputationVerification = reader.GetDecimal(reader.GetOrdinal("reputation_verification")),
|
||||
ReputationSampleCount = reader.GetInt32(reader.GetOrdinal("reputation_sample_count")),
|
||||
|
||||
TrustScore = reader.GetDecimal(reader.GetOrdinal("trust_score")),
|
||||
TrustTier = reader.GetString(reader.GetOrdinal("trust_tier")),
|
||||
TrustFormula = reader.GetString(reader.GetOrdinal("trust_formula")),
|
||||
TrustReasons = reader.GetFieldValue<string[]>(reader.GetOrdinal("trust_reasons")).ToList(),
|
||||
MeetsPolicyThreshold = reader.IsDBNull(reader.GetOrdinal("meets_policy_threshold")) ? null : reader.GetBoolean(reader.GetOrdinal("meets_policy_threshold")),
|
||||
PolicyThreshold = reader.IsDBNull(reader.GetOrdinal("policy_threshold")) ? null : reader.GetDecimal(reader.GetOrdinal("policy_threshold")),
|
||||
|
||||
EvidenceMerkleRoot = reader.GetString(reader.GetOrdinal("evidence_merkle_root")),
|
||||
EvidenceItems = evidenceItems,
|
||||
|
||||
EnvelopeBase64 = reader.IsDBNull(reader.GetOrdinal("envelope_base64")) ? null : reader.GetString(reader.GetOrdinal("envelope_base64")),
|
||||
VerdictDigest = reader.GetString(reader.GetOrdinal("verdict_digest")),
|
||||
|
||||
EvaluatedAt = reader.GetDateTime(reader.GetOrdinal("evaluated_at")),
|
||||
EvaluatorVersion = reader.GetString(reader.GetOrdinal("evaluator_version")),
|
||||
CryptoProfile = reader.GetString(reader.GetOrdinal("crypto_profile")),
|
||||
PolicyDigest = reader.IsDBNull(reader.GetOrdinal("policy_digest")) ? null : reader.GetString(reader.GetOrdinal("policy_digest")),
|
||||
Environment = reader.IsDBNull(reader.GetOrdinal("environment")) ? null : reader.GetString(reader.GetOrdinal("environment")),
|
||||
CorrelationId = reader.IsDBNull(reader.GetOrdinal("correlation_id")) ? null : reader.GetString(reader.GetOrdinal("correlation_id")),
|
||||
|
||||
OciDigest = reader.IsDBNull(reader.GetOrdinal("oci_digest")) ? null : reader.GetString(reader.GetOrdinal("oci_digest")),
|
||||
RekorLogIndex = reader.IsDBNull(reader.GetOrdinal("rekor_log_index")) ? null : reader.GetInt64(reader.GetOrdinal("rekor_log_index")),
|
||||
|
||||
CreatedAt = reader.GetDateTime(reader.GetOrdinal("created_at")),
|
||||
ExpiresAt = reader.IsDBNull(reader.GetOrdinal("expires_at")) ? null : reader.GetDateTime(reader.GetOrdinal("expires_at"))
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
// TrustVerdictPredicate - in-toto predicate for VEX trust verification results
|
||||
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.TrustVerdict.Predicates;
|
||||
|
||||
/// <summary>
|
||||
/// in-toto predicate for VEX trust verification results.
|
||||
/// This predicate captures the complete trust evaluation of a VEX document,
|
||||
/// including origin verification, freshness, reputation, and evidence chain.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Predicate type URI: "https://stellaops.dev/predicates/trust-verdict@v1"
|
||||
///
|
||||
/// Design principles:
|
||||
/// - Deterministic: Same inputs always produce identical predicates
|
||||
/// - Auditable: Complete evidence chain for replay
|
||||
/// - Self-contained: All context needed for verification
|
||||
/// </remarks>
|
||||
public sealed record TrustVerdictPredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// Official predicate type URI for TrustVerdict.
|
||||
/// </summary>
|
||||
public const string PredicateType = "https://stellaops.dev/predicates/trust-verdict@v1";
|
||||
|
||||
/// <summary>
|
||||
/// Schema version for forward compatibility.
|
||||
/// </summary>
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; init; } = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// VEX document being verified.
|
||||
/// </summary>
|
||||
[JsonPropertyName("subject")]
|
||||
public required TrustVerdictSubject Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Origin (signature) verification result.
|
||||
/// </summary>
|
||||
[JsonPropertyName("origin")]
|
||||
public required OriginVerification Origin { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Freshness evaluation result.
|
||||
/// </summary>
|
||||
[JsonPropertyName("freshness")]
|
||||
public required FreshnessEvaluation Freshness { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reputation score and breakdown.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reputation")]
|
||||
public required ReputationScore Reputation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Composite trust score and tier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("composite")]
|
||||
public required TrustComposite Composite { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence chain for audit.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidence")]
|
||||
public required TrustEvidenceChain Evidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evaluation metadata.
|
||||
/// </summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
public required TrustEvaluationMetadata Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject of the trust verdict - the VEX document being evaluated.
|
||||
/// </summary>
|
||||
public sealed record TrustVerdictSubject
|
||||
{
|
||||
/// <summary>
|
||||
/// Content-addressable digest of the VEX document (sha256:...).
|
||||
/// </summary>
|
||||
[JsonPropertyName("vexDigest")]
|
||||
public required string VexDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Format of the VEX document (openvex, csaf, cyclonedx).
|
||||
/// </summary>
|
||||
[JsonPropertyName("vexFormat")]
|
||||
public required string VexFormat { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Provider/issuer identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("providerId")]
|
||||
public required string ProviderId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Statement identifier within the VEX document.
|
||||
/// </summary>
|
||||
[JsonPropertyName("statementId")]
|
||||
public required string StatementId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE or vulnerability identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Product/component key (PURL or similar).
|
||||
/// </summary>
|
||||
[JsonPropertyName("productKey")]
|
||||
public required string ProductKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX status being asserted (not_affected, fixed, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("vexStatus")]
|
||||
public string? VexStatus { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of origin/signature verification.
|
||||
/// </summary>
|
||||
public sealed record OriginVerification
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the signature was successfully verified.
|
||||
/// </summary>
|
||||
[JsonPropertyName("valid")]
|
||||
public required bool Valid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verification method used (dsse, cosign, pgp, x509, keyless).
|
||||
/// </summary>
|
||||
[JsonPropertyName("method")]
|
||||
public required string Method { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key identifier used for verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("keyId")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Issuer display name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("issuerName")]
|
||||
public string? IssuerName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Issuer canonical identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("issuerId")]
|
||||
public string? IssuerId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Certificate subject (for X.509/keyless).
|
||||
/// </summary>
|
||||
[JsonPropertyName("certSubject")]
|
||||
public string? CertSubject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Certificate fingerprint (for X.509/keyless).
|
||||
/// </summary>
|
||||
[JsonPropertyName("certFingerprint")]
|
||||
public string? CertFingerprint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// OIDC issuer for keyless signing.
|
||||
/// </summary>
|
||||
[JsonPropertyName("oidcIssuer")]
|
||||
public string? OidcIssuer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log index if transparency was verified.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rekorLogIndex")]
|
||||
public long? RekorLogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rekorLogId")]
|
||||
public string? RekorLogId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for verification failure (if valid=false).
|
||||
/// </summary>
|
||||
[JsonPropertyName("failureReason")]
|
||||
public string? FailureReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Origin verification score (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("score")]
|
||||
public decimal Score { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Freshness evaluation result.
|
||||
/// </summary>
|
||||
public sealed record FreshnessEvaluation
|
||||
{
|
||||
/// <summary>
|
||||
/// Freshness status (fresh, stale, superseded, expired).
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the VEX statement was issued.
|
||||
/// </summary>
|
||||
[JsonPropertyName("issuedAt")]
|
||||
public required DateTimeOffset IssuedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the VEX statement expires (if any).
|
||||
/// </summary>
|
||||
[JsonPropertyName("expiresAt")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Identifier of superseding VEX (if superseded).
|
||||
/// </summary>
|
||||
[JsonPropertyName("supersededBy")]
|
||||
public string? SupersededBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Age in days at evaluation time.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ageInDays")]
|
||||
public int AgeInDays { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Freshness score (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("score")]
|
||||
public required decimal Score { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reputation score breakdown.
|
||||
/// </summary>
|
||||
public sealed record ReputationScore
|
||||
{
|
||||
/// <summary>
|
||||
/// Composite reputation score (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("composite")]
|
||||
public required decimal Composite { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Authority factor (issuer trust level).
|
||||
/// </summary>
|
||||
[JsonPropertyName("authority")]
|
||||
public required decimal Authority { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Accuracy factor (historical correctness).
|
||||
/// </summary>
|
||||
[JsonPropertyName("accuracy")]
|
||||
public required decimal Accuracy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timeliness factor (response speed to vulnerabilities).
|
||||
/// </summary>
|
||||
[JsonPropertyName("timeliness")]
|
||||
public required decimal Timeliness { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Coverage factor (product/ecosystem coverage).
|
||||
/// </summary>
|
||||
[JsonPropertyName("coverage")]
|
||||
public required decimal Coverage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verification factor (signing practices).
|
||||
/// </summary>
|
||||
[JsonPropertyName("verification")]
|
||||
public required decimal Verification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the reputation was computed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("computedAt")]
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of historical samples used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sampleCount")]
|
||||
public int SampleCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composite trust score and classification.
|
||||
/// </summary>
|
||||
public sealed record TrustComposite
|
||||
{
|
||||
/// <summary>
|
||||
/// Final trust score (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("score")]
|
||||
public required decimal Score { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trust tier classification (VeryHigh, High, Medium, Low, VeryLow).
|
||||
/// </summary>
|
||||
[JsonPropertyName("tier")]
|
||||
public required string Tier { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable reasons contributing to the score.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reasons")]
|
||||
public required IReadOnlyList<string> Reasons { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Formula used for computation (for transparency).
|
||||
/// </summary>
|
||||
[JsonPropertyName("formula")]
|
||||
public required string Formula { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the score meets the policy threshold.
|
||||
/// </summary>
|
||||
[JsonPropertyName("meetsPolicyThreshold")]
|
||||
public bool MeetsPolicyThreshold { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy threshold applied.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyThreshold")]
|
||||
public decimal? PolicyThreshold { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence chain for audit and replay.
|
||||
/// </summary>
|
||||
public sealed record TrustEvidenceChain
|
||||
{
|
||||
/// <summary>
|
||||
/// Merkle root hash of the evidence items.
|
||||
/// </summary>
|
||||
[JsonPropertyName("merkleRoot")]
|
||||
public required string MerkleRoot { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Individual evidence items.
|
||||
/// </summary>
|
||||
[JsonPropertyName("items")]
|
||||
public required IReadOnlyList<TrustEvidenceItem> Items { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single evidence item in the chain.
|
||||
/// </summary>
|
||||
public sealed record TrustEvidenceItem
|
||||
{
|
||||
/// <summary>
|
||||
/// Type of evidence (signature, certificate, rekor_entry, issuer_profile, vex_document).
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressable digest of the evidence.
|
||||
/// </summary>
|
||||
[JsonPropertyName("digest")]
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URI to retrieve the evidence (if available).
|
||||
/// </summary>
|
||||
[JsonPropertyName("uri")]
|
||||
public string? Uri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the evidence was collected.
|
||||
/// </summary>
|
||||
[JsonPropertyName("collectedAt")]
|
||||
public DateTimeOffset? CollectedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata about the trust evaluation.
|
||||
/// </summary>
|
||||
public sealed record TrustEvaluationMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// When the evaluation was performed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evaluatedAt")]
|
||||
public required DateTimeOffset EvaluatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the evaluator component.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evaluatorVersion")]
|
||||
public required string EvaluatorVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Crypto profile used (world, fips, gost, sm, eidas).
|
||||
/// </summary>
|
||||
[JsonPropertyName("cryptoProfile")]
|
||||
public required string CryptoProfile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tenantId")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the policy bundle applied.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyDigest")]
|
||||
public string? PolicyDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Environment context (production, staging, development).
|
||||
/// </summary>
|
||||
[JsonPropertyName("environment")]
|
||||
public string? Environment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Correlation ID for tracing.
|
||||
/// </summary>
|
||||
[JsonPropertyName("correlationId")]
|
||||
public string? CorrelationId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Well-known evidence types.
|
||||
/// </summary>
|
||||
public static class TrustEvidenceTypes
|
||||
{
|
||||
public const string VexDocument = "vex_document";
|
||||
public const string Signature = "signature";
|
||||
public const string Certificate = "certificate";
|
||||
public const string RekorEntry = "rekor_entry";
|
||||
public const string IssuerProfile = "issuer_profile";
|
||||
public const string IssuerKey = "issuer_key";
|
||||
public const string PolicyBundle = "policy_bundle";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Well-known trust tiers.
|
||||
/// </summary>
|
||||
public static class TrustTiers
|
||||
{
|
||||
public const string VeryHigh = "VeryHigh";
|
||||
public const string High = "High";
|
||||
public const string Medium = "Medium";
|
||||
public const string Low = "Low";
|
||||
public const string VeryLow = "VeryLow";
|
||||
|
||||
public static string FromScore(decimal score) => score switch
|
||||
{
|
||||
>= 0.9m => VeryHigh,
|
||||
>= 0.7m => High,
|
||||
>= 0.5m => Medium,
|
||||
>= 0.3m => Low,
|
||||
_ => VeryLow
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Well-known freshness statuses.
|
||||
/// </summary>
|
||||
public static class FreshnessStatuses
|
||||
{
|
||||
public const string Fresh = "fresh";
|
||||
public const string Stale = "stale";
|
||||
public const string Superseded = "superseded";
|
||||
public const string Expired = "expired";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Well-known verification methods.
|
||||
/// </summary>
|
||||
public static class VerificationMethods
|
||||
{
|
||||
public const string Dsse = "dsse";
|
||||
public const string DsseKeyless = "dsse_keyless";
|
||||
public const string Cosign = "cosign";
|
||||
public const string CosignKeyless = "cosign_keyless";
|
||||
public const string Pgp = "pgp";
|
||||
public const string X509 = "x509";
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
// TrustVerdictService - Service for generating signed TrustVerdict attestations
|
||||
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.StandardPredicates;
|
||||
using StellaOps.Attestor.TrustVerdict.Predicates;
|
||||
|
||||
namespace StellaOps.Attestor.TrustVerdict.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for generating and verifying signed TrustVerdict attestations.
|
||||
/// </summary>
|
||||
public interface ITrustVerdictService
|
||||
{
|
||||
/// <summary>
|
||||
/// Generate a signed TrustVerdict for a VEX document.
|
||||
/// </summary>
|
||||
/// <param name="request">The verdict generation request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The verdict result with signed envelope.</returns>
|
||||
Task<TrustVerdictResult> GenerateVerdictAsync(
|
||||
TrustVerdictRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Batch generation for performance.
|
||||
/// </summary>
|
||||
/// <param name="requests">Multiple verdict requests.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Results for each request.</returns>
|
||||
Task<IReadOnlyList<TrustVerdictResult>> GenerateBatchAsync(
|
||||
IEnumerable<TrustVerdictRequest> requests,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Compute deterministic verdict digest without signing.
|
||||
/// Used for cache lookups.
|
||||
/// </summary>
|
||||
string ComputeVerdictDigest(TrustVerdictPredicate predicate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for generating a TrustVerdict.
|
||||
/// </summary>
|
||||
public sealed record TrustVerdictRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// VEX document digest (sha256:...).
|
||||
/// </summary>
|
||||
public required string VexDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX document format (openvex, csaf, cyclonedx).
|
||||
/// </summary>
|
||||
public required string VexFormat { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Provider/issuer identifier.
|
||||
/// </summary>
|
||||
public required string ProviderId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Statement identifier.
|
||||
/// </summary>
|
||||
public required string StatementId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability identifier.
|
||||
/// </summary>
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Product key (PURL or similar).
|
||||
/// </summary>
|
||||
public required string ProductKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX status (not_affected, fixed, etc.).
|
||||
/// </summary>
|
||||
public string? VexStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Origin verification result.
|
||||
/// </summary>
|
||||
public required TrustVerdictOriginInput Origin { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Freshness evaluation input.
|
||||
/// </summary>
|
||||
public required TrustVerdictFreshnessInput Freshness { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reputation score input.
|
||||
/// </summary>
|
||||
public required TrustVerdictReputationInput Reputation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence items collected.
|
||||
/// </summary>
|
||||
public IReadOnlyList<TrustVerdictEvidenceInput> EvidenceItems { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Options for verdict generation.
|
||||
/// </summary>
|
||||
public required TrustVerdictOptions Options { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Origin verification input.
|
||||
/// </summary>
|
||||
public sealed record TrustVerdictOriginInput
|
||||
{
|
||||
public required bool Valid { get; init; }
|
||||
public required string Method { get; init; }
|
||||
public string? KeyId { get; init; }
|
||||
public string? IssuerName { get; init; }
|
||||
public string? IssuerId { get; init; }
|
||||
public string? CertSubject { get; init; }
|
||||
public string? CertFingerprint { get; init; }
|
||||
public string? OidcIssuer { get; init; }
|
||||
public long? RekorLogIndex { get; init; }
|
||||
public string? RekorLogId { get; init; }
|
||||
public string? FailureReason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Freshness evaluation input.
|
||||
/// </summary>
|
||||
public sealed record TrustVerdictFreshnessInput
|
||||
{
|
||||
public required string Status { get; init; }
|
||||
public required DateTimeOffset IssuedAt { get; init; }
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
public string? SupersededBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reputation score input.
|
||||
/// </summary>
|
||||
public sealed record TrustVerdictReputationInput
|
||||
{
|
||||
public required decimal Authority { get; init; }
|
||||
public required decimal Accuracy { get; init; }
|
||||
public required decimal Timeliness { get; init; }
|
||||
public required decimal Coverage { get; init; }
|
||||
public required decimal Verification { get; init; }
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
public int SampleCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence item input.
|
||||
/// </summary>
|
||||
public sealed record TrustVerdictEvidenceInput
|
||||
{
|
||||
public required string Type { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
public string? Uri { get; init; }
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for verdict generation.
|
||||
/// </summary>
|
||||
public sealed record TrustVerdictOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Crypto profile (world, fips, gost, sm, eidas).
|
||||
/// </summary>
|
||||
public required string CryptoProfile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Environment (production, staging, development).
|
||||
/// </summary>
|
||||
public string? Environment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy digest applied.
|
||||
/// </summary>
|
||||
public string? PolicyDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy threshold for this context.
|
||||
/// </summary>
|
||||
public decimal? PolicyThreshold { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Correlation ID for tracing.
|
||||
/// </summary>
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to attach to OCI registry.
|
||||
/// </summary>
|
||||
public bool AttachToOci { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// OCI reference for attachment.
|
||||
/// </summary>
|
||||
public string? OciReference { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to publish to Rekor.
|
||||
/// </summary>
|
||||
public bool PublishToRekor { get; init; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of verdict generation.
|
||||
/// </summary>
|
||||
public sealed record TrustVerdictResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether generation succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The generated predicate.
|
||||
/// </summary>
|
||||
public TrustVerdictPredicate? Predicate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic digest of the verdict.
|
||||
/// </summary>
|
||||
public string? VerdictDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signed DSSE envelope (base64 encoded).
|
||||
/// </summary>
|
||||
public string? EnvelopeBase64 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// OCI digest if attached.
|
||||
/// </summary>
|
||||
public string? OciDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log index if published.
|
||||
/// </summary>
|
||||
public long? RekorLogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Processing duration.
|
||||
/// </summary>
|
||||
public TimeSpan Duration { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of ITrustVerdictService.
|
||||
/// </summary>
|
||||
public sealed class TrustVerdictService : ITrustVerdictService
|
||||
{
|
||||
private readonly IOptionsMonitor<TrustVerdictServiceOptions> _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<TrustVerdictService> _logger;
|
||||
|
||||
// Standard formula for trust composite calculation
|
||||
private const string DefaultFormula = "0.50*Origin + 0.30*Freshness + 0.20*Reputation";
|
||||
|
||||
public TrustVerdictService(
|
||||
IOptionsMonitor<TrustVerdictServiceOptions> options,
|
||||
ILogger<TrustVerdictService> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TrustVerdictResult> GenerateVerdictAsync(
|
||||
TrustVerdictRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var startTime = _timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Build predicate
|
||||
var predicate = BuildPredicate(request, startTime);
|
||||
|
||||
// 2. Compute deterministic verdict digest
|
||||
var verdictDigest = ComputeVerdictDigest(predicate);
|
||||
|
||||
// Note: Actual DSSE signing would happen here via IDsseSigner
|
||||
// For this implementation, we return the predicate ready for signing
|
||||
|
||||
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||
|
||||
_logger.LogDebug(
|
||||
"Generated TrustVerdict for {VexDigest} with score {Score} in {Duration}ms",
|
||||
request.VexDigest,
|
||||
predicate.Composite.Score,
|
||||
duration.TotalMilliseconds);
|
||||
|
||||
return Task.FromResult(new TrustVerdictResult
|
||||
{
|
||||
Success = true,
|
||||
Predicate = predicate,
|
||||
VerdictDigest = verdictDigest,
|
||||
Duration = duration
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to generate TrustVerdict for {VexDigest}", request.VexDigest);
|
||||
|
||||
return Task.FromResult(new TrustVerdictResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = ex.Message,
|
||||
Duration = _timeProvider.GetUtcNow() - startTime
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TrustVerdictResult>> GenerateBatchAsync(
|
||||
IEnumerable<TrustVerdictRequest> requests,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var results = new List<TrustVerdictResult>();
|
||||
|
||||
foreach (var request in requests)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var result = await GenerateVerdictAsync(request, ct);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ComputeVerdictDigest(TrustVerdictPredicate predicate)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(predicate);
|
||||
|
||||
// Use canonical JSON serialization for determinism
|
||||
var canonical = JsonCanonicalizer.Canonicalize(predicate);
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical));
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
private TrustVerdictPredicate BuildPredicate(
|
||||
TrustVerdictRequest request,
|
||||
DateTimeOffset evaluatedAt)
|
||||
{
|
||||
var options = _options.CurrentValue;
|
||||
|
||||
// Build subject
|
||||
var subject = new TrustVerdictSubject
|
||||
{
|
||||
VexDigest = request.VexDigest,
|
||||
VexFormat = request.VexFormat,
|
||||
ProviderId = request.ProviderId,
|
||||
StatementId = request.StatementId,
|
||||
VulnerabilityId = request.VulnerabilityId,
|
||||
ProductKey = request.ProductKey,
|
||||
VexStatus = request.VexStatus
|
||||
};
|
||||
|
||||
// Build origin verification
|
||||
var originScore = request.Origin.Valid ? 1.0m : 0.0m;
|
||||
var origin = new OriginVerification
|
||||
{
|
||||
Valid = request.Origin.Valid,
|
||||
Method = request.Origin.Method,
|
||||
KeyId = request.Origin.KeyId,
|
||||
IssuerName = request.Origin.IssuerName,
|
||||
IssuerId = request.Origin.IssuerId,
|
||||
CertSubject = request.Origin.CertSubject,
|
||||
CertFingerprint = request.Origin.CertFingerprint,
|
||||
OidcIssuer = request.Origin.OidcIssuer,
|
||||
RekorLogIndex = request.Origin.RekorLogIndex,
|
||||
RekorLogId = request.Origin.RekorLogId,
|
||||
FailureReason = request.Origin.FailureReason,
|
||||
Score = originScore
|
||||
};
|
||||
|
||||
// Build freshness evaluation
|
||||
var ageInDays = (int)(evaluatedAt - request.Freshness.IssuedAt).TotalDays;
|
||||
var freshnessScore = ComputeFreshnessScore(request.Freshness.Status, ageInDays);
|
||||
var freshness = new FreshnessEvaluation
|
||||
{
|
||||
Status = request.Freshness.Status,
|
||||
IssuedAt = request.Freshness.IssuedAt,
|
||||
ExpiresAt = request.Freshness.ExpiresAt,
|
||||
SupersededBy = request.Freshness.SupersededBy,
|
||||
AgeInDays = ageInDays,
|
||||
Score = freshnessScore
|
||||
};
|
||||
|
||||
// Build reputation score
|
||||
var reputationComposite = ComputeReputationComposite(request.Reputation);
|
||||
var reputation = new ReputationScore
|
||||
{
|
||||
Composite = reputationComposite,
|
||||
Authority = request.Reputation.Authority,
|
||||
Accuracy = request.Reputation.Accuracy,
|
||||
Timeliness = request.Reputation.Timeliness,
|
||||
Coverage = request.Reputation.Coverage,
|
||||
Verification = request.Reputation.Verification,
|
||||
ComputedAt = request.Reputation.ComputedAt,
|
||||
SampleCount = request.Reputation.SampleCount
|
||||
};
|
||||
|
||||
// Compute composite trust score
|
||||
var compositeScore = ComputeCompositeScore(originScore, freshnessScore, reputationComposite);
|
||||
var meetsPolicyThreshold = request.Options.PolicyThreshold.HasValue
|
||||
&& compositeScore >= request.Options.PolicyThreshold.Value;
|
||||
|
||||
var reasons = BuildReasons(origin, freshness, reputation, compositeScore);
|
||||
|
||||
var composite = new TrustComposite
|
||||
{
|
||||
Score = compositeScore,
|
||||
Tier = TrustTiers.FromScore(compositeScore),
|
||||
Reasons = reasons,
|
||||
Formula = DefaultFormula,
|
||||
MeetsPolicyThreshold = meetsPolicyThreshold,
|
||||
PolicyThreshold = request.Options.PolicyThreshold
|
||||
};
|
||||
|
||||
// Build evidence chain
|
||||
var evidenceItems = request.EvidenceItems
|
||||
.OrderBy(e => e.Digest, StringComparer.Ordinal)
|
||||
.Select(e => new TrustEvidenceItem
|
||||
{
|
||||
Type = e.Type,
|
||||
Digest = e.Digest,
|
||||
Uri = e.Uri,
|
||||
Description = e.Description,
|
||||
CollectedAt = evaluatedAt
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var merkleRoot = ComputeMerkleRoot(evidenceItems);
|
||||
|
||||
var evidence = new TrustEvidenceChain
|
||||
{
|
||||
MerkleRoot = merkleRoot,
|
||||
Items = evidenceItems
|
||||
};
|
||||
|
||||
// Build metadata
|
||||
var metadata = new TrustEvaluationMetadata
|
||||
{
|
||||
EvaluatedAt = evaluatedAt,
|
||||
EvaluatorVersion = options.EvaluatorVersion,
|
||||
CryptoProfile = request.Options.CryptoProfile,
|
||||
TenantId = request.Options.TenantId,
|
||||
PolicyDigest = request.Options.PolicyDigest,
|
||||
Environment = request.Options.Environment,
|
||||
CorrelationId = request.Options.CorrelationId
|
||||
};
|
||||
|
||||
return new TrustVerdictPredicate
|
||||
{
|
||||
SchemaVersion = "1.0.0",
|
||||
Subject = subject,
|
||||
Origin = origin,
|
||||
Freshness = freshness,
|
||||
Reputation = reputation,
|
||||
Composite = composite,
|
||||
Evidence = evidence,
|
||||
Metadata = metadata
|
||||
};
|
||||
}
|
||||
|
||||
private static decimal ComputeFreshnessScore(string status, int ageInDays)
|
||||
{
|
||||
// Base score from status
|
||||
var baseScore = status.ToLowerInvariant() switch
|
||||
{
|
||||
FreshnessStatuses.Fresh => 1.0m,
|
||||
FreshnessStatuses.Stale => 0.6m,
|
||||
FreshnessStatuses.Superseded => 0.3m,
|
||||
FreshnessStatuses.Expired => 0.1m,
|
||||
_ => 0.5m
|
||||
};
|
||||
|
||||
// Decay based on age (90-day half-life)
|
||||
if (ageInDays > 0)
|
||||
{
|
||||
var decay = (decimal)Math.Exp(-ageInDays / 90.0);
|
||||
baseScore = Math.Max(0.1m, baseScore * decay);
|
||||
}
|
||||
|
||||
return Math.Round(baseScore, 3);
|
||||
}
|
||||
|
||||
private static decimal ComputeReputationComposite(TrustVerdictReputationInput input)
|
||||
{
|
||||
// Weighted average of reputation factors
|
||||
var composite =
|
||||
input.Authority * 0.25m +
|
||||
input.Accuracy * 0.30m +
|
||||
input.Timeliness * 0.15m +
|
||||
input.Coverage * 0.15m +
|
||||
input.Verification * 0.15m;
|
||||
|
||||
return Math.Clamp(Math.Round(composite, 3), 0m, 1m);
|
||||
}
|
||||
|
||||
private static decimal ComputeCompositeScore(
|
||||
decimal originScore,
|
||||
decimal freshnessScore,
|
||||
decimal reputationScore)
|
||||
{
|
||||
// Formula: 0.50*Origin + 0.30*Freshness + 0.20*Reputation
|
||||
var composite =
|
||||
originScore * 0.50m +
|
||||
freshnessScore * 0.30m +
|
||||
reputationScore * 0.20m;
|
||||
|
||||
return Math.Clamp(Math.Round(composite, 3), 0m, 1m);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildReasons(
|
||||
OriginVerification origin,
|
||||
FreshnessEvaluation freshness,
|
||||
ReputationScore reputation,
|
||||
decimal compositeScore)
|
||||
{
|
||||
var reasons = new List<string>();
|
||||
|
||||
// Origin reason
|
||||
if (origin.Valid)
|
||||
{
|
||||
reasons.Add($"Signature verified via {origin.Method}");
|
||||
if (origin.RekorLogIndex.HasValue)
|
||||
{
|
||||
reasons.Add($"Logged in transparency log (Rekor #{origin.RekorLogIndex})");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
reasons.Add($"Signature not verified: {origin.FailureReason ?? "unknown"}");
|
||||
}
|
||||
|
||||
// Freshness reason
|
||||
reasons.Add($"VEX freshness: {freshness.Status} ({freshness.AgeInDays} days old)");
|
||||
|
||||
// Reputation reason
|
||||
reasons.Add($"Issuer reputation: {reputation.Composite:P0} ({reputation.SampleCount} samples)");
|
||||
|
||||
// Composite summary
|
||||
var tier = TrustTiers.FromScore(compositeScore);
|
||||
reasons.Add($"Overall trust: {tier} ({compositeScore:P0})");
|
||||
|
||||
return reasons;
|
||||
}
|
||||
|
||||
private static string ComputeMerkleRoot(IReadOnlyList<TrustEvidenceItem> items)
|
||||
{
|
||||
if (items.Count == 0)
|
||||
{
|
||||
return "sha256:" + Convert.ToHexStringLower(SHA256.HashData([]));
|
||||
}
|
||||
|
||||
// Get leaf hashes
|
||||
var hashes = items
|
||||
.Select(i => SHA256.HashData(Encoding.UTF8.GetBytes(i.Digest)))
|
||||
.ToList();
|
||||
|
||||
// Build tree bottom-up
|
||||
while (hashes.Count > 1)
|
||||
{
|
||||
var newLevel = new List<byte[]>();
|
||||
|
||||
for (var i = 0; i < hashes.Count; i += 2)
|
||||
{
|
||||
if (i + 1 < hashes.Count)
|
||||
{
|
||||
// Combine two nodes
|
||||
var combined = new byte[hashes[i].Length + hashes[i + 1].Length];
|
||||
hashes[i].CopyTo(combined, 0);
|
||||
hashes[i + 1].CopyTo(combined, hashes[i].Length);
|
||||
newLevel.Add(SHA256.HashData(combined));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Odd node, promote as-is
|
||||
newLevel.Add(hashes[i]);
|
||||
}
|
||||
}
|
||||
|
||||
hashes = newLevel;
|
||||
}
|
||||
|
||||
return $"sha256:{Convert.ToHexStringLower(hashes[0])}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for TrustVerdictService.
|
||||
/// </summary>
|
||||
public sealed class TrustVerdictServiceOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section key.
|
||||
/// </summary>
|
||||
public const string SectionKey = "TrustVerdict";
|
||||
|
||||
/// <summary>
|
||||
/// Evaluator version string.
|
||||
/// </summary>
|
||||
public string EvaluatorVersion { get; set; } = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Default TTL for cached verdicts.
|
||||
/// </summary>
|
||||
public TimeSpan CacheTtl { get; set; } = TimeSpan.FromHours(1);
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable Rekor publishing by default.
|
||||
/// </summary>
|
||||
public bool DefaultRekorPublish { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable OCI attachment by default.
|
||||
/// </summary>
|
||||
public bool DefaultOciAttach { get; set; } = false;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>StellaOps.Attestor.TrustVerdict</RootNamespace>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Description>TrustVerdict attestation library for signed VEX trust evaluations</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Attestor.StandardPredicates\StellaOps.Attestor.StandardPredicates.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,298 @@
|
||||
// TrustVerdictMetrics - OpenTelemetry metrics for TrustVerdict attestations
|
||||
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace StellaOps.Attestor.TrustVerdict.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// OpenTelemetry metrics for TrustVerdict operations.
|
||||
/// </summary>
|
||||
public sealed class TrustVerdictMetrics : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Meter name for TrustVerdict metrics.
|
||||
/// </summary>
|
||||
public const string MeterName = "StellaOps.TrustVerdict";
|
||||
|
||||
/// <summary>
|
||||
/// Activity source name for TrustVerdict tracing.
|
||||
/// </summary>
|
||||
public const string ActivitySourceName = "StellaOps.TrustVerdict";
|
||||
|
||||
private readonly Meter _meter;
|
||||
|
||||
// Counters
|
||||
private readonly Counter<long> _verdictsGenerated;
|
||||
private readonly Counter<long> _verdictsVerified;
|
||||
private readonly Counter<long> _verdictsFailed;
|
||||
private readonly Counter<long> _cacheHits;
|
||||
private readonly Counter<long> _cacheMisses;
|
||||
private readonly Counter<long> _rekorPublications;
|
||||
private readonly Counter<long> _ociAttachments;
|
||||
|
||||
// Histograms
|
||||
private readonly Histogram<double> _verdictGenerationDuration;
|
||||
private readonly Histogram<double> _verdictVerificationDuration;
|
||||
private readonly Histogram<double> _trustScore;
|
||||
private readonly Histogram<int> _evidenceItemCount;
|
||||
private readonly Histogram<double> _merkleTreeBuildDuration;
|
||||
|
||||
// Gauges (via observable)
|
||||
private readonly ObservableGauge<long> _cacheEntries;
|
||||
private long _currentCacheEntries;
|
||||
|
||||
/// <summary>
|
||||
/// Activity source for distributed tracing.
|
||||
/// </summary>
|
||||
public static readonly ActivitySource ActivitySource = new(ActivitySourceName);
|
||||
|
||||
public TrustVerdictMetrics(IMeterFactory? meterFactory = null)
|
||||
{
|
||||
_meter = meterFactory?.Create(MeterName) ?? new Meter(MeterName);
|
||||
|
||||
// Counters
|
||||
_verdictsGenerated = _meter.CreateCounter<long>(
|
||||
"stellaops.trustverdicts.generated.total",
|
||||
unit: "{verdict}",
|
||||
description: "Total number of TrustVerdicts generated");
|
||||
|
||||
_verdictsVerified = _meter.CreateCounter<long>(
|
||||
"stellaops.trustverdicts.verified.total",
|
||||
unit: "{verdict}",
|
||||
description: "Total number of TrustVerdicts verified");
|
||||
|
||||
_verdictsFailed = _meter.CreateCounter<long>(
|
||||
"stellaops.trustverdicts.failed.total",
|
||||
unit: "{verdict}",
|
||||
description: "Total number of TrustVerdict generation failures");
|
||||
|
||||
_cacheHits = _meter.CreateCounter<long>(
|
||||
"stellaops.trustverdicts.cache.hits.total",
|
||||
unit: "{hit}",
|
||||
description: "Total number of cache hits");
|
||||
|
||||
_cacheMisses = _meter.CreateCounter<long>(
|
||||
"stellaops.trustverdicts.cache.misses.total",
|
||||
unit: "{miss}",
|
||||
description: "Total number of cache misses");
|
||||
|
||||
_rekorPublications = _meter.CreateCounter<long>(
|
||||
"stellaops.trustverdicts.rekor.publications.total",
|
||||
unit: "{publication}",
|
||||
description: "Total number of verdicts published to Rekor");
|
||||
|
||||
_ociAttachments = _meter.CreateCounter<long>(
|
||||
"stellaops.trustverdicts.oci.attachments.total",
|
||||
unit: "{attachment}",
|
||||
description: "Total number of verdicts attached to OCI artifacts");
|
||||
|
||||
// Histograms
|
||||
_verdictGenerationDuration = _meter.CreateHistogram<double>(
|
||||
"stellaops.trustverdicts.generation.duration",
|
||||
unit: "ms",
|
||||
description: "Duration of TrustVerdict generation");
|
||||
|
||||
_verdictVerificationDuration = _meter.CreateHistogram<double>(
|
||||
"stellaops.trustverdicts.verification.duration",
|
||||
unit: "ms",
|
||||
description: "Duration of TrustVerdict verification");
|
||||
|
||||
_trustScore = _meter.CreateHistogram<double>(
|
||||
"stellaops.trustverdicts.trust_score",
|
||||
unit: "1",
|
||||
description: "Distribution of computed trust scores");
|
||||
|
||||
_evidenceItemCount = _meter.CreateHistogram<int>(
|
||||
"stellaops.trustverdicts.evidence_items",
|
||||
unit: "{item}",
|
||||
description: "Number of evidence items per verdict");
|
||||
|
||||
_merkleTreeBuildDuration = _meter.CreateHistogram<double>(
|
||||
"stellaops.trustverdicts.merkle_tree.build.duration",
|
||||
unit: "ms",
|
||||
description: "Duration of Merkle tree construction");
|
||||
|
||||
// Observable gauge for cache entries
|
||||
_cacheEntries = _meter.CreateObservableGauge(
|
||||
"stellaops.trustverdicts.cache.entries",
|
||||
() => _currentCacheEntries,
|
||||
unit: "{entry}",
|
||||
description: "Current number of cached verdicts");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record a verdict generation.
|
||||
/// </summary>
|
||||
public void RecordVerdictGenerated(
|
||||
string tenantId,
|
||||
string tier,
|
||||
decimal trustScore,
|
||||
int evidenceCount,
|
||||
TimeSpan duration,
|
||||
bool success)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "tenant_id", tenantId },
|
||||
{ "trust_tier", tier },
|
||||
{ "success", success.ToString().ToLowerInvariant() }
|
||||
};
|
||||
|
||||
if (success)
|
||||
{
|
||||
_verdictsGenerated.Add(1, tags);
|
||||
_trustScore.Record((double)trustScore, tags);
|
||||
_evidenceItemCount.Record(evidenceCount, tags);
|
||||
}
|
||||
else
|
||||
{
|
||||
_verdictsFailed.Add(1, tags);
|
||||
}
|
||||
|
||||
_verdictGenerationDuration.Record(duration.TotalMilliseconds, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record a verdict verification.
|
||||
/// </summary>
|
||||
public void RecordVerdictVerified(
|
||||
string tenantId,
|
||||
bool valid,
|
||||
TimeSpan duration)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "tenant_id", tenantId },
|
||||
{ "valid", valid.ToString().ToLowerInvariant() }
|
||||
};
|
||||
|
||||
_verdictsVerified.Add(1, tags);
|
||||
_verdictVerificationDuration.Record(duration.TotalMilliseconds, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record a cache hit.
|
||||
/// </summary>
|
||||
public void RecordCacheHit(string tenantId)
|
||||
{
|
||||
_cacheHits.Add(1, new TagList { { "tenant_id", tenantId } });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record a cache miss.
|
||||
/// </summary>
|
||||
public void RecordCacheMiss(string tenantId)
|
||||
{
|
||||
_cacheMisses.Add(1, new TagList { { "tenant_id", tenantId } });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record a Rekor publication.
|
||||
/// </summary>
|
||||
public void RecordRekorPublication(string tenantId, bool success)
|
||||
{
|
||||
_rekorPublications.Add(1, new TagList
|
||||
{
|
||||
{ "tenant_id", tenantId },
|
||||
{ "success", success.ToString().ToLowerInvariant() }
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record an OCI attachment.
|
||||
/// </summary>
|
||||
public void RecordOciAttachment(string tenantId, bool success)
|
||||
{
|
||||
_ociAttachments.Add(1, new TagList
|
||||
{
|
||||
{ "tenant_id", tenantId },
|
||||
{ "success", success.ToString().ToLowerInvariant() }
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record Merkle tree build duration.
|
||||
/// </summary>
|
||||
public void RecordMerkleTreeBuild(int leafCount, TimeSpan duration)
|
||||
{
|
||||
_merkleTreeBuildDuration.Record(duration.TotalMilliseconds, new TagList
|
||||
{
|
||||
{ "leaf_count_bucket", GetLeafCountBucket(leafCount) }
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update the cache entry count gauge.
|
||||
/// </summary>
|
||||
public void SetCacheEntryCount(long count)
|
||||
{
|
||||
_currentCacheEntries = count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start an activity for verdict generation.
|
||||
/// </summary>
|
||||
public static Activity? StartGenerationActivity(string vexDigest, string tenantId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("TrustVerdict.Generate");
|
||||
activity?.SetTag("vex.digest", vexDigest);
|
||||
activity?.SetTag("tenant.id", tenantId);
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start an activity for verdict verification.
|
||||
/// </summary>
|
||||
public static Activity? StartVerificationActivity(string verdictDigest, string tenantId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("TrustVerdict.Verify");
|
||||
activity?.SetTag("verdict.digest", verdictDigest);
|
||||
activity?.SetTag("tenant.id", tenantId);
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start an activity for cache lookup.
|
||||
/// </summary>
|
||||
public static Activity? StartCacheLookupActivity(string key)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("TrustVerdict.CacheLookup");
|
||||
activity?.SetTag("cache.key", key);
|
||||
return activity;
|
||||
}
|
||||
|
||||
private static string GetLeafCountBucket(int count) => count switch
|
||||
{
|
||||
0 => "0",
|
||||
<= 5 => "1-5",
|
||||
<= 10 => "6-10",
|
||||
<= 20 => "11-20",
|
||||
<= 50 => "21-50",
|
||||
_ => "50+"
|
||||
};
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_meter.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for adding TrustVerdict metrics.
|
||||
/// </summary>
|
||||
public static class TrustVerdictMetricsExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Add TrustVerdict OpenTelemetry metrics.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddTrustVerdictMetrics(
|
||||
this IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton<TrustVerdictMetrics>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// TrustVerdictServiceCollectionExtensions - DI registration for TrustVerdict services
|
||||
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Attestor.TrustVerdict.Caching;
|
||||
using StellaOps.Attestor.TrustVerdict.Evidence;
|
||||
using StellaOps.Attestor.TrustVerdict.Services;
|
||||
|
||||
namespace StellaOps.Attestor.TrustVerdict;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering TrustVerdict services.
|
||||
/// </summary>
|
||||
public static class TrustVerdictServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Add TrustVerdict attestation services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configuration">Configuration for binding options.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddTrustVerdictServices(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
// Bind configuration
|
||||
services.Configure<TrustVerdictServiceOptions>(
|
||||
configuration.GetSection(TrustVerdictServiceOptions.SectionKey));
|
||||
|
||||
services.Configure<TrustVerdictCacheOptions>(
|
||||
configuration.GetSection(TrustVerdictCacheOptions.SectionKey));
|
||||
|
||||
// Register core services
|
||||
services.TryAddSingleton<ITrustVerdictService, TrustVerdictService>();
|
||||
services.TryAddSingleton<ITrustEvidenceMerkleBuilder, TrustEvidenceMerkleBuilder>();
|
||||
|
||||
// Register cache based on configuration
|
||||
var cacheOptions = configuration
|
||||
.GetSection(TrustVerdictCacheOptions.SectionKey)
|
||||
.Get<TrustVerdictCacheOptions>() ?? new TrustVerdictCacheOptions();
|
||||
|
||||
if (cacheOptions.UseValkey)
|
||||
{
|
||||
services.TryAddSingleton<ITrustVerdictCache, ValkeyTrustVerdictCache>();
|
||||
}
|
||||
else
|
||||
{
|
||||
services.TryAddSingleton<ITrustVerdictCache, InMemoryTrustVerdictCache>();
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add TrustVerdict services with custom configuration.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configureService">Action to configure service options.</param>
|
||||
/// <param name="configureCache">Action to configure cache options.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddTrustVerdictServices(
|
||||
this IServiceCollection services,
|
||||
Action<TrustVerdictServiceOptions>? configureService = null,
|
||||
Action<TrustVerdictCacheOptions>? configureCache = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
// Configure options
|
||||
if (configureService != null)
|
||||
{
|
||||
services.Configure(configureService);
|
||||
}
|
||||
|
||||
if (configureCache != null)
|
||||
{
|
||||
services.Configure(configureCache);
|
||||
}
|
||||
|
||||
// Register core services
|
||||
services.TryAddSingleton<ITrustVerdictService, TrustVerdictService>();
|
||||
services.TryAddSingleton<ITrustEvidenceMerkleBuilder, TrustEvidenceMerkleBuilder>();
|
||||
services.TryAddSingleton<ITrustVerdictCache, InMemoryTrustVerdictCache>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add Valkey-backed TrustVerdict cache.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="connectionString">Valkey connection string.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddValkeyTrustVerdictCache(
|
||||
this IServiceCollection services,
|
||||
string connectionString)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
|
||||
|
||||
services.Configure<TrustVerdictCacheOptions>(opts =>
|
||||
{
|
||||
opts.UseValkey = true;
|
||||
opts.ConnectionString = connectionString;
|
||||
});
|
||||
|
||||
// Replace any existing cache registration
|
||||
services.RemoveAll<ITrustVerdictCache>();
|
||||
services.AddSingleton<ITrustVerdictCache, ValkeyTrustVerdictCache>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add in-memory TrustVerdict cache (for development/testing).
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="maxEntries">Maximum cache entries.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddInMemoryTrustVerdictCache(
|
||||
this IServiceCollection services,
|
||||
int maxEntries = 10_000)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.Configure<TrustVerdictCacheOptions>(opts =>
|
||||
{
|
||||
opts.UseValkey = false;
|
||||
opts.MaxEntries = maxEntries;
|
||||
});
|
||||
|
||||
// Replace any existing cache registration
|
||||
services.RemoveAll<ITrustVerdictCache>();
|
||||
services.AddSingleton<ITrustVerdictCache, InMemoryTrustVerdictCache>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -12,24 +12,10 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.GraphRoot\StellaOps.Attestor.GraphRoot.csproj" />
|
||||
@@ -37,5 +23,4 @@
|
||||
<ProjectReference Include="..\..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="../../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user