notify doctors work, audit work, new product advisory sprints

This commit is contained in:
master
2026-01-13 08:36:29 +02:00
parent b8868a5f13
commit 9ca7cb183e
343 changed files with 24492 additions and 3544 deletions

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -5,8 +6,8 @@ namespace StellaOps.Evidence.Budgets;
public interface IEvidenceBudgetService
{
BudgetCheckResult CheckBudget(Guid scanId, EvidenceItem item);
BudgetStatus GetBudgetStatus(Guid scanId);
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);
}
@@ -26,10 +27,11 @@ public sealed class EvidenceBudgetService : IEvidenceBudgetService
_logger = logger;
}
public BudgetCheckResult CheckBudget(Guid scanId, EvidenceItem item)
public async Task<BudgetCheckResult> CheckBudgetAsync(Guid scanId, EvidenceItem item, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(item);
var budget = _options.CurrentValue;
var currentUsage = GetCurrentUsage(scanId);
var currentUsage = await GetCurrentUsageAsync(scanId, ct);
var issues = new List<string>();
@@ -37,7 +39,9 @@ public sealed class EvidenceBudgetService : IEvidenceBudgetService
var projectedTotal = currentUsage.TotalBytes + item.SizeBytes;
if (projectedTotal > budget.MaxScanSizeBytes)
{
issues.Add($"Would exceed total budget: {projectedTotal:N0} > {budget.MaxScanSizeBytes:N0} bytes");
issues.Add(
$"Would exceed total budget: {projectedTotal.ToString("N0", CultureInfo.InvariantCulture)} > " +
$"{budget.MaxScanSizeBytes.ToString("N0", CultureInfo.InvariantCulture)} bytes");
}
// Check per-type budget
@@ -47,7 +51,9 @@ public sealed class EvidenceBudgetService : IEvidenceBudgetService
var projectedType = typeUsage + item.SizeBytes;
if (projectedType > typeLimit)
{
issues.Add($"Would exceed {item.Type} budget: {projectedType:N0} > {typeLimit:N0} bytes");
issues.Add(
$"Would exceed {item.Type} budget: {projectedType.ToString("N0", CultureInfo.InvariantCulture)} > " +
$"{typeLimit.ToString("N0", CultureInfo.InvariantCulture)} bytes");
}
}
@@ -66,10 +72,10 @@ public sealed class EvidenceBudgetService : IEvidenceBudgetService
};
}
public BudgetStatus GetBudgetStatus(Guid scanId)
public async Task<BudgetStatus> GetBudgetStatusAsync(Guid scanId, CancellationToken ct)
{
var budget = _options.CurrentValue;
var usage = GetCurrentUsage(scanId);
var usage = await GetCurrentUsageAsync(scanId, ct);
return new BudgetStatus
{
@@ -92,13 +98,10 @@ public sealed class EvidenceBudgetService : IEvidenceBudgetService
};
}
public async Task<PruneResult> PruneToFitAsync(
Guid scanId,
long targetBytes,
CancellationToken ct)
public async Task<PruneResult> PruneToFitAsync(Guid scanId, long targetBytes, CancellationToken ct)
{
var budget = _options.CurrentValue;
var usage = GetCurrentUsage(scanId);
var usage = await GetCurrentUsageAsync(scanId, ct);
if (usage.TotalBytes <= targetBytes)
{
@@ -113,6 +116,8 @@ public sealed class EvidenceBudgetService : IEvidenceBudgetService
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;
@@ -158,15 +163,15 @@ public sealed class EvidenceBudgetService : IEvidenceBudgetService
};
}
private UsageStats GetCurrentUsage(Guid scanId)
private async Task<UsageStats> GetCurrentUsageAsync(Guid scanId, CancellationToken ct)
{
// Implementation to calculate current usage from repository
var items = _repository.GetByScanIdAsync(scanId, CancellationToken.None)
.GetAwaiter().GetResult();
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

View File

@@ -15,24 +15,27 @@ public sealed class RetentionTierManager : IRetentionTierManager
private readonly IEvidenceRepository _repository;
private readonly IArchiveStorage _archiveStorage;
private readonly IOptionsMonitor<EvidenceBudget> _options;
private readonly TimeProvider _timeProvider;
public RetentionTierManager(
IEvidenceRepository repository,
IArchiveStorage archiveStorage,
IOptionsMonitor<EvidenceBudget> options)
IOptionsMonitor<EvidenceBudget> options,
TimeProvider? timeProvider = null)
{
_repository = repository;
_archiveStorage = archiveStorage;
_options = options;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<TierMigrationResult> RunMigrationAsync(CancellationToken ct)
{
var budget = _options.CurrentValue;
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
var migrated = new List<MigratedItem>();
// Hot Warm
// Hot -> Warm
var hotExpiry = now - budget.RetentionPolicies[RetentionTier.Hot].Duration;
var toWarm = await _repository.GetOlderThanAsync(RetentionTier.Hot, hotExpiry, ct);
foreach (var item in toWarm)
@@ -41,7 +44,7 @@ public sealed class RetentionTierManager : IRetentionTierManager
migrated.Add(new MigratedItem(item.Id, RetentionTier.Hot, RetentionTier.Warm));
}
// Warm Cold
// Warm -> Cold
var warmExpiry = now - budget.RetentionPolicies[RetentionTier.Warm].Duration;
var toCold = await _repository.GetOlderThanAsync(RetentionTier.Warm, warmExpiry, ct);
foreach (var item in toCold)
@@ -50,7 +53,7 @@ public sealed class RetentionTierManager : IRetentionTierManager
migrated.Add(new MigratedItem(item.Id, RetentionTier.Warm, RetentionTier.Cold));
}
// Cold Archive
// Cold -> Archive
var coldExpiry = now - budget.RetentionPolicies[RetentionTier.Cold].Duration;
var toArchive = await _repository.GetOlderThanAsync(RetentionTier.Cold, coldExpiry, ct);
foreach (var item in toArchive)
@@ -69,7 +72,7 @@ public sealed class RetentionTierManager : IRetentionTierManager
public RetentionTier GetCurrentTier(EvidenceItem item)
{
var budget = _options.CurrentValue;
var age = DateTimeOffset.UtcNow - item.CreatedAt;
var age = _timeProvider.GetUtcNow() - item.CreatedAt;
if (age < budget.RetentionPolicies[RetentionTier.Hot].Duration)
return RetentionTier.Hot;
@@ -122,15 +125,13 @@ public sealed class RetentionTierManager : IRetentionTierManager
await _repository.MoveToTierAsync(item.Id, RetentionTier.Hot, ct);
}
private async Task<byte[]> CompressAsync(
private Task<byte[]> CompressAsync(
EvidenceItem item,
CompressionLevel level,
CancellationToken ct)
{
// Placeholder for compression logic
// In real implementation, would read content, compress, and return
await Task.CompletedTask;
return Array.Empty<byte>();
return Task.FromException<byte[]>(new NotSupportedException(
"Compression requires repository content retrieval, which is not implemented."));
}
}

View File

@@ -13,18 +13,22 @@ namespace StellaOps.Evidence.Serialization;
/// </summary>
public static class EvidenceIndexSerializer
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
Encoder = JavaScriptEncoder.Default,
PropertyNameCaseInsensitive = true,
Converters =
{
new JsonStringEnumConverter(null, allowIntegerValues: false)
}
};
public static string Serialize(EvidenceIndex index)
{
var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(index, JsonOptions);
var canonicalBytes = CanonJson.CanonicalizeParsedJson(jsonBytes);
var canonicalBytes = CanonJson.Canonicalize(index, JsonOptions);
return Encoding.UTF8.GetString(canonicalBytes);
}
@@ -37,8 +41,8 @@ public static class EvidenceIndexSerializer
public static string ComputeDigest(EvidenceIndex index)
{
var withoutDigest = index with { IndexDigest = null };
var json = Serialize(withoutDigest);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
var canonicalBytes = CanonJson.Canonicalize(withoutDigest, JsonOptions);
var hash = SHA256.HashData(canonicalBytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}

View File

@@ -1,6 +1,8 @@
using System.Collections.Immutable;
using StellaOps.Evidence.Models;
using StellaOps.Evidence.Serialization;
using System.Globalization;
using StellaOps.Determinism;
namespace StellaOps.Evidence.Services;
@@ -9,6 +11,8 @@ namespace StellaOps.Evidence.Services;
/// </summary>
public sealed class EvidenceLinker : IEvidenceLinker
{
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
private readonly object _lock = new();
private readonly List<SbomEvidence> _sboms = [];
private readonly List<AttestationEvidence> _attestations = [];
@@ -17,6 +21,17 @@ public sealed class EvidenceLinker : IEvidenceLinker
private readonly List<UnknownEvidence> _unknowns = [];
private ToolChainEvidence? _toolChain;
public EvidenceLinker()
: this(TimeProvider.System, SystemGuidProvider.Instance)
{
}
public EvidenceLinker(TimeProvider timeProvider, IGuidProvider guidProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
}
public void AddSbom(SbomEvidence sbom)
{
lock (_lock)
@@ -84,23 +99,76 @@ public sealed class EvidenceLinker : IEvidenceLinker
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 = Guid.NewGuid().ToString(),
IndexId = _guidProvider.NewGuid().ToString("D", CultureInfo.InvariantCulture),
SchemaVersion = "1.0.0",
Verdict = verdict,
Sboms = sboms,
Attestations = attestations,
VexDocuments = vexDocuments,
ReachabilityProofs = reachabilityProofs,
Unknowns = unknowns,
Sboms = orderedSboms,
Attestations = orderedAttestations,
VexDocuments = orderedVex,
ReachabilityProofs = orderedReachability,
Unknowns = orderedUnknowns,
ToolChain = toolChain,
RunManifestDigest = runManifestDigest,
CreatedAt = DateTimeOffset.UtcNow
CreatedAt = _timeProvider.GetUtcNow()
};
return EvidenceIndexSerializer.WithDigest(index);
}
private static string NormalizeSortKey(string? value) => value ?? string.Empty;
}
public interface IEvidenceLinker

View File

@@ -10,8 +10,15 @@ public sealed class EvidenceQueryService : IEvidenceQueryService
public IEnumerable<AttestationEvidence> GetAttestationsForSbom(
EvidenceIndex index, string sbomDigest)
{
var sbomExists = index.Sboms.Any(s => string.Equals(s.Digest, sbomDigest, StringComparison.Ordinal));
if (!sbomExists)
{
return Array.Empty<AttestationEvidence>();
}
return index.Attestations
.Where(a => a.Type == "sbom" && index.Sboms.Any(s => s.Digest == sbomDigest));
.Where(a => string.Equals(a.Type, "sbom", StringComparison.Ordinal) &&
string.Equals(a.Digest, sbomDigest, StringComparison.Ordinal));
}
public IEnumerable<ReachabilityEvidence> GetReachabilityForVulnerability(

View File

@@ -15,6 +15,7 @@
<ItemGroup>
<ProjectReference Include="..\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
<ProjectReference Include="..\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -7,4 +7,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| --- | --- | --- |
| AUDIT-0082-M | DONE | Revalidated 2026-01-08; open findings tracked in audit report. |
| AUDIT-0082-T | DONE | Revalidated 2026-01-08; open findings tracked in audit report. |
| AUDIT-0082-A | TODO | Revalidated 2026-01-08 (open findings). |
| AUDIT-0082-A | DONE | Applied 2026-01-13; determinism, schema validation, budget async, retention safeguards, tests. |

View File

@@ -1,14 +1,46 @@
using StellaOps.Evidence.Models;
using StellaOps.Evidence.Serialization;
using System.Text.Json;
using Json.Schema;
namespace StellaOps.Evidence.Validation;
public sealed class EvidenceIndexValidator : IEvidenceIndexValidator
{
private readonly JsonSchema _schema;
public EvidenceIndexValidator()
{
var schemaJson = SchemaLoader.LoadSchema("evidence-index.schema.json");
_schema = JsonSchema.FromText(schemaJson, new BuildOptions
{
SchemaRegistry = new SchemaRegistry()
});
}
public ValidationResult Validate(EvidenceIndex index)
{
ArgumentNullException.ThrowIfNull(index);
var errors = new List<ValidationError>();
var json = EvidenceIndexSerializer.Serialize(index);
using var document = JsonDocument.Parse(json);
var schemaResult = _schema.Evaluate(document.RootElement);
if (!schemaResult.IsValid)
{
if (schemaResult.Errors is not null)
{
foreach (var error in schemaResult.Errors)
{
errors.Add(new ValidationError("Schema", error.Value ?? "Unknown error"));
}
}
else
{
errors.Add(new ValidationError("Schema", "Schema validation failed"));
}
}
if (index.Sboms.Length == 0)
{
errors.Add(new ValidationError("Sboms", "At least one SBOM required"));
@@ -25,7 +57,7 @@ public sealed class EvidenceIndexValidator : IEvidenceIndexValidator
foreach (var proof in index.ReachabilityProofs)
{
if (proof.Status == ReachabilityStatus.Inconclusive &&
!index.Unknowns.Any(u => u.VulnerabilityId == proof.VulnerabilityId))
!index.Unknowns.Any(u => string.Equals(u.VulnerabilityId, proof.VulnerabilityId, StringComparison.Ordinal)))
{
errors.Add(new ValidationError("ReachabilityProofs",
$"Inconclusive reachability for {proof.VulnerabilityId} not recorded as unknown"));