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