758 lines
32 KiB
C#
758 lines
32 KiB
C#
// -----------------------------------------------------------------------------
|
|
// 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;
|
|
|
|
/// <summary>
|
|
/// Endpoints for delta/compare view - comparing scan snapshots.
|
|
/// Per SPRINT_4200_0002_0006.
|
|
/// </summary>
|
|
internal static class DeltaCompareEndpoints
|
|
{
|
|
/// <summary>
|
|
/// Maps delta compare endpoints.
|
|
/// </summary>
|
|
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<DeltaCompareResponseDto>(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<QuickDiffSummaryDto>(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<DeltaCompareResponseDto>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.RequireAuthorization(ScannerPolicies.ScansRead);
|
|
}
|
|
|
|
private static async Task<IResult> 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<IResult> 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<IResult> 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Service interface for delta compare operations.
|
|
/// Per SPRINT_4200_0002_0006.
|
|
/// </summary>
|
|
public interface IDeltaCompareService
|
|
{
|
|
/// <summary>
|
|
/// Performs a full comparison between two snapshots.
|
|
/// </summary>
|
|
Task<DeltaCompareResponseDto> CompareAsync(DeltaCompareRequestDto request, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Gets a quick diff summary for the Can I Ship header.
|
|
/// </summary>
|
|
Task<QuickDiffSummaryDto> GetQuickDiffAsync(string baseDigest, string targetDigest, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Gets a cached comparison by ID.
|
|
/// </summary>
|
|
Task<DeltaCompareResponseDto?> GetComparisonAsync(string comparisonId, CancellationToken ct = default);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Default implementation of delta compare service.
|
|
/// </summary>
|
|
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<string, DeltaCompareResponseDto> _comparisons = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly TimeProvider _timeProvider;
|
|
|
|
public DeltaCompareService(TimeProvider timeProvider)
|
|
{
|
|
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
|
}
|
|
|
|
public Task<DeltaCompareResponseDto> 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<QuickDiffSummaryDto> 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<DeltaCompareResponseDto?> GetComparisonAsync(string comparisonId, CancellationToken ct = default)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
if (string.IsNullOrWhiteSpace(comparisonId))
|
|
{
|
|
return Task.FromResult<DeltaCompareResponseDto?>(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<string, SnapshotComponent>(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<SnapshotVulnerability>();
|
|
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<DeltaVulnerabilityDto> 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<DeltaVulnerabilityDto>();
|
|
|
|
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<DeltaFieldChangeDto>();
|
|
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<DeltaComponentDto> 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<DeltaComponentDto>();
|
|
|
|
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<DeltaVulnerabilityDto> 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<DeltaVulnerabilityDto> 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<DeltaFieldChangeDto> 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<SnapshotComponent> Components,
|
|
IReadOnlyList<SnapshotVulnerability> 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);
|
|
}
|