Refactor code structure for improved readability and maintainability; optimize performance in key functions.
This commit is contained in:
119
src/__Libraries/StellaOps.Evidence/Budgets/EvidenceBudget.cs
Normal file
119
src/__Libraries/StellaOps.Evidence/Budgets/EvidenceBudget.cs
Normal 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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
102
src/__Libraries/StellaOps.Evidence/Models/EvidenceIndex.cs
Normal file
102
src/__Libraries/StellaOps.Evidence/Models/EvidenceIndex.cs
Normal 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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
115
src/__Libraries/StellaOps.Evidence/Services/EvidenceLinker.cs
Normal file
115
src/__Libraries/StellaOps.Evidence/Services/EvidenceLinker.cs
Normal 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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
21
src/__Libraries/StellaOps.Evidence/StellaOps.Evidence.csproj
Normal file
21
src/__Libraries/StellaOps.Evidence/StellaOps.Evidence.csproj
Normal 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>
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user