Refactor code structure for improved readability and maintainability; optimize performance in key functions.

This commit is contained in:
master
2025-12-22 19:06:31 +02:00
parent dfaa2079aa
commit 4602ccc3a3
1444 changed files with 109919 additions and 8058 deletions

View File

@@ -0,0 +1,119 @@
namespace StellaOps.Evidence.Budgets;
/// <summary>
/// Budget configuration for evidence storage.
/// </summary>
public sealed record EvidenceBudget
{
/// <summary>
/// Maximum total evidence size per scan (bytes).
/// </summary>
public required long MaxScanSizeBytes { get; init; }
/// <summary>
/// Maximum size per evidence type (bytes).
/// </summary>
public IReadOnlyDictionary<EvidenceType, long> MaxPerType { get; init; }
= new Dictionary<EvidenceType, long>();
/// <summary>
/// Retention policy by tier.
/// </summary>
public required IReadOnlyDictionary<RetentionTier, RetentionPolicy> RetentionPolicies { get; init; }
/// <summary>
/// Action when budget is exceeded.
/// </summary>
public BudgetExceededAction ExceededAction { get; init; } = BudgetExceededAction.Warn;
/// <summary>
/// Evidence types to always preserve (never prune).
/// </summary>
public IReadOnlySet<EvidenceType> AlwaysPreserve { get; init; }
= new HashSet<EvidenceType> { EvidenceType.Verdict, EvidenceType.Attestation };
public static EvidenceBudget Default => new()
{
MaxScanSizeBytes = 100 * 1024 * 1024, // 100 MB
MaxPerType = new Dictionary<EvidenceType, long>
{
[EvidenceType.CallGraph] = 50 * 1024 * 1024,
[EvidenceType.RuntimeCapture] = 20 * 1024 * 1024,
[EvidenceType.Sbom] = 10 * 1024 * 1024,
[EvidenceType.PolicyTrace] = 5 * 1024 * 1024
},
RetentionPolicies = new Dictionary<RetentionTier, RetentionPolicy>
{
[RetentionTier.Hot] = new RetentionPolicy { Duration = TimeSpan.FromDays(7) },
[RetentionTier.Warm] = new RetentionPolicy { Duration = TimeSpan.FromDays(30) },
[RetentionTier.Cold] = new RetentionPolicy { Duration = TimeSpan.FromDays(90) },
[RetentionTier.Archive] = new RetentionPolicy { Duration = TimeSpan.FromDays(365) }
}
};
}
public enum EvidenceType
{
Verdict,
PolicyTrace,
CallGraph,
RuntimeCapture,
Sbom,
Vex,
Attestation,
PathWitness,
Advisory
}
public enum RetentionTier
{
/// <summary>Immediately accessible, highest cost.</summary>
Hot,
/// <summary>Quick retrieval, moderate cost.</summary>
Warm,
/// <summary>Delayed retrieval, lower cost.</summary>
Cold,
/// <summary>Long-term storage, lowest cost.</summary>
Archive
}
public sealed record RetentionPolicy
{
/// <summary>
/// How long evidence stays in this tier.
/// </summary>
public required TimeSpan Duration { get; init; }
/// <summary>
/// Compression algorithm for this tier.
/// </summary>
public CompressionLevel Compression { get; init; } = CompressionLevel.None;
/// <summary>
/// Whether to deduplicate within this tier.
/// </summary>
public bool Deduplicate { get; init; } = true;
}
public enum CompressionLevel
{
None,
Fast,
Optimal,
Maximum
}
public enum BudgetExceededAction
{
/// <summary>Log warning but continue.</summary>
Warn,
/// <summary>Block the operation.</summary>
Block,
/// <summary>Automatically prune lowest priority evidence.</summary>
AutoPrune
}

View File

@@ -0,0 +1,247 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Evidence.Budgets;
public interface IEvidenceBudgetService
{
BudgetCheckResult CheckBudget(Guid scanId, EvidenceItem item);
BudgetStatus GetBudgetStatus(Guid scanId);
Task<PruneResult> PruneToFitAsync(Guid scanId, long targetBytes, CancellationToken ct);
}
public sealed class EvidenceBudgetService : IEvidenceBudgetService
{
private readonly IEvidenceRepository _repository;
private readonly IOptionsMonitor<EvidenceBudget> _options;
private readonly ILogger<EvidenceBudgetService> _logger;
public EvidenceBudgetService(
IEvidenceRepository repository,
IOptionsMonitor<EvidenceBudget> options,
ILogger<EvidenceBudgetService> logger)
{
_repository = repository;
_options = options;
_logger = logger;
}
public BudgetCheckResult CheckBudget(Guid scanId, EvidenceItem item)
{
var budget = _options.CurrentValue;
var currentUsage = GetCurrentUsage(scanId);
var issues = new List<string>();
// Check total budget
var projectedTotal = currentUsage.TotalBytes + item.SizeBytes;
if (projectedTotal > budget.MaxScanSizeBytes)
{
issues.Add($"Would exceed total budget: {projectedTotal:N0} > {budget.MaxScanSizeBytes:N0} bytes");
}
// Check per-type budget
if (budget.MaxPerType.TryGetValue(item.Type, out var typeLimit))
{
var typeUsage = currentUsage.ByType.GetValueOrDefault(item.Type, 0);
var projectedType = typeUsage + item.SizeBytes;
if (projectedType > typeLimit)
{
issues.Add($"Would exceed {item.Type} budget: {projectedType:N0} > {typeLimit:N0} bytes");
}
}
if (issues.Count == 0)
{
return BudgetCheckResult.WithinBudget();
}
return new BudgetCheckResult
{
IsWithinBudget = false,
Issues = issues,
RecommendedAction = budget.ExceededAction,
CanAutoPrune = budget.ExceededAction == BudgetExceededAction.AutoPrune,
BytesToFree = Math.Max(0, projectedTotal - budget.MaxScanSizeBytes)
};
}
public BudgetStatus GetBudgetStatus(Guid scanId)
{
var budget = _options.CurrentValue;
var usage = GetCurrentUsage(scanId);
return new BudgetStatus
{
ScanId = scanId,
TotalBudgetBytes = budget.MaxScanSizeBytes,
UsedBytes = usage.TotalBytes,
RemainingBytes = Math.Max(0, budget.MaxScanSizeBytes - usage.TotalBytes),
UtilizationPercent = (decimal)usage.TotalBytes / budget.MaxScanSizeBytes * 100,
ByType = usage.ByType.ToDictionary(
kvp => kvp.Key,
kvp => new TypeBudgetStatus
{
Type = kvp.Key,
UsedBytes = kvp.Value,
LimitBytes = budget.MaxPerType.GetValueOrDefault(kvp.Key),
UtilizationPercent = budget.MaxPerType.TryGetValue(kvp.Key, out var limit)
? (decimal)kvp.Value / limit * 100
: 0
})
};
}
public async Task<PruneResult> PruneToFitAsync(
Guid scanId,
long targetBytes,
CancellationToken ct)
{
var budget = _options.CurrentValue;
var usage = GetCurrentUsage(scanId);
if (usage.TotalBytes <= targetBytes)
{
return PruneResult.NoPruningNeeded();
}
var bytesToPrune = usage.TotalBytes - targetBytes;
var pruned = new List<PrunedItem>();
// Get all evidence items, sorted by pruning priority
var items = await _repository.GetByScanIdAsync(scanId, ct);
var candidates = items
.Where(i => !budget.AlwaysPreserve.Contains(i.Type))
.OrderBy(i => GetPrunePriority(i))
.ToList();
long prunedBytes = 0;
foreach (var item in candidates)
{
if (prunedBytes >= bytesToPrune)
break;
// Move to archive tier or delete
await _repository.MoveToTierAsync(item.Id, RetentionTier.Archive, ct);
pruned.Add(new PrunedItem(item.Id, item.Type, item.SizeBytes));
prunedBytes += item.SizeBytes;
}
_logger.LogInformation(
"Pruned {Count} items ({Bytes:N0} bytes) for scan {ScanId}",
pruned.Count, prunedBytes, scanId);
return new PruneResult
{
Success = prunedBytes >= bytesToPrune,
BytesPruned = prunedBytes,
ItemsPruned = pruned,
BytesRemaining = usage.TotalBytes - prunedBytes
};
}
private static int GetPrunePriority(EvidenceItem item)
{
// Lower = prune first
return item.Type switch
{
EvidenceType.RuntimeCapture => 1,
EvidenceType.CallGraph => 2,
EvidenceType.Advisory => 3,
EvidenceType.PathWitness => 4,
EvidenceType.PolicyTrace => 5,
EvidenceType.Sbom => 6,
EvidenceType.Vex => 7,
EvidenceType.Attestation => 8,
EvidenceType.Verdict => 9, // Never prune
_ => 5
};
}
private UsageStats GetCurrentUsage(Guid scanId)
{
// Implementation to calculate current usage from repository
var items = _repository.GetByScanIdAsync(scanId, CancellationToken.None)
.GetAwaiter().GetResult();
var totalBytes = items.Sum(i => i.SizeBytes);
var byType = items
.GroupBy(i => i.Type)
.ToDictionary(g => g.Key, g => g.Sum(i => i.SizeBytes));
return new UsageStats
{
TotalBytes = totalBytes,
ByType = byType
};
}
}
public sealed record BudgetCheckResult
{
public required bool IsWithinBudget { get; init; }
public IReadOnlyList<string> Issues { get; init; } = [];
public BudgetExceededAction RecommendedAction { get; init; }
public bool CanAutoPrune { get; init; }
public long BytesToFree { get; init; }
public static BudgetCheckResult WithinBudget() => new() { IsWithinBudget = true };
}
public sealed record BudgetStatus
{
public required Guid ScanId { get; init; }
public required long TotalBudgetBytes { get; init; }
public required long UsedBytes { get; init; }
public required long RemainingBytes { get; init; }
public required decimal UtilizationPercent { get; init; }
public required IReadOnlyDictionary<EvidenceType, TypeBudgetStatus> ByType { get; init; }
}
public sealed record TypeBudgetStatus
{
public required EvidenceType Type { get; init; }
public required long UsedBytes { get; init; }
public long? LimitBytes { get; init; }
public decimal UtilizationPercent { get; init; }
}
public sealed record PruneResult
{
public required bool Success { get; init; }
public long BytesPruned { get; init; }
public IReadOnlyList<PrunedItem> ItemsPruned { get; init; } = [];
public long BytesRemaining { get; init; }
public static PruneResult NoPruningNeeded() => new() { Success = true };
}
public sealed record PrunedItem(Guid ItemId, EvidenceType Type, long SizeBytes);
public sealed record UsageStats
{
public long TotalBytes { get; init; }
public IReadOnlyDictionary<EvidenceType, long> ByType { get; init; } = new Dictionary<EvidenceType, long>();
}
// Supporting interfaces and types
public interface IEvidenceRepository
{
Task<IReadOnlyList<EvidenceItem>> GetByScanIdAsync(Guid scanId, CancellationToken ct);
Task<IReadOnlyList<EvidenceItem>> GetByScanIdAndTypeAsync(Guid scanId, EvidenceType type, CancellationToken ct);
Task<IReadOnlyList<EvidenceItem>> GetOlderThanAsync(RetentionTier tier, DateTimeOffset cutoff, CancellationToken ct);
Task MoveToTierAsync(Guid itemId, RetentionTier tier, CancellationToken ct);
Task UpdateContentAsync(Guid itemId, byte[] content, CancellationToken ct);
}
public sealed record EvidenceItem
{
public required Guid Id { get; init; }
public required Guid ScanId { get; init; }
public required EvidenceType Type { get; init; }
public required long SizeBytes { get; init; }
public required RetentionTier Tier { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public string? ArchiveKey { get; init; }
}

View File

@@ -0,0 +1,102 @@
using System.Collections.Immutable;
namespace StellaOps.Evidence.Models;
/// <summary>
/// Machine-readable index linking a verdict to all supporting evidence.
/// </summary>
public sealed record EvidenceIndex
{
public required string IndexId { get; init; }
public string SchemaVersion { get; init; } = "1.0.0";
public required VerdictReference Verdict { get; init; }
public required ImmutableArray<SbomEvidence> Sboms { get; init; }
public required ImmutableArray<AttestationEvidence> Attestations { get; init; }
public ImmutableArray<VexEvidence> VexDocuments { get; init; } = [];
public ImmutableArray<ReachabilityEvidence> ReachabilityProofs { get; init; } = [];
public ImmutableArray<UnknownEvidence> Unknowns { get; init; } = [];
public required ToolChainEvidence ToolChain { get; init; }
public required string RunManifestDigest { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public string? IndexDigest { get; init; }
}
public sealed record VerdictReference(
string VerdictId,
string Digest,
VerdictOutcome Outcome,
string? PolicyVersion);
public enum VerdictOutcome
{
Pass,
Fail,
Warn,
Unknown
}
public sealed record SbomEvidence(
string SbomId,
string Format,
string Digest,
string? Uri,
int ComponentCount,
DateTimeOffset GeneratedAt);
public sealed record AttestationEvidence(
string AttestationId,
string Type,
string Digest,
string SignerKeyId,
bool SignatureValid,
DateTimeOffset SignedAt,
string? RekorLogIndex);
public sealed record VexEvidence(
string VexId,
string Format,
string Digest,
string Source,
int StatementCount,
ImmutableArray<string> AffectedVulnerabilities);
public sealed record ReachabilityEvidence(
string ProofId,
string VulnerabilityId,
string ComponentPurl,
ReachabilityStatus Status,
string? EntryPoint,
ImmutableArray<string> CallPath,
string Digest);
public enum ReachabilityStatus
{
Reachable,
NotReachable,
Inconclusive,
NotAnalyzed
}
public sealed record UnknownEvidence(
string UnknownId,
string ReasonCode,
string Description,
string? ComponentPurl,
string? VulnerabilityId,
UnknownSeverity Severity);
public enum UnknownSeverity
{
Low,
Medium,
High,
Critical
}
public sealed record ToolChainEvidence(
string ScannerVersion,
string SbomGeneratorVersion,
string ReachabilityEngineVersion,
string AttestorVersion,
string PolicyEngineVersion,
ImmutableDictionary<string, string> AdditionalTools);

View File

@@ -0,0 +1,152 @@
using Microsoft.Extensions.Options;
using StellaOps.Evidence.Budgets;
namespace StellaOps.Evidence.Retention;
public interface IRetentionTierManager
{
Task<TierMigrationResult> RunMigrationAsync(CancellationToken ct);
RetentionTier GetCurrentTier(EvidenceItem item);
Task EnsureAuditCompleteAsync(Guid scanId, CancellationToken ct);
}
public sealed class RetentionTierManager : IRetentionTierManager
{
private readonly IEvidenceRepository _repository;
private readonly IArchiveStorage _archiveStorage;
private readonly IOptionsMonitor<EvidenceBudget> _options;
public RetentionTierManager(
IEvidenceRepository repository,
IArchiveStorage archiveStorage,
IOptionsMonitor<EvidenceBudget> options)
{
_repository = repository;
_archiveStorage = archiveStorage;
_options = options;
}
public async Task<TierMigrationResult> RunMigrationAsync(CancellationToken ct)
{
var budget = _options.CurrentValue;
var now = DateTimeOffset.UtcNow;
var migrated = new List<MigratedItem>();
// Hot → Warm
var hotExpiry = now - budget.RetentionPolicies[RetentionTier.Hot].Duration;
var toWarm = await _repository.GetOlderThanAsync(RetentionTier.Hot, hotExpiry, ct);
foreach (var item in toWarm)
{
await MigrateAsync(item, RetentionTier.Warm, ct);
migrated.Add(new MigratedItem(item.Id, RetentionTier.Hot, RetentionTier.Warm));
}
// Warm → Cold
var warmExpiry = now - budget.RetentionPolicies[RetentionTier.Warm].Duration;
var toCold = await _repository.GetOlderThanAsync(RetentionTier.Warm, warmExpiry, ct);
foreach (var item in toCold)
{
await MigrateAsync(item, RetentionTier.Cold, ct);
migrated.Add(new MigratedItem(item.Id, RetentionTier.Warm, RetentionTier.Cold));
}
// Cold → Archive
var coldExpiry = now - budget.RetentionPolicies[RetentionTier.Cold].Duration;
var toArchive = await _repository.GetOlderThanAsync(RetentionTier.Cold, coldExpiry, ct);
foreach (var item in toArchive)
{
await MigrateAsync(item, RetentionTier.Archive, ct);
migrated.Add(new MigratedItem(item.Id, RetentionTier.Cold, RetentionTier.Archive));
}
return new TierMigrationResult
{
MigratedCount = migrated.Count,
Items = migrated
};
}
public RetentionTier GetCurrentTier(EvidenceItem item)
{
var budget = _options.CurrentValue;
var age = DateTimeOffset.UtcNow - item.CreatedAt;
if (age < budget.RetentionPolicies[RetentionTier.Hot].Duration)
return RetentionTier.Hot;
if (age < budget.RetentionPolicies[RetentionTier.Warm].Duration)
return RetentionTier.Warm;
if (age < budget.RetentionPolicies[RetentionTier.Cold].Duration)
return RetentionTier.Cold;
return RetentionTier.Archive;
}
public async Task EnsureAuditCompleteAsync(Guid scanId, CancellationToken ct)
{
var budget = _options.CurrentValue;
// Ensure all AlwaysPreserve types are in Hot tier for audit export
foreach (var type in budget.AlwaysPreserve)
{
var items = await _repository.GetByScanIdAndTypeAsync(scanId, type, ct);
foreach (var item in items.Where(i => i.Tier != RetentionTier.Hot))
{
await RestoreToHotAsync(item, ct);
}
}
}
private async Task MigrateAsync(EvidenceItem item, RetentionTier targetTier, CancellationToken ct)
{
var policy = _options.CurrentValue.RetentionPolicies[targetTier];
if (policy.Compression != CompressionLevel.None)
{
// Compress before migration
var compressed = await CompressAsync(item, policy.Compression, ct);
await _repository.UpdateContentAsync(item.Id, compressed, ct);
}
await _repository.MoveToTierAsync(item.Id, targetTier, ct);
}
private async Task RestoreToHotAsync(EvidenceItem item, CancellationToken ct)
{
if (item.Tier == RetentionTier.Archive)
{
// Retrieve from archive storage
var content = await _archiveStorage.RetrieveAsync(item.ArchiveKey!, ct);
await _repository.UpdateContentAsync(item.Id, content, ct);
}
await _repository.MoveToTierAsync(item.Id, RetentionTier.Hot, ct);
}
private async Task<byte[]> CompressAsync(
EvidenceItem item,
CompressionLevel level,
CancellationToken ct)
{
// Placeholder for compression logic
// In real implementation, would read content, compress, and return
await Task.CompletedTask;
return Array.Empty<byte>();
}
}
public sealed record TierMigrationResult
{
public required int MigratedCount { get; init; }
public IReadOnlyList<MigratedItem> Items { get; init; } = [];
}
public sealed record MigratedItem(Guid ItemId, RetentionTier FromTier, RetentionTier ToTier);
/// <summary>
/// Archive storage interface for long-term retention.
/// </summary>
public interface IArchiveStorage
{
Task<byte[]> RetrieveAsync(string archiveKey, CancellationToken ct);
Task<string> StoreAsync(byte[] content, CancellationToken ct);
}

View File

@@ -0,0 +1,116 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://stellaops.io/schemas/evidence-index/v1",
"title": "StellaOps Evidence Index",
"type": "object",
"required": [
"indexId",
"schemaVersion",
"verdict",
"sboms",
"attestations",
"toolChain",
"runManifestDigest",
"createdAt"
],
"properties": {
"indexId": { "type": "string" },
"schemaVersion": { "type": "string" },
"verdict": { "$ref": "#/$defs/verdictReference" },
"sboms": { "type": "array", "items": { "$ref": "#/$defs/sbomEvidence" } },
"attestations": { "type": "array", "items": { "$ref": "#/$defs/attestationEvidence" } },
"vexDocuments": { "type": "array", "items": { "$ref": "#/$defs/vexEvidence" } },
"reachabilityProofs": { "type": "array", "items": { "$ref": "#/$defs/reachabilityEvidence" } },
"unknowns": { "type": "array", "items": { "$ref": "#/$defs/unknownEvidence" } },
"toolChain": { "$ref": "#/$defs/toolChainEvidence" },
"runManifestDigest": { "type": "string" },
"createdAt": { "type": "string", "format": "date-time" },
"indexDigest": { "type": ["string", "null"] }
},
"$defs": {
"verdictReference": {
"type": "object",
"required": ["verdictId", "digest", "outcome"],
"properties": {
"verdictId": { "type": "string" },
"digest": { "type": "string" },
"outcome": { "enum": ["Pass", "Fail", "Warn", "Unknown"] },
"policyVersion": { "type": ["string", "null"] }
}
},
"sbomEvidence": {
"type": "object",
"required": ["sbomId", "format", "digest", "componentCount", "generatedAt"],
"properties": {
"sbomId": { "type": "string" },
"format": { "type": "string" },
"digest": { "type": "string" },
"uri": { "type": ["string", "null"] },
"componentCount": { "type": "integer" },
"generatedAt": { "type": "string", "format": "date-time" }
}
},
"attestationEvidence": {
"type": "object",
"required": ["attestationId", "type", "digest", "signerKeyId", "signatureValid", "signedAt"],
"properties": {
"attestationId": { "type": "string" },
"type": { "type": "string" },
"digest": { "type": "string" },
"signerKeyId": { "type": "string" },
"signatureValid": { "type": "boolean" },
"signedAt": { "type": "string", "format": "date-time" },
"rekorLogIndex": { "type": ["string", "null"] }
}
},
"vexEvidence": {
"type": "object",
"required": ["vexId", "format", "digest", "source", "statementCount", "affectedVulnerabilities"],
"properties": {
"vexId": { "type": "string" },
"format": { "type": "string" },
"digest": { "type": "string" },
"source": { "type": "string" },
"statementCount": { "type": "integer" },
"affectedVulnerabilities": { "type": "array", "items": { "type": "string" } }
}
},
"reachabilityEvidence": {
"type": "object",
"required": ["proofId", "vulnerabilityId", "componentPurl", "status", "callPath", "digest"],
"properties": {
"proofId": { "type": "string" },
"vulnerabilityId": { "type": "string" },
"componentPurl": { "type": "string" },
"status": { "enum": ["Reachable", "NotReachable", "Inconclusive", "NotAnalyzed"] },
"entryPoint": { "type": ["string", "null"] },
"callPath": { "type": "array", "items": { "type": "string" } },
"digest": { "type": "string" }
}
},
"unknownEvidence": {
"type": "object",
"required": ["unknownId", "reasonCode", "description", "severity"],
"properties": {
"unknownId": { "type": "string" },
"reasonCode": { "type": "string" },
"description": { "type": "string" },
"componentPurl": { "type": ["string", "null"] },
"vulnerabilityId": { "type": ["string", "null"] },
"severity": { "enum": ["Low", "Medium", "High", "Critical"] }
}
},
"toolChainEvidence": {
"type": "object",
"required": ["scannerVersion", "sbomGeneratorVersion", "reachabilityEngineVersion", "attestorVersion", "policyEngineVersion", "additionalTools"],
"properties": {
"scannerVersion": { "type": "string" },
"sbomGeneratorVersion": { "type": "string" },
"reachabilityEngineVersion": { "type": "string" },
"attestorVersion": { "type": "string" },
"policyEngineVersion": { "type": "string" },
"additionalTools": { "type": "object" }
}
}
}
}

View File

@@ -0,0 +1,47 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Canonical.Json;
using StellaOps.Evidence.Models;
namespace StellaOps.Evidence.Serialization;
/// <summary>
/// Serialize and hash EvidenceIndex in canonical form.
/// </summary>
public static class EvidenceIndexSerializer
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
public static string Serialize(EvidenceIndex index)
{
var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(index, JsonOptions);
var canonicalBytes = CanonJson.CanonicalizeParsedJson(jsonBytes);
return Encoding.UTF8.GetString(canonicalBytes);
}
public static EvidenceIndex Deserialize(string json)
{
return JsonSerializer.Deserialize<EvidenceIndex>(json, JsonOptions)
?? throw new InvalidOperationException("Failed to deserialize evidence index");
}
public static string ComputeDigest(EvidenceIndex index)
{
var withoutDigest = index with { IndexDigest = null };
var json = Serialize(withoutDigest);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return Convert.ToHexString(hash).ToLowerInvariant();
}
public static EvidenceIndex WithDigest(EvidenceIndex index)
=> index with { IndexDigest = ComputeDigest(index) };
}

View File

@@ -0,0 +1,115 @@
using System.Collections.Immutable;
using StellaOps.Evidence.Models;
using StellaOps.Evidence.Serialization;
namespace StellaOps.Evidence.Services;
/// <summary>
/// Collects evidence entries and builds a deterministic EvidenceIndex.
/// </summary>
public sealed class EvidenceLinker : IEvidenceLinker
{
private readonly object _lock = new();
private readonly List<SbomEvidence> _sboms = [];
private readonly List<AttestationEvidence> _attestations = [];
private readonly List<VexEvidence> _vexDocuments = [];
private readonly List<ReachabilityEvidence> _reachabilityProofs = [];
private readonly List<UnknownEvidence> _unknowns = [];
private ToolChainEvidence? _toolChain;
public void AddSbom(SbomEvidence sbom)
{
lock (_lock)
{
_sboms.Add(sbom);
}
}
public void AddAttestation(AttestationEvidence attestation)
{
lock (_lock)
{
_attestations.Add(attestation);
}
}
public void AddVex(VexEvidence vex)
{
lock (_lock)
{
_vexDocuments.Add(vex);
}
}
public void AddReachabilityProof(ReachabilityEvidence proof)
{
lock (_lock)
{
_reachabilityProofs.Add(proof);
}
}
public void AddUnknown(UnknownEvidence unknown)
{
lock (_lock)
{
_unknowns.Add(unknown);
}
}
public void SetToolChain(ToolChainEvidence toolChain)
{
lock (_lock)
{
_toolChain = toolChain;
}
}
public EvidenceIndex Build(VerdictReference verdict, string runManifestDigest)
{
ToolChainEvidence toolChain;
ImmutableArray<SbomEvidence> sboms;
ImmutableArray<AttestationEvidence> attestations;
ImmutableArray<VexEvidence> vexDocuments;
ImmutableArray<ReachabilityEvidence> reachabilityProofs;
ImmutableArray<UnknownEvidence> unknowns;
lock (_lock)
{
toolChain = _toolChain ?? throw new InvalidOperationException("ToolChain must be set before building index");
sboms = _sboms.ToImmutableArray();
attestations = _attestations.ToImmutableArray();
vexDocuments = _vexDocuments.ToImmutableArray();
reachabilityProofs = _reachabilityProofs.ToImmutableArray();
unknowns = _unknowns.ToImmutableArray();
}
var index = new EvidenceIndex
{
IndexId = Guid.NewGuid().ToString(),
SchemaVersion = "1.0.0",
Verdict = verdict,
Sboms = sboms,
Attestations = attestations,
VexDocuments = vexDocuments,
ReachabilityProofs = reachabilityProofs,
Unknowns = unknowns,
ToolChain = toolChain,
RunManifestDigest = runManifestDigest,
CreatedAt = DateTimeOffset.UtcNow
};
return EvidenceIndexSerializer.WithDigest(index);
}
}
public interface IEvidenceLinker
{
void AddSbom(SbomEvidence sbom);
void AddAttestation(AttestationEvidence attestation);
void AddVex(VexEvidence vex);
void AddReachabilityProof(ReachabilityEvidence proof);
void AddUnknown(UnknownEvidence unknown);
void SetToolChain(ToolChainEvidence toolChain);
EvidenceIndex Build(VerdictReference verdict, string runManifestDigest);
}

View File

@@ -0,0 +1,67 @@
using StellaOps.Evidence.Models;
namespace StellaOps.Evidence.Services;
/// <summary>
/// Query helpers for evidence chains.
/// </summary>
public sealed class EvidenceQueryService : IEvidenceQueryService
{
public IEnumerable<AttestationEvidence> GetAttestationsForSbom(
EvidenceIndex index, string sbomDigest)
{
return index.Attestations
.Where(a => a.Type == "sbom" && index.Sboms.Any(s => s.Digest == sbomDigest));
}
public IEnumerable<ReachabilityEvidence> GetReachabilityForVulnerability(
EvidenceIndex index, string vulnerabilityId)
{
return index.ReachabilityProofs
.Where(r => r.VulnerabilityId == vulnerabilityId);
}
public IEnumerable<VexEvidence> GetVexForVulnerability(
EvidenceIndex index, string vulnerabilityId)
{
return index.VexDocuments
.Where(v => v.AffectedVulnerabilities.Contains(vulnerabilityId));
}
public EvidenceChainReport BuildChainReport(EvidenceIndex index)
{
return new EvidenceChainReport
{
VerdictDigest = index.Verdict.Digest,
SbomCount = index.Sboms.Length,
AttestationCount = index.Attestations.Length,
VexCount = index.VexDocuments.Length,
ReachabilityProofCount = index.ReachabilityProofs.Length,
UnknownCount = index.Unknowns.Length,
AllSignaturesValid = index.Attestations.All(a => a.SignatureValid),
HasRekorEntries = index.Attestations.Any(a => a.RekorLogIndex is not null),
ToolChainComplete = index.ToolChain is not null
};
}
}
public interface IEvidenceQueryService
{
IEnumerable<AttestationEvidence> GetAttestationsForSbom(EvidenceIndex index, string sbomDigest);
IEnumerable<ReachabilityEvidence> GetReachabilityForVulnerability(EvidenceIndex index, string vulnerabilityId);
IEnumerable<VexEvidence> GetVexForVulnerability(EvidenceIndex index, string vulnerabilityId);
EvidenceChainReport BuildChainReport(EvidenceIndex index);
}
public sealed record EvidenceChainReport
{
public required string VerdictDigest { get; init; }
public int SbomCount { get; init; }
public int AttestationCount { get; init; }
public int VexCount { get; init; }
public int ReachabilityProofCount { get; init; }
public int UnknownCount { get; init; }
public bool AllSignaturesValid { get; init; }
public bool HasRekorEntries { get; init; }
public bool ToolChainComplete { get; init; }
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Json.Schema.Net" Version="7.2.0" />
<PackageReference Include="System.Collections.Immutable" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Schemas\*.json" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,63 @@
using StellaOps.Evidence.Models;
using StellaOps.Evidence.Serialization;
namespace StellaOps.Evidence.Validation;
public sealed class EvidenceIndexValidator : IEvidenceIndexValidator
{
public ValidationResult Validate(EvidenceIndex index)
{
var errors = new List<ValidationError>();
if (index.Sboms.Length == 0)
{
errors.Add(new ValidationError("Sboms", "At least one SBOM required"));
}
foreach (var vex in index.VexDocuments)
{
if (vex.StatementCount == 0)
{
errors.Add(new ValidationError("VexDocuments", $"VEX {vex.VexId} has no statements"));
}
}
foreach (var proof in index.ReachabilityProofs)
{
if (proof.Status == ReachabilityStatus.Inconclusive &&
!index.Unknowns.Any(u => u.VulnerabilityId == proof.VulnerabilityId))
{
errors.Add(new ValidationError("ReachabilityProofs",
$"Inconclusive reachability for {proof.VulnerabilityId} not recorded as unknown"));
}
}
foreach (var att in index.Attestations)
{
if (!att.SignatureValid)
{
errors.Add(new ValidationError("Attestations",
$"Attestation {att.AttestationId} has invalid signature"));
}
}
if (index.IndexDigest is not null)
{
var computed = EvidenceIndexSerializer.ComputeDigest(index);
if (!string.Equals(computed, index.IndexDigest, StringComparison.OrdinalIgnoreCase))
{
errors.Add(new ValidationError("IndexDigest", "Digest mismatch"));
}
}
return new ValidationResult(errors.Count == 0, errors);
}
}
public interface IEvidenceIndexValidator
{
ValidationResult Validate(EvidenceIndex index);
}
public sealed record ValidationResult(bool IsValid, IReadOnlyList<ValidationError> Errors);
public sealed record ValidationError(string Field, string Message);

View File

@@ -0,0 +1,27 @@
using System.Reflection;
namespace StellaOps.Evidence.Validation;
internal static class SchemaLoader
{
public static string LoadSchema(string fileName)
{
var assembly = Assembly.GetExecutingAssembly();
var resourceName = assembly.GetManifestResourceNames()
.FirstOrDefault(name => name.EndsWith(fileName, StringComparison.OrdinalIgnoreCase));
if (resourceName is null)
{
throw new InvalidOperationException($"Schema resource not found: {fileName}");
}
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream is null)
{
throw new InvalidOperationException($"Schema resource not available: {resourceName}");
}
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
}