part #2
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.Evidence.Budgets;
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace StellaOps.Evidence.Budgets;
|
||||
|
||||
public enum BudgetExceededAction
|
||||
{
|
||||
/// <summary>Log warning but continue.</summary>
|
||||
Warn,
|
||||
|
||||
/// <summary>Block the operation.</summary>
|
||||
Block,
|
||||
|
||||
/// <summary>Automatically prune lowest priority evidence.</summary>
|
||||
AutoPrune
|
||||
}
|
||||
19
src/__Libraries/StellaOps.Evidence/Budgets/BudgetStatus.cs
Normal file
19
src/__Libraries/StellaOps.Evidence/Budgets/BudgetStatus.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace StellaOps.Evidence.Budgets;
|
||||
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace StellaOps.Evidence.Budgets;
|
||||
|
||||
public enum CompressionLevel
|
||||
{
|
||||
None,
|
||||
Fast,
|
||||
Optimal,
|
||||
Maximum
|
||||
}
|
||||
@@ -51,69 +51,3 @@ public sealed record EvidenceBudget
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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,71 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Evidence.Budgets;
|
||||
|
||||
public sealed partial class EvidenceBudgetService
|
||||
{
|
||||
public async Task<PruneResult> PruneToFitAsync(Guid scanId, long targetBytes, CancellationToken ct)
|
||||
{
|
||||
var budget = _options.CurrentValue;
|
||||
var usage = await GetCurrentUsageAsync(scanId, ct).ConfigureAwait(false);
|
||||
|
||||
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).ConfigureAwait(false);
|
||||
var candidates = items
|
||||
.Where(i => !budget.AlwaysPreserve.Contains(i.Type))
|
||||
.OrderBy(i => GetPrunePriority(i))
|
||||
.ThenBy(i => i.CreatedAt.UtcDateTime.Ticks)
|
||||
.ThenBy(i => i.Id)
|
||||
.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).ConfigureAwait(false);
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace StellaOps.Evidence.Budgets;
|
||||
|
||||
public sealed partial class EvidenceBudgetService
|
||||
{
|
||||
public async Task<BudgetStatus> GetBudgetStatusAsync(Guid scanId, CancellationToken ct)
|
||||
{
|
||||
var budget = _options.CurrentValue;
|
||||
var usage = await GetCurrentUsageAsync(scanId, ct).ConfigureAwait(false);
|
||||
|
||||
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
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace StellaOps.Evidence.Budgets;
|
||||
|
||||
public sealed partial class EvidenceBudgetService
|
||||
{
|
||||
private async Task<UsageStats> GetCurrentUsageAsync(Guid scanId, CancellationToken ct)
|
||||
{
|
||||
// Implementation to calculate current usage from repository
|
||||
var items = await _repository.GetByScanIdAsync(scanId, ct).ConfigureAwait(false);
|
||||
|
||||
var totalBytes = items.Sum(i => i.SizeBytes);
|
||||
var byType = items
|
||||
.GroupBy(i => i.Type)
|
||||
.OrderBy(g => g.Key)
|
||||
.ToDictionary(g => g.Key, g => g.Sum(i => i.SizeBytes));
|
||||
|
||||
return new UsageStats
|
||||
{
|
||||
TotalBytes = totalBytes,
|
||||
ByType = byType
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,10 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Evidence.Budgets;
|
||||
|
||||
public interface IEvidenceBudgetService
|
||||
{
|
||||
Task<BudgetCheckResult> CheckBudgetAsync(Guid scanId, EvidenceItem item, CancellationToken ct);
|
||||
Task<BudgetStatus> GetBudgetStatusAsync(Guid scanId, CancellationToken ct);
|
||||
Task<PruneResult> PruneToFitAsync(Guid scanId, long targetBytes, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed class EvidenceBudgetService : IEvidenceBudgetService
|
||||
public sealed partial class EvidenceBudgetService : IEvidenceBudgetService
|
||||
{
|
||||
private readonly IEvidenceRepository _repository;
|
||||
private readonly IOptionsMonitor<EvidenceBudget> _options;
|
||||
@@ -32,7 +24,7 @@ public sealed class EvidenceBudgetService : IEvidenceBudgetService
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
var budget = _options.CurrentValue;
|
||||
var currentUsage = await GetCurrentUsageAsync(scanId, ct);
|
||||
var currentUsage = await GetCurrentUsageAsync(scanId, ct).ConfigureAwait(false);
|
||||
|
||||
var issues = new List<string>();
|
||||
|
||||
@@ -72,182 +64,4 @@ public sealed class EvidenceBudgetService : IEvidenceBudgetService
|
||||
BytesToFree = Math.Max(0, projectedTotal - budget.MaxScanSizeBytes)
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<BudgetStatus> GetBudgetStatusAsync(Guid scanId, CancellationToken ct)
|
||||
{
|
||||
var budget = _options.CurrentValue;
|
||||
var usage = await GetCurrentUsageAsync(scanId, ct);
|
||||
|
||||
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 = await GetCurrentUsageAsync(scanId, ct);
|
||||
|
||||
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))
|
||||
.ThenBy(i => i.CreatedAt.UtcDateTime.Ticks)
|
||||
.ThenBy(i => i.Id)
|
||||
.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 async Task<UsageStats> GetCurrentUsageAsync(Guid scanId, CancellationToken ct)
|
||||
{
|
||||
// Implementation to calculate current usage from repository
|
||||
var items = await _repository.GetByScanIdAsync(scanId, ct);
|
||||
|
||||
var totalBytes = items.Sum(i => i.SizeBytes);
|
||||
var byType = items
|
||||
.GroupBy(i => i.Type)
|
||||
.OrderBy(g => g.Key)
|
||||
.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; }
|
||||
}
|
||||
|
||||
12
src/__Libraries/StellaOps.Evidence/Budgets/EvidenceItem.cs
Normal file
12
src/__Libraries/StellaOps.Evidence/Budgets/EvidenceItem.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.Evidence.Budgets;
|
||||
|
||||
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; }
|
||||
}
|
||||
14
src/__Libraries/StellaOps.Evidence/Budgets/EvidenceType.cs
Normal file
14
src/__Libraries/StellaOps.Evidence/Budgets/EvidenceType.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace StellaOps.Evidence.Budgets;
|
||||
|
||||
public enum EvidenceType
|
||||
{
|
||||
Verdict,
|
||||
PolicyTrace,
|
||||
CallGraph,
|
||||
RuntimeCapture,
|
||||
Sbom,
|
||||
Vex,
|
||||
Attestation,
|
||||
PathWitness,
|
||||
Advisory
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace StellaOps.Evidence.Budgets;
|
||||
|
||||
public interface IEvidenceBudgetService
|
||||
{
|
||||
Task<BudgetCheckResult> CheckBudgetAsync(Guid scanId, EvidenceItem item, CancellationToken ct);
|
||||
Task<BudgetStatus> GetBudgetStatusAsync(Guid scanId, CancellationToken ct);
|
||||
Task<PruneResult> PruneToFitAsync(Guid scanId, long targetBytes, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.Evidence.Budgets;
|
||||
|
||||
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);
|
||||
}
|
||||
13
src/__Libraries/StellaOps.Evidence/Budgets/PruneResult.cs
Normal file
13
src/__Libraries/StellaOps.Evidence/Budgets/PruneResult.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace StellaOps.Evidence.Budgets;
|
||||
|
||||
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);
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace StellaOps.Evidence.Budgets;
|
||||
|
||||
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;
|
||||
}
|
||||
16
src/__Libraries/StellaOps.Evidence/Budgets/RetentionTier.cs
Normal file
16
src/__Libraries/StellaOps.Evidence/Budgets/RetentionTier.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace StellaOps.Evidence.Budgets;
|
||||
|
||||
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
|
||||
}
|
||||
7
src/__Libraries/StellaOps.Evidence/Budgets/UsageStats.cs
Normal file
7
src/__Libraries/StellaOps.Evidence/Budgets/UsageStats.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace StellaOps.Evidence.Budgets;
|
||||
|
||||
public sealed record UsageStats
|
||||
{
|
||||
public long TotalBytes { get; init; }
|
||||
public IReadOnlyDictionary<EvidenceType, long> ByType { get; init; } = new Dictionary<EvidenceType, long>();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.Evidence.Models;
|
||||
|
||||
public sealed record AttestationEvidence(
|
||||
string AttestationId,
|
||||
string Type,
|
||||
string Digest,
|
||||
string SignerKeyId,
|
||||
bool SignatureValid,
|
||||
DateTimeOffset SignedAt,
|
||||
string? RekorLogIndex);
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Evidence.Models;
|
||||
|
||||
@@ -20,83 +20,3 @@ public sealed record EvidenceIndex
|
||||
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);
|
||||
|
||||
32
src/__Libraries/StellaOps.Evidence/Models/ProofConclusion.cs
Normal file
32
src/__Libraries/StellaOps.Evidence/Models/ProofConclusion.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
namespace StellaOps.Evidence.Models;
|
||||
|
||||
/// <summary>
|
||||
/// The conclusion drawn from a proof record.
|
||||
/// </summary>
|
||||
public enum ProofConclusion
|
||||
{
|
||||
/// <summary>
|
||||
/// The component is affected by the vulnerability.
|
||||
/// </summary>
|
||||
Affected,
|
||||
|
||||
/// <summary>
|
||||
/// The component is not affected by the vulnerability.
|
||||
/// </summary>
|
||||
NotAffected,
|
||||
|
||||
/// <summary>
|
||||
/// The vulnerability has been fixed in this component.
|
||||
/// </summary>
|
||||
Fixed,
|
||||
|
||||
/// <summary>
|
||||
/// The effect is still under investigation.
|
||||
/// </summary>
|
||||
UnderInvestigation,
|
||||
|
||||
/// <summary>
|
||||
/// The proof is inconclusive.
|
||||
/// </summary>
|
||||
Inconclusive
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Evidence.Models;
|
||||
@@ -72,42 +71,11 @@ public sealed record ProofRecord
|
||||
/// <summary>
|
||||
/// Gets additional attributes for extensibility.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Attributes { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
public ImmutableDictionary<string, string> Attributes { get; init; }
|
||||
= ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the content-addressed digest of this proof for deduplication.
|
||||
/// </summary>
|
||||
public string? Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The conclusion drawn from a proof record.
|
||||
/// </summary>
|
||||
public enum ProofConclusion
|
||||
{
|
||||
/// <summary>
|
||||
/// The component is affected by the vulnerability.
|
||||
/// </summary>
|
||||
Affected,
|
||||
|
||||
/// <summary>
|
||||
/// The component is not affected by the vulnerability.
|
||||
/// </summary>
|
||||
NotAffected,
|
||||
|
||||
/// <summary>
|
||||
/// The vulnerability has been fixed in this component.
|
||||
/// </summary>
|
||||
Fixed,
|
||||
|
||||
/// <summary>
|
||||
/// The effect is still under investigation.
|
||||
/// </summary>
|
||||
UnderInvestigation,
|
||||
|
||||
/// <summary>
|
||||
/// The proof is inconclusive.
|
||||
/// </summary>
|
||||
Inconclusive
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Evidence.Models;
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace StellaOps.Evidence.Models;
|
||||
|
||||
public sealed record SbomEvidence(
|
||||
string SbomId,
|
||||
string Format,
|
||||
string Digest,
|
||||
string? Uri,
|
||||
int ComponentCount,
|
||||
DateTimeOffset GeneratedAt);
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Evidence.Models;
|
||||
|
||||
public sealed record ToolChainEvidence(
|
||||
string ScannerVersion,
|
||||
string SbomGeneratorVersion,
|
||||
string ReachabilityEngineVersion,
|
||||
string AttestorVersion,
|
||||
string PolicyEngineVersion,
|
||||
ImmutableDictionary<string, string> AdditionalTools);
|
||||
17
src/__Libraries/StellaOps.Evidence/Models/UnknownEvidence.cs
Normal file
17
src/__Libraries/StellaOps.Evidence/Models/UnknownEvidence.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace StellaOps.Evidence.Models;
|
||||
|
||||
public sealed record UnknownEvidence(
|
||||
string UnknownId,
|
||||
string ReasonCode,
|
||||
string Description,
|
||||
string? ComponentPurl,
|
||||
string? VulnerabilityId,
|
||||
UnknownSeverity Severity);
|
||||
|
||||
public enum UnknownSeverity
|
||||
{
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
Critical
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace StellaOps.Evidence.Models;
|
||||
|
||||
public sealed record VerdictReference(
|
||||
string VerdictId,
|
||||
string Digest,
|
||||
VerdictOutcome Outcome,
|
||||
string? PolicyVersion);
|
||||
|
||||
public enum VerdictOutcome
|
||||
{
|
||||
Pass,
|
||||
Fail,
|
||||
Warn,
|
||||
Unknown
|
||||
}
|
||||
11
src/__Libraries/StellaOps.Evidence/Models/VexEvidence.cs
Normal file
11
src/__Libraries/StellaOps.Evidence/Models/VexEvidence.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Evidence.Models;
|
||||
|
||||
public sealed record VexEvidence(
|
||||
string VexId,
|
||||
string Format,
|
||||
string Digest,
|
||||
string Source,
|
||||
int StatementCount,
|
||||
ImmutableArray<string> AffectedVulnerabilities);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.Evidence.Retention;
|
||||
|
||||
/// <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,10 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using StellaOps.Evidence.Budgets;
|
||||
|
||||
namespace StellaOps.Evidence.Retention;
|
||||
|
||||
public sealed record MigratedItem(Guid ItemId, RetentionTier FromTier, RetentionTier ToTier);
|
||||
@@ -0,0 +1,33 @@
|
||||
using StellaOps.Evidence.Budgets;
|
||||
|
||||
namespace StellaOps.Evidence.Retention;
|
||||
|
||||
public sealed partial class RetentionTierManager
|
||||
{
|
||||
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).ConfigureAwait(false);
|
||||
foreach (var item in items.Where(i => i.Tier != RetentionTier.Hot))
|
||||
{
|
||||
await RestoreToHotAsync(item, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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).ConfigureAwait(false);
|
||||
await _repository.UpdateContentAsync(item.Id, content, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await _repository.MoveToTierAsync(item.Id, RetentionTier.Hot, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using StellaOps.Evidence.Budgets;
|
||||
|
||||
namespace StellaOps.Evidence.Retention;
|
||||
|
||||
public sealed partial class RetentionTierManager
|
||||
{
|
||||
private Task<byte[]> CompressAsync(
|
||||
EvidenceItem item,
|
||||
CompressionLevel level,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return Task.FromException<byte[]>(new NotSupportedException(
|
||||
"Compression requires repository content retrieval, which is not implemented."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using StellaOps.Evidence.Budgets;
|
||||
|
||||
namespace StellaOps.Evidence.Retention;
|
||||
|
||||
public sealed partial class RetentionTierManager
|
||||
{
|
||||
public RetentionTier GetCurrentTier(EvidenceItem item)
|
||||
{
|
||||
var budget = _options.CurrentValue;
|
||||
var age = _timeProvider.GetUtcNow() - 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using StellaOps.Evidence.Budgets;
|
||||
|
||||
namespace StellaOps.Evidence.Retention;
|
||||
|
||||
public sealed partial class RetentionTierManager
|
||||
{
|
||||
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).ConfigureAwait(false);
|
||||
await _repository.UpdateContentAsync(item.Id, compressed, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await _repository.MoveToTierAsync(item.Id, targetTier, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,7 @@ 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
|
||||
public sealed partial class RetentionTierManager : IRetentionTierManager
|
||||
{
|
||||
private readonly IEvidenceRepository _repository;
|
||||
private readonly IArchiveStorage _archiveStorage;
|
||||
@@ -37,28 +30,28 @@ public sealed class RetentionTierManager : IRetentionTierManager
|
||||
|
||||
// Hot -> Warm
|
||||
var hotExpiry = now - budget.RetentionPolicies[RetentionTier.Hot].Duration;
|
||||
var toWarm = await _repository.GetOlderThanAsync(RetentionTier.Hot, hotExpiry, ct);
|
||||
var toWarm = await _repository.GetOlderThanAsync(RetentionTier.Hot, hotExpiry, ct).ConfigureAwait(false);
|
||||
foreach (var item in toWarm)
|
||||
{
|
||||
await MigrateAsync(item, RetentionTier.Warm, ct);
|
||||
await MigrateAsync(item, RetentionTier.Warm, ct).ConfigureAwait(false);
|
||||
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);
|
||||
var toCold = await _repository.GetOlderThanAsync(RetentionTier.Warm, warmExpiry, ct).ConfigureAwait(false);
|
||||
foreach (var item in toCold)
|
||||
{
|
||||
await MigrateAsync(item, RetentionTier.Cold, ct);
|
||||
await MigrateAsync(item, RetentionTier.Cold, ct).ConfigureAwait(false);
|
||||
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);
|
||||
var toArchive = await _repository.GetOlderThanAsync(RetentionTier.Cold, coldExpiry, ct).ConfigureAwait(false);
|
||||
foreach (var item in toArchive)
|
||||
{
|
||||
await MigrateAsync(item, RetentionTier.Archive, ct);
|
||||
await MigrateAsync(item, RetentionTier.Archive, ct).ConfigureAwait(false);
|
||||
migrated.Add(new MigratedItem(item.Id, RetentionTier.Cold, RetentionTier.Archive));
|
||||
}
|
||||
|
||||
@@ -68,86 +61,4 @@ public sealed class RetentionTierManager : IRetentionTierManager
|
||||
Items = migrated
|
||||
};
|
||||
}
|
||||
|
||||
public RetentionTier GetCurrentTier(EvidenceItem item)
|
||||
{
|
||||
var budget = _options.CurrentValue;
|
||||
var age = _timeProvider.GetUtcNow() - 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 Task<byte[]> CompressAsync(
|
||||
EvidenceItem item,
|
||||
CompressionLevel level,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return Task.FromException<byte[]>(new NotSupportedException(
|
||||
"Compression requires repository content retrieval, which is not implemented."));
|
||||
}
|
||||
}
|
||||
|
||||
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,7 @@
|
||||
namespace StellaOps.Evidence.Retention;
|
||||
|
||||
public sealed record TierMigrationResult
|
||||
{
|
||||
public required int MigratedCount { get; init; }
|
||||
public IReadOnlyList<MigratedItem> Items { get; init; } = [];
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
using StellaOps.Canonical.Json;
|
||||
using StellaOps.Evidence.Models;
|
||||
using System.Security.Cryptography;
|
||||
@@ -14,7 +13,7 @@ namespace StellaOps.Evidence.Serialization;
|
||||
/// </summary>
|
||||
public static class EvidenceIndexSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
@@ -29,20 +28,20 @@ public static class EvidenceIndexSerializer
|
||||
|
||||
public static string Serialize(EvidenceIndex index)
|
||||
{
|
||||
var canonicalBytes = CanonJson.Canonicalize(index, JsonOptions);
|
||||
var canonicalBytes = CanonJson.Canonicalize(index, _jsonOptions);
|
||||
return Encoding.UTF8.GetString(canonicalBytes);
|
||||
}
|
||||
|
||||
public static EvidenceIndex Deserialize(string json)
|
||||
{
|
||||
return JsonSerializer.Deserialize<EvidenceIndex>(json, JsonOptions)
|
||||
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 canonicalBytes = CanonJson.Canonicalize(withoutDigest, JsonOptions);
|
||||
var canonicalBytes = CanonJson.Canonicalize(withoutDigest, _jsonOptions);
|
||||
var hash = SHA256.HashData(canonicalBytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
using StellaOps.Evidence.Models;
|
||||
using StellaOps.Evidence.Serialization;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Evidence.Services;
|
||||
|
||||
public sealed partial class EvidenceLinker
|
||||
{
|
||||
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 orderedSboms = sboms
|
||||
.OrderBy(s => s.Digest, StringComparer.Ordinal)
|
||||
.ThenBy(s => s.SbomId, StringComparer.Ordinal)
|
||||
.ThenBy(s => s.Format, StringComparer.Ordinal)
|
||||
.ThenBy(s => NormalizeSortKey(s.Uri), StringComparer.Ordinal)
|
||||
.ThenBy(s => s.ComponentCount)
|
||||
.ThenBy(s => s.GeneratedAt.UtcDateTime.Ticks)
|
||||
.ToImmutableArray();
|
||||
|
||||
var orderedAttestations = attestations
|
||||
.OrderBy(a => a.Type, StringComparer.Ordinal)
|
||||
.ThenBy(a => a.Digest, StringComparer.Ordinal)
|
||||
.ThenBy(a => a.SignerKeyId, StringComparer.Ordinal)
|
||||
.ThenBy(a => a.AttestationId, StringComparer.Ordinal)
|
||||
.ThenBy(a => a.SignedAt.UtcDateTime.Ticks)
|
||||
.ThenBy(a => a.SignatureValid)
|
||||
.ThenBy(a => NormalizeSortKey(a.RekorLogIndex), StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var orderedVex = vexDocuments
|
||||
.Select(v => v with
|
||||
{
|
||||
AffectedVulnerabilities = v.AffectedVulnerabilities
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToImmutableArray()
|
||||
})
|
||||
.OrderBy(v => v.Digest, StringComparer.Ordinal)
|
||||
.ThenBy(v => v.VexId, StringComparer.Ordinal)
|
||||
.ThenBy(v => v.Format, StringComparer.Ordinal)
|
||||
.ThenBy(v => v.Source, StringComparer.Ordinal)
|
||||
.ThenBy(v => v.StatementCount)
|
||||
.ToImmutableArray();
|
||||
|
||||
var orderedReachability = reachabilityProofs
|
||||
.OrderBy(r => r.VulnerabilityId, StringComparer.Ordinal)
|
||||
.ThenBy(r => r.ComponentPurl, StringComparer.Ordinal)
|
||||
.ThenBy(r => r.Status)
|
||||
.ThenBy(r => NormalizeSortKey(r.EntryPoint), StringComparer.Ordinal)
|
||||
.ThenBy(r => r.Digest, StringComparer.Ordinal)
|
||||
.ThenBy(r => r.ProofId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var orderedUnknowns = unknowns
|
||||
.OrderBy(u => u.ReasonCode, StringComparer.Ordinal)
|
||||
.ThenBy(u => NormalizeSortKey(u.VulnerabilityId), StringComparer.Ordinal)
|
||||
.ThenBy(u => NormalizeSortKey(u.ComponentPurl), StringComparer.Ordinal)
|
||||
.ThenBy(u => u.Severity)
|
||||
.ThenBy(u => u.UnknownId, StringComparer.Ordinal)
|
||||
.ThenBy(u => u.Description, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var index = new EvidenceIndex
|
||||
{
|
||||
IndexId = _guidProvider.NewGuid().ToString("D", CultureInfo.InvariantCulture),
|
||||
SchemaVersion = "1.0.0",
|
||||
Verdict = verdict,
|
||||
Sboms = orderedSboms,
|
||||
Attestations = orderedAttestations,
|
||||
VexDocuments = orderedVex,
|
||||
ReachabilityProofs = orderedReachability,
|
||||
Unknowns = orderedUnknowns,
|
||||
ToolChain = toolChain,
|
||||
RunManifestDigest = runManifestDigest,
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
return EvidenceIndexSerializer.WithDigest(index);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Evidence.Models;
|
||||
using StellaOps.Evidence.Serialization;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Evidence.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Collects evidence entries and builds a deterministic EvidenceIndex.
|
||||
/// </summary>
|
||||
public sealed class EvidenceLinker : IEvidenceLinker
|
||||
public sealed partial class EvidenceLinker : IEvidenceLinker
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
@@ -81,104 +77,5 @@ public sealed class EvidenceLinker : IEvidenceLinker
|
||||
}
|
||||
}
|
||||
|
||||
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 orderedSboms = sboms
|
||||
.OrderBy(s => s.Digest, StringComparer.Ordinal)
|
||||
.ThenBy(s => s.SbomId, StringComparer.Ordinal)
|
||||
.ThenBy(s => s.Format, StringComparer.Ordinal)
|
||||
.ThenBy(s => NormalizeSortKey(s.Uri), StringComparer.Ordinal)
|
||||
.ThenBy(s => s.ComponentCount)
|
||||
.ThenBy(s => s.GeneratedAt.UtcDateTime.Ticks)
|
||||
.ToImmutableArray();
|
||||
|
||||
var orderedAttestations = attestations
|
||||
.OrderBy(a => a.Type, StringComparer.Ordinal)
|
||||
.ThenBy(a => a.Digest, StringComparer.Ordinal)
|
||||
.ThenBy(a => a.SignerKeyId, StringComparer.Ordinal)
|
||||
.ThenBy(a => a.AttestationId, StringComparer.Ordinal)
|
||||
.ThenBy(a => a.SignedAt.UtcDateTime.Ticks)
|
||||
.ThenBy(a => a.SignatureValid)
|
||||
.ThenBy(a => NormalizeSortKey(a.RekorLogIndex), StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var orderedVex = vexDocuments
|
||||
.Select(v => v with
|
||||
{
|
||||
AffectedVulnerabilities = v.AffectedVulnerabilities
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToImmutableArray()
|
||||
})
|
||||
.OrderBy(v => v.Digest, StringComparer.Ordinal)
|
||||
.ThenBy(v => v.VexId, StringComparer.Ordinal)
|
||||
.ThenBy(v => v.Format, StringComparer.Ordinal)
|
||||
.ThenBy(v => v.Source, StringComparer.Ordinal)
|
||||
.ThenBy(v => v.StatementCount)
|
||||
.ToImmutableArray();
|
||||
|
||||
var orderedReachability = reachabilityProofs
|
||||
.OrderBy(r => r.VulnerabilityId, StringComparer.Ordinal)
|
||||
.ThenBy(r => r.ComponentPurl, StringComparer.Ordinal)
|
||||
.ThenBy(r => r.Status)
|
||||
.ThenBy(r => NormalizeSortKey(r.EntryPoint), StringComparer.Ordinal)
|
||||
.ThenBy(r => r.Digest, StringComparer.Ordinal)
|
||||
.ThenBy(r => r.ProofId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var orderedUnknowns = unknowns
|
||||
.OrderBy(u => u.ReasonCode, StringComparer.Ordinal)
|
||||
.ThenBy(u => NormalizeSortKey(u.VulnerabilityId), StringComparer.Ordinal)
|
||||
.ThenBy(u => NormalizeSortKey(u.ComponentPurl), StringComparer.Ordinal)
|
||||
.ThenBy(u => u.Severity)
|
||||
.ThenBy(u => u.UnknownId, StringComparer.Ordinal)
|
||||
.ThenBy(u => u.Description, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var index = new EvidenceIndex
|
||||
{
|
||||
IndexId = _guidProvider.NewGuid().ToString("D", CultureInfo.InvariantCulture),
|
||||
SchemaVersion = "1.0.0",
|
||||
Verdict = verdict,
|
||||
Sboms = orderedSboms,
|
||||
Attestations = orderedAttestations,
|
||||
VexDocuments = orderedVex,
|
||||
ReachabilityProofs = orderedReachability,
|
||||
Unknowns = orderedUnknowns,
|
||||
ToolChain = toolChain,
|
||||
RunManifestDigest = runManifestDigest,
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
return EvidenceIndexSerializer.WithDigest(index);
|
||||
}
|
||||
|
||||
private static string NormalizeSortKey(string? value) => value ?? string.Empty;
|
||||
}
|
||||
|
||||
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,14 @@
|
||||
using StellaOps.Evidence.Models;
|
||||
|
||||
namespace StellaOps.Evidence.Services;
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0082-T | DONE | Revalidated 2026-01-08; open findings tracked in audit report. |
|
||||
| AUDIT-0082-A | DONE | Applied 2026-01-13; determinism, schema validation, budget async, retention safeguards, tests. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| REMED-07 | DONE | Remediated csproj findings (split budget/retention/models/services/validation, ConfigureAwait(false), private field naming); tests passed 2026-02-04 (24 tests). |
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
using Json.Schema;
|
||||
using StellaOps.Evidence.Models;
|
||||
using StellaOps.Evidence.Serialization;
|
||||
@@ -86,11 +85,3 @@ public sealed class EvidenceIndexValidator : IEvidenceIndexValidator
|
||||
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,8 @@
|
||||
using StellaOps.Evidence.Models;
|
||||
|
||||
namespace StellaOps.Evidence.Validation;
|
||||
|
||||
public interface IEvidenceIndexValidator
|
||||
{
|
||||
ValidationResult Validate(EvidenceIndex index);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace StellaOps.Evidence.Validation;
|
||||
|
||||
public sealed record ValidationError(string Field, string Message);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace StellaOps.Evidence.Validation;
|
||||
|
||||
public sealed record ValidationResult(bool IsValid, IReadOnlyList<ValidationError> Errors);
|
||||
Reference in New Issue
Block a user