// ----------------------------------------------------------------------------- // DeltaCompareEndpoints.cs // Sprint: SPRINT_4200_0002_0006_delta_compare_api // Description: HTTP endpoints for delta/compare view API. // ----------------------------------------------------------------------------- using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using StellaOps.Scanner.WebService.Contracts; using StellaOps.Scanner.WebService.Security; using System.Collections.Concurrent; using System.Security.Cryptography; using System.Text; using static StellaOps.Localization.T; namespace StellaOps.Scanner.WebService.Endpoints; /// /// Endpoints for delta/compare view - comparing scan snapshots. /// Per SPRINT_4200_0002_0006. /// internal static class DeltaCompareEndpoints { /// /// Maps delta compare endpoints. /// public static void MapDeltaCompareEndpoints(this RouteGroupBuilder apiGroup, string prefix = "/delta") { ArgumentNullException.ThrowIfNull(apiGroup); var group = apiGroup.MapGroup(prefix) .WithTags("DeltaCompare"); // POST /v1/delta/compare - Full comparison between two snapshots group.MapPost("/compare", HandleCompareAsync) .WithName("scanner.delta.compare") .WithDescription(_t("scanner.delta.compare_description")) .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .RequireAuthorization(ScannerPolicies.ScansRead); // GET /v1/delta/quick - Quick summary for header display group.MapGet("/quick", HandleQuickDiffAsync) .WithName("scanner.delta.quick") .WithDescription(_t("scanner.delta.quick_description")) .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .RequireAuthorization(ScannerPolicies.ScansRead); // GET /v1/delta/{comparisonId} - Get cached comparison by ID group.MapGet("/{comparisonId}", HandleGetComparisonAsync) .WithName("scanner.delta.get") .WithDescription(_t("scanner.delta.get_description")) .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(ScannerPolicies.ScansRead); } private static async Task HandleCompareAsync( DeltaCompareRequestDto request, IDeltaCompareService compareService, HttpContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(compareService); if (string.IsNullOrWhiteSpace(request.BaseDigest)) { return Results.BadRequest(new { type = "validation-error", title = _t("scanner.delta.invalid_base_digest"), detail = _t("scanner.delta.base_digest_required") }); } if (string.IsNullOrWhiteSpace(request.TargetDigest)) { return Results.BadRequest(new { type = "validation-error", title = _t("scanner.delta.invalid_target_digest"), detail = _t("scanner.delta.target_digest_required") }); } var result = await compareService.CompareAsync(request, cancellationToken); return Results.Ok(result); } private static async Task HandleQuickDiffAsync( string baseDigest, string targetDigest, IDeltaCompareService compareService, HttpContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(compareService); if (string.IsNullOrWhiteSpace(baseDigest)) { return Results.BadRequest(new { type = "validation-error", title = _t("scanner.delta.invalid_base_digest"), detail = _t("scanner.delta.base_digest_required") }); } if (string.IsNullOrWhiteSpace(targetDigest)) { return Results.BadRequest(new { type = "validation-error", title = _t("scanner.delta.invalid_target_digest"), detail = _t("scanner.delta.target_digest_required") }); } var result = await compareService.GetQuickDiffAsync(baseDigest, targetDigest, cancellationToken); return Results.Ok(result); } private static async Task HandleGetComparisonAsync( string comparisonId, IDeltaCompareService compareService, HttpContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(compareService); if (string.IsNullOrWhiteSpace(comparisonId)) { return Results.BadRequest(new { type = "validation-error", title = _t("scanner.delta.invalid_comparison_id"), detail = _t("scanner.delta.comparison_id_required") }); } var result = await compareService.GetComparisonAsync(comparisonId, cancellationToken); if (result is null) { return Results.NotFound(new { type = "not-found", title = _t("scanner.delta.comparison_not_found"), detail = _tn("scanner.delta.comparison_not_found_detail", ("comparisonId", comparisonId)) }); } return Results.Ok(result); } } /// /// Service interface for delta compare operations. /// Per SPRINT_4200_0002_0006. /// public interface IDeltaCompareService { /// /// Performs a full comparison between two snapshots. /// Task CompareAsync(DeltaCompareRequestDto request, CancellationToken ct = default); /// /// Gets a quick diff summary for the Can I Ship header. /// Task GetQuickDiffAsync(string baseDigest, string targetDigest, CancellationToken ct = default); /// /// Gets a cached comparison by ID. /// Task GetComparisonAsync(string comparisonId, CancellationToken ct = default); } /// /// Default implementation of delta compare service. /// public sealed class DeltaCompareService : IDeltaCompareService { private static readonly (string Ecosystem, string Name, string License)[] ComponentTemplates = [ ("npm", "axios", "MIT"), ("npm", "lodash", "MIT"), ("maven", "org.apache.logging.log4j/log4j-core", "Apache-2.0"), ("maven", "org.springframework/spring-core", "Apache-2.0"), ("pypi", "requests", "Apache-2.0"), ("nuget", "Newtonsoft.Json", "MIT"), ("golang", "golang.org/x/net", "BSD-3-Clause"), ("cargo", "tokio", "MIT"), ]; private static readonly string[] OrderedSeverities = ["critical", "high", "medium", "low", "unknown"]; private readonly ConcurrentDictionary _comparisons = new(StringComparer.OrdinalIgnoreCase); private readonly TimeProvider _timeProvider; public DeltaCompareService(TimeProvider timeProvider) { _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); } public Task CompareAsync(DeltaCompareRequestDto request, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); ArgumentNullException.ThrowIfNull(request); var comparisonId = ComputeComparisonId(request.BaseDigest, request.TargetDigest); if (!_comparisons.TryGetValue(comparisonId, out var fullComparison)) { fullComparison = BuildComparison(request.BaseDigest.Trim(), request.TargetDigest.Trim(), comparisonId); _comparisons[comparisonId] = fullComparison; } return Task.FromResult(ProjectComparison(fullComparison, request)); } public async Task GetQuickDiffAsync(string baseDigest, string targetDigest, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); var comparisonId = ComputeComparisonId(baseDigest, targetDigest); if (!_comparisons.TryGetValue(comparisonId, out var comparison)) { comparison = await CompareAsync( new DeltaCompareRequestDto { BaseDigest = baseDigest, TargetDigest = targetDigest, IncludeComponents = true, IncludePolicyDiff = true, IncludeVulnerabilities = true, IncludeUnchanged = true }, ct).ConfigureAwait(false); } var netBlockingChange = (comparison.Target.SeverityCounts.Critical + comparison.Target.SeverityCounts.High) - (comparison.Base.SeverityCounts.Critical + comparison.Base.SeverityCounts.High); return new QuickDiffSummaryDto { BaseDigest = baseDigest, TargetDigest = targetDigest, CanShip = !string.Equals(comparison.Target.PolicyVerdict, "Block", StringComparison.OrdinalIgnoreCase), RiskDirection = comparison.Summary.RiskDirection, NetBlockingChange = netBlockingChange, CriticalAdded = comparison.Summary.SeverityChanges.CriticalAdded, CriticalRemoved = comparison.Summary.SeverityChanges.CriticalRemoved, HighAdded = comparison.Summary.SeverityChanges.HighAdded, HighRemoved = comparison.Summary.SeverityChanges.HighRemoved, Summary = comparison.Summary.RiskDirection switch { "degraded" => "Risk increased between snapshots.", "improved" => "Risk reduced between snapshots.", _ => "Risk profile is unchanged." } }; } public Task GetComparisonAsync(string comparisonId, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); if (string.IsNullOrWhiteSpace(comparisonId)) { return Task.FromResult(null); } return Task.FromResult(_comparisons.TryGetValue(comparisonId.Trim(), out var comparison) ? comparison : null); } private DeltaCompareResponseDto BuildComparison(string baseDigest, string targetDigest, string comparisonId) { var baseSnapshot = BuildSnapshot(baseDigest); var targetSnapshot = BuildSnapshot(targetDigest); var vulnerabilities = BuildVulnerabilityDiffs(baseSnapshot, targetSnapshot, includeUnchanged: true); var components = BuildComponentDiffs(baseSnapshot, targetSnapshot, includeUnchanged: true); var severityChanges = BuildSeverityChanges(vulnerabilities); var policyDiff = BuildPolicyDiff(baseSnapshot, targetSnapshot, vulnerabilities); var riskScore = (severityChanges.CriticalAdded - severityChanges.CriticalRemoved) * 4 + (severityChanges.HighAdded - severityChanges.HighRemoved) * 3 + (severityChanges.MediumAdded - severityChanges.MediumRemoved) * 2 + (severityChanges.LowAdded - severityChanges.LowRemoved) + ((policyDiff.ShipToBlockCount - policyDiff.BlockToShipCount) * 5); return new DeltaCompareResponseDto { Base = BuildSummary(baseSnapshot), Target = BuildSummary(targetSnapshot), Summary = new DeltaChangeSummaryDto { Added = vulnerabilities.Count(v => v.ChangeType.Equals("Added", StringComparison.Ordinal)), Removed = vulnerabilities.Count(v => v.ChangeType.Equals("Removed", StringComparison.Ordinal)), Modified = vulnerabilities.Count(v => v.ChangeType.Equals("Modified", StringComparison.Ordinal)), Unchanged = vulnerabilities.Count(v => v.ChangeType.Equals("Unchanged", StringComparison.Ordinal)), NetVulnerabilityChange = targetSnapshot.Vulnerabilities.Count - baseSnapshot.Vulnerabilities.Count, NetComponentChange = targetSnapshot.Components.Count - baseSnapshot.Components.Count, SeverityChanges = severityChanges, VerdictChanged = !string.Equals(baseSnapshot.PolicyVerdict, targetSnapshot.PolicyVerdict, StringComparison.OrdinalIgnoreCase), RiskDirection = riskScore > 0 ? "degraded" : riskScore < 0 ? "improved" : "unchanged" }, Vulnerabilities = vulnerabilities, Components = components, PolicyDiff = policyDiff, GeneratedAt = _timeProvider.GetUtcNow(), ComparisonId = comparisonId }; } private DeltaCompareResponseDto ProjectComparison(DeltaCompareResponseDto full, DeltaCompareRequestDto request) { var changeTypeFilter = request.ChangeTypes? .Where(static value => !string.IsNullOrWhiteSpace(value)) .Select(static value => value.Trim()) .ToHashSet(StringComparer.OrdinalIgnoreCase); var severityFilter = request.Severities? .Where(static value => !string.IsNullOrWhiteSpace(value)) .Select(static value => value.Trim()) .ToHashSet(StringComparer.OrdinalIgnoreCase); var filteredVulnerabilities = (full.Vulnerabilities ?? []) .Where(v => request.IncludeUnchanged || !v.ChangeType.Equals("Unchanged", StringComparison.OrdinalIgnoreCase)) .Where(v => changeTypeFilter is null || changeTypeFilter.Contains(v.ChangeType)) .Where(v => severityFilter is null || severityFilter.Contains(EffectiveSeverity(v))) .OrderBy(v => ChangeTypeOrder(v.ChangeType)) .ThenBy(v => v.VulnId, StringComparer.Ordinal) .ThenBy(v => v.Purl, StringComparer.Ordinal) .ToList(); var filteredComponents = (full.Components ?? []) .Where(c => request.IncludeUnchanged || !c.ChangeType.Equals("Unchanged", StringComparison.OrdinalIgnoreCase)) .OrderBy(c => ChangeTypeOrder(c.ChangeType)) .ThenBy(c => c.Purl, StringComparer.Ordinal) .ToList(); return full with { Summary = full.Summary with { Added = filteredVulnerabilities.Count(v => v.ChangeType.Equals("Added", StringComparison.OrdinalIgnoreCase)), Removed = filteredVulnerabilities.Count(v => v.ChangeType.Equals("Removed", StringComparison.OrdinalIgnoreCase)), Modified = filteredVulnerabilities.Count(v => v.ChangeType.Equals("Modified", StringComparison.OrdinalIgnoreCase)), Unchanged = filteredVulnerabilities.Count(v => v.ChangeType.Equals("Unchanged", StringComparison.OrdinalIgnoreCase)), SeverityChanges = BuildSeverityChanges(filteredVulnerabilities), }, Vulnerabilities = request.IncludeVulnerabilities ? filteredVulnerabilities : null, Components = request.IncludeComponents ? filteredComponents : null, PolicyDiff = request.IncludePolicyDiff ? full.PolicyDiff : null }; } private Snapshot BuildSnapshot(string digest) { var hash = SHA256.HashData(Encoding.UTF8.GetBytes(digest)); var createdAt = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero) .AddSeconds(BitConverter.ToUInt32(hash.AsSpan(0, sizeof(uint))) % (365 * 24 * 60 * 60)); var componentCount = 4 + (hash[1] % 3); var components = new Dictionary(StringComparer.Ordinal); for (var i = 0; i < componentCount; i++) { var template = ComponentTemplates[(hash[(i * 5 + 7) % hash.Length] + i) % ComponentTemplates.Length]; var version = $"{1 + (hash[(i * 3 + 9) % hash.Length] % 3)}.{hash[(i * 7 + 13) % hash.Length] % 10}.{hash[(i * 11 + 17) % hash.Length] % 20}"; var purl = template.Ecosystem switch { "rpm" or "deb" => $"pkg:generic/{template.Name}@{version}", _ => $"pkg:{template.Ecosystem}/{template.Name}@{version}" }; components[purl] = new SnapshotComponent(purl, version, template.License); } var vulnerabilities = new List(); foreach (var (component, index) in components.Values.OrderBy(v => v.Purl, StringComparer.Ordinal).Select((value, idx) => (value, idx))) { var vulnerabilityCount = 1 + (hash[(index + 19) % hash.Length] % 2); for (var slot = 0; slot < vulnerabilityCount; slot++) { var cve = $"CVE-{2024 + (hash[(index + slot + 3) % hash.Length] % 3)}-{1000 + (((hash[(index * 3 + slot + 5) % hash.Length] << 8) + hash[(index * 3 + slot + 6) % hash.Length]) % 8000):D4}"; var severity = OrderedSeverities[hash[(index * 3 + slot + 23) % hash.Length] % 4]; var reachability = (hash[(index * 3 + slot + 29) % hash.Length] % 3) switch { 0 => "reachable", 1 => "likely", _ => "unreachable" }; var verdict = severity is "critical" or "high" ? "Block" : severity == "medium" ? "Warn" : "Ship"; vulnerabilities.Add(new SnapshotVulnerability(cve, component.Purl, severity, reachability, verdict, IncrementPatch(component.Version))); } } var distinctVulnerabilities = vulnerabilities .DistinctBy(v => $"{v.VulnId}|{v.Purl}", StringComparer.Ordinal) .OrderBy(v => v.VulnId, StringComparer.Ordinal) .ThenBy(v => v.Purl, StringComparer.Ordinal) .ToList(); var hasBlocking = distinctVulnerabilities.Any(v => v.Severity is "critical" or "high"); var hasMedium = distinctVulnerabilities.Any(v => v.Severity == "medium"); var policyVerdict = hasBlocking ? "Block" : hasMedium ? "Warn" : "Ship"; return new Snapshot(digest, createdAt, components.Values.OrderBy(v => v.Purl, StringComparer.Ordinal).ToList(), distinctVulnerabilities, policyVerdict); } private static IReadOnlyList BuildVulnerabilityDiffs(Snapshot baseline, Snapshot target, bool includeUnchanged) { var baseIndex = baseline.Vulnerabilities.ToDictionary(v => $"{v.VulnId}|{v.Purl}", StringComparer.Ordinal); var targetIndex = target.Vulnerabilities.ToDictionary(v => $"{v.VulnId}|{v.Purl}", StringComparer.Ordinal); var keys = baseIndex.Keys.Union(targetIndex.Keys, StringComparer.Ordinal).OrderBy(v => v, StringComparer.Ordinal); var results = new List(); foreach (var key in keys) { baseIndex.TryGetValue(key, out var before); targetIndex.TryGetValue(key, out var after); if (before is null && after is not null) { results.Add(new DeltaVulnerabilityDto { VulnId = after.VulnId, Purl = after.Purl, ChangeType = "Added", Severity = after.Severity, Reachability = after.Reachability, Verdict = after.Verdict, FixedVersion = after.FixedVersion }); continue; } if (before is not null && after is null) { results.Add(new DeltaVulnerabilityDto { VulnId = before.VulnId, Purl = before.Purl, ChangeType = "Removed", Severity = "unknown", PreviousSeverity = before.Severity, PreviousReachability = before.Reachability, PreviousVerdict = before.Verdict, FixedVersion = before.FixedVersion }); continue; } if (before is null || after is null) { continue; } var fields = new List(); AddFieldChange(fields, "severity", before.Severity, after.Severity); AddFieldChange(fields, "reachability", before.Reachability, after.Reachability); AddFieldChange(fields, "verdict", before.Verdict, after.Verdict); AddFieldChange(fields, "fixedVersion", before.FixedVersion, after.FixedVersion); if (fields.Count == 0) { if (!includeUnchanged) { continue; } results.Add(new DeltaVulnerabilityDto { VulnId = after.VulnId, Purl = after.Purl, ChangeType = "Unchanged", Severity = after.Severity, Reachability = after.Reachability, Verdict = after.Verdict, FixedVersion = after.FixedVersion }); continue; } results.Add(new DeltaVulnerabilityDto { VulnId = after.VulnId, Purl = after.Purl, ChangeType = "Modified", Severity = after.Severity, PreviousSeverity = before.Severity, Reachability = after.Reachability, PreviousReachability = before.Reachability, Verdict = after.Verdict, PreviousVerdict = before.Verdict, FixedVersion = after.FixedVersion, FieldChanges = fields }); } return results; } private static IReadOnlyList BuildComponentDiffs(Snapshot baseline, Snapshot target, bool includeUnchanged) { var baseIndex = baseline.Components.ToDictionary(v => v.Purl, StringComparer.Ordinal); var targetIndex = target.Components.ToDictionary(v => v.Purl, StringComparer.Ordinal); var baseVulnCount = baseline.Vulnerabilities.GroupBy(v => v.Purl, StringComparer.Ordinal).ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal); var targetVulnCount = target.Vulnerabilities.GroupBy(v => v.Purl, StringComparer.Ordinal).ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal); var keys = baseIndex.Keys.Union(targetIndex.Keys, StringComparer.Ordinal).OrderBy(v => v, StringComparer.Ordinal); var results = new List(); foreach (var key in keys) { baseIndex.TryGetValue(key, out var before); targetIndex.TryGetValue(key, out var after); var beforeVuln = baseVulnCount.TryGetValue(key, out var bc) ? bc : 0; var afterVuln = targetVulnCount.TryGetValue(key, out var ac) ? ac : 0; if (before is null && after is not null) { results.Add(new DeltaComponentDto { Purl = key, ChangeType = "Added", CurrentVersion = after.Version, VulnerabilitiesInBase = beforeVuln, VulnerabilitiesInTarget = afterVuln, License = after.License }); continue; } if (before is not null && after is null) { results.Add(new DeltaComponentDto { Purl = key, ChangeType = "Removed", PreviousVersion = before.Version, VulnerabilitiesInBase = beforeVuln, VulnerabilitiesInTarget = afterVuln, License = before.License }); continue; } if (before is null || after is null) { continue; } if (!string.Equals(before.Version, after.Version, StringComparison.Ordinal)) { results.Add(new DeltaComponentDto { Purl = key, ChangeType = "VersionChanged", PreviousVersion = before.Version, CurrentVersion = after.Version, VulnerabilitiesInBase = beforeVuln, VulnerabilitiesInTarget = afterVuln, License = after.License }); continue; } if (!includeUnchanged) { continue; } results.Add(new DeltaComponentDto { Purl = key, ChangeType = "Unchanged", PreviousVersion = before.Version, CurrentVersion = after.Version, VulnerabilitiesInBase = beforeVuln, VulnerabilitiesInTarget = afterVuln, License = after.License }); } return results; } private static DeltaPolicyDiffDto BuildPolicyDiff(Snapshot baseline, Snapshot target, IReadOnlyList vulnerabilities) { return new DeltaPolicyDiffDto { BaseVerdict = baseline.PolicyVerdict, TargetVerdict = target.PolicyVerdict, VerdictChanged = !string.Equals(baseline.PolicyVerdict, target.PolicyVerdict, StringComparison.OrdinalIgnoreCase), BlockToShipCount = vulnerabilities.Count(v => string.Equals(v.PreviousVerdict, "Block", StringComparison.OrdinalIgnoreCase) && string.Equals(v.Verdict, "Ship", StringComparison.OrdinalIgnoreCase)), ShipToBlockCount = vulnerabilities.Count(v => string.Equals(v.PreviousVerdict, "Ship", StringComparison.OrdinalIgnoreCase) && string.Equals(v.Verdict, "Block", StringComparison.OrdinalIgnoreCase)), WouldPassIf = vulnerabilities .Where(v => string.Equals(v.Verdict, "Block", StringComparison.OrdinalIgnoreCase)) .Select(v => $"Mitigate {v.VulnId} in {v.Purl}") .Distinct(StringComparer.Ordinal) .OrderBy(v => v, StringComparer.Ordinal) .Take(3) .ToList() }; } private static DeltaSeverityChangesDto BuildSeverityChanges(IReadOnlyList vulnerabilities) { var criticalAdded = 0; var criticalRemoved = 0; var highAdded = 0; var highRemoved = 0; var mediumAdded = 0; var mediumRemoved = 0; var lowAdded = 0; var lowRemoved = 0; foreach (var vulnerability in vulnerabilities) { var current = NormalizeSeverity(vulnerability.Severity); var previous = NormalizeSeverity(vulnerability.PreviousSeverity); if (vulnerability.ChangeType.Equals("Added", StringComparison.OrdinalIgnoreCase)) { Increment(current, isAdded: true); } else if (vulnerability.ChangeType.Equals("Removed", StringComparison.OrdinalIgnoreCase)) { Increment(previous, isAdded: false); } else if (vulnerability.ChangeType.Equals("Modified", StringComparison.OrdinalIgnoreCase) && !string.Equals(current, previous, StringComparison.OrdinalIgnoreCase)) { Increment(previous, isAdded: false); Increment(current, isAdded: true); } } return new DeltaSeverityChangesDto { CriticalAdded = criticalAdded, CriticalRemoved = criticalRemoved, HighAdded = highAdded, HighRemoved = highRemoved, MediumAdded = mediumAdded, MediumRemoved = mediumRemoved, LowAdded = lowAdded, LowRemoved = lowRemoved }; void Increment(string severity, bool isAdded) { switch (severity) { case "critical": if (isAdded) criticalAdded++; else criticalRemoved++; break; case "high": if (isAdded) highAdded++; else highRemoved++; break; case "medium": if (isAdded) mediumAdded++; else mediumRemoved++; break; case "low": if (isAdded) lowAdded++; else lowRemoved++; break; } } } private static DeltaSnapshotSummaryDto BuildSummary(Snapshot snapshot) => new() { Digest = snapshot.Digest, CreatedAt = snapshot.CreatedAt, ComponentCount = snapshot.Components.Count, VulnerabilityCount = snapshot.Vulnerabilities.Count, SeverityCounts = new DeltaSeverityCountsDto { Critical = snapshot.Vulnerabilities.Count(v => v.Severity == "critical"), High = snapshot.Vulnerabilities.Count(v => v.Severity == "high"), Medium = snapshot.Vulnerabilities.Count(v => v.Severity == "medium"), Low = snapshot.Vulnerabilities.Count(v => v.Severity == "low"), Unknown = snapshot.Vulnerabilities.Count(v => v.Severity == "unknown") }, PolicyVerdict = snapshot.PolicyVerdict }; private static void AddFieldChange(List changes, string field, string oldValue, string newValue) { if (string.Equals(oldValue, newValue, StringComparison.Ordinal)) { return; } changes.Add(new DeltaFieldChangeDto { Field = field, PreviousValue = oldValue, CurrentValue = newValue }); } private static string EffectiveSeverity(DeltaVulnerabilityDto vulnerability) => vulnerability.ChangeType.Equals("Removed", StringComparison.OrdinalIgnoreCase) ? NormalizeSeverity(vulnerability.PreviousSeverity) : NormalizeSeverity(vulnerability.Severity); private static int ChangeTypeOrder(string value) => value switch { "Added" => 0, "Removed" => 1, "Modified" => 2, "VersionChanged" => 3, "Unchanged" => 4, _ => 5 }; private static string NormalizeSeverity(string? severity) { if (string.IsNullOrWhiteSpace(severity)) { return "unknown"; } var normalized = severity.Trim().ToLowerInvariant(); return OrderedSeverities.Contains(normalized, StringComparer.Ordinal) ? normalized : "unknown"; } private static string IncrementPatch(string version) { var parts = version.Split('.', StringSplitOptions.RemoveEmptyEntries); if (parts.Length != 3 || !int.TryParse(parts[0], out var major) || !int.TryParse(parts[1], out var minor) || !int.TryParse(parts[2], out var patch)) { return version; } return $"{major}.{minor}.{patch + 1}"; } private static string ComputeComparisonId(string baseDigest, string targetDigest) { var input = $"{baseDigest}|{targetDigest}"; var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input)); return $"cmp-{Convert.ToHexString(hash)[..16].ToLowerInvariant()}"; } private sealed record Snapshot( string Digest, DateTimeOffset CreatedAt, IReadOnlyList Components, IReadOnlyList Vulnerabilities, string PolicyVerdict); private sealed record SnapshotComponent(string Purl, string Version, string License); private sealed record SnapshotVulnerability(string VulnId, string Purl, string Severity, string Reachability, string Verdict, string FixedVersion); }