This commit is contained in:
master
2026-02-04 19:59:20 +02:00
parent 557feefdc3
commit 5548cf83bf
1479 changed files with 53557 additions and 40339 deletions

View File

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

View File

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

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

View File

@@ -0,0 +1,9 @@
namespace StellaOps.Evidence.Budgets;
public enum CompressionLevel
{
None,
Fast,
Optimal,
Maximum
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,14 @@
namespace StellaOps.Evidence.Budgets;
public enum EvidenceType
{
Verdict,
PolicyTrace,
CallGraph,
RuntimeCapture,
Sbom,
Vex,
Attestation,
PathWitness,
Advisory
}

View File

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

View File

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

View 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);

View File

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

View 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
}

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

View File

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

View File

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

View 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
}

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
namespace StellaOps.Evidence.Models;
public sealed record SbomEvidence(
string SbomId,
string Format,
string Digest,
string? Uri,
int ComponentCount,
DateTimeOffset GeneratedAt);

View File

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

View 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
}

View File

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

View 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);

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
using StellaOps.Evidence.Budgets;
namespace StellaOps.Evidence.Retention;
public sealed record MigratedItem(Guid ItemId, RetentionTier FromTier, RetentionTier ToTier);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
namespace StellaOps.Evidence.Retention;
public sealed record TierMigrationResult
{
public required int MigratedCount { get; init; }
public IReadOnlyList<MigratedItem> Items { get; init; } = [];
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
using StellaOps.Evidence.Models;
namespace StellaOps.Evidence.Validation;
public interface IEvidenceIndexValidator
{
ValidationResult Validate(EvidenceIndex index);
}

View File

@@ -0,0 +1,3 @@
namespace StellaOps.Evidence.Validation;
public sealed record ValidationError(string Field, string Message);

View File

@@ -0,0 +1,3 @@
namespace StellaOps.Evidence.Validation;
public sealed record ValidationResult(bool IsValid, IReadOnlyList<ValidationError> Errors);