Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.

This commit is contained in:
StellaOps Bot
2025-12-26 21:54:17 +02:00
parent 335ff7da16
commit c2b9cd8d1f
3717 changed files with 264714 additions and 48202 deletions

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}
}

View File

@@ -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)';

View File

@@ -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; }
}

View File

@@ -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"))
};
}
}

View File

@@ -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";
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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;
}
}