Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user