using System.Collections.Immutable; using System.Security.Cryptography; using System.Text; using StellaOps.DeltaVerdict.Models; namespace StellaOps.DeltaVerdict.Engine; public interface IDeltaComputationEngine { DeltaVerdict.Models.DeltaVerdict ComputeDelta(Verdict baseVerdict, Verdict headVerdict); } public sealed class DeltaComputationEngine : IDeltaComputationEngine { private readonly TimeProvider _timeProvider; public DeltaComputationEngine(TimeProvider? timeProvider = null) { _timeProvider = timeProvider ?? TimeProvider.System; } public DeltaVerdict.Models.DeltaVerdict ComputeDelta(Verdict baseVerdict, Verdict headVerdict) { ArgumentNullException.ThrowIfNull(baseVerdict); ArgumentNullException.ThrowIfNull(headVerdict); var baseComponents = baseVerdict.Components .ToDictionary(c => c.Purl, c => c, StringComparer.Ordinal); var headComponents = headVerdict.Components .ToDictionary(c => c.Purl, c => c, StringComparer.Ordinal); var changedPairs = FindChangedComponentPairs(baseComponents, headComponents); var changedComponents = BuildChangedComponents(changedPairs); var changedBasePurls = changedPairs .Select(pair => pair.Base.Purl) .ToHashSet(StringComparer.Ordinal); var changedHeadPurls = changedPairs .Select(pair => pair.Head.Purl) .ToHashSet(StringComparer.Ordinal); var addedComponents = ComputeAddedComponents(baseComponents, headComponents, changedHeadPurls); var removedComponents = ComputeRemovedComponents(baseComponents, headComponents, changedBasePurls); var baseVulns = baseVerdict.Vulnerabilities .ToDictionary(v => v.Id, v => v, StringComparer.Ordinal); var headVulns = headVerdict.Vulnerabilities .ToDictionary(v => v.Id, v => v, StringComparer.Ordinal); var addedVulns = ComputeAddedVulnerabilities(baseVulns, headVulns); var removedVulns = ComputeRemovedVulnerabilities(baseVulns, headVulns); var changedStatuses = ComputeStatusChanges(baseVulns, headVulns); var riskDelta = ComputeRiskScoreDelta(baseVerdict.RiskScore, headVerdict.RiskScore); var totalChanges = addedComponents.Length + removedComponents.Length + changedComponents.Length + addedVulns.Length + removedVulns.Length + changedStatuses.Length; var summary = new DeltaSummary( ComponentsAdded: addedComponents.Length, ComponentsRemoved: removedComponents.Length, ComponentsChanged: changedComponents.Length, VulnerabilitiesAdded: addedVulns.Length, VulnerabilitiesRemoved: removedVulns.Length, VulnerabilityStatusChanges: changedStatuses.Length, TotalChanges: totalChanges, Magnitude: ClassifyMagnitude(totalChanges)); return new DeltaVerdict.Models.DeltaVerdict { DeltaId = ComputeDeltaId(baseVerdict, headVerdict), SchemaVersion = "1.0.0", BaseVerdict = CreateVerdictReference(baseVerdict), HeadVerdict = CreateVerdictReference(headVerdict), AddedComponents = addedComponents, RemovedComponents = removedComponents, ChangedComponents = changedComponents, AddedVulnerabilities = addedVulns, RemovedVulnerabilities = removedVulns, ChangedVulnerabilityStatuses = changedStatuses, RiskScoreDelta = riskDelta, Summary = summary, ComputedAt = _timeProvider.GetUtcNow() }; } private static ImmutableArray ComputeAddedComponents( IReadOnlyDictionary baseComponents, IReadOnlyDictionary headComponents, ISet excludedPurls) { return headComponents .Where(kv => !baseComponents.ContainsKey(kv.Key) && !excludedPurls.Contains(kv.Key)) .OrderBy(kv => kv.Key, StringComparer.Ordinal) .Select(kv => new ComponentDelta( kv.Value.Purl, kv.Value.Name, kv.Value.Version, kv.Value.Type, kv.Value.Vulnerabilities)) .ToImmutableArray(); } private static ImmutableArray ComputeRemovedComponents( IReadOnlyDictionary baseComponents, IReadOnlyDictionary headComponents, ISet excludedPurls) { return baseComponents .Where(kv => !headComponents.ContainsKey(kv.Key) && !excludedPurls.Contains(kv.Key)) .OrderBy(kv => kv.Key, StringComparer.Ordinal) .Select(kv => new ComponentDelta( kv.Value.Purl, kv.Value.Name, kv.Value.Version, kv.Value.Type, kv.Value.Vulnerabilities)) .ToImmutableArray(); } private static ImmutableArray BuildChangedComponents( IReadOnlyCollection<(Component Base, Component Head)> pairs) { return pairs .OrderBy(pair => pair.Base.Purl, StringComparer.Ordinal) .Select(pair => { var baseComponent = pair.Base; var headComponent = pair.Head; var fixedVulns = baseComponent.Vulnerabilities .Except(headComponent.Vulnerabilities, StringComparer.Ordinal) .OrderBy(v => v, StringComparer.Ordinal) .ToImmutableArray(); var introducedVulns = headComponent.Vulnerabilities .Except(baseComponent.Vulnerabilities, StringComparer.Ordinal) .OrderBy(v => v, StringComparer.Ordinal) .ToImmutableArray(); return new ComponentVersionDelta( baseComponent.Purl, baseComponent.Name, baseComponent.Version, headComponent.Version, fixedVulns, introducedVulns); }) .ToImmutableArray(); } private static IReadOnlyCollection<(Component Base, Component Head)> FindChangedComponentPairs( IReadOnlyDictionary baseComponents, IReadOnlyDictionary headComponents) { var pairs = new List<(Component Base, Component Head)>(); var matchedBase = new HashSet(StringComparer.Ordinal); var matchedHead = new HashSet(StringComparer.Ordinal); foreach (var baseComponent in baseComponents.Values.OrderBy(c => c.Purl, StringComparer.Ordinal)) { if (headComponents.TryGetValue(baseComponent.Purl, out var headComponent) && !string.Equals(baseComponent.Version, headComponent.Version, StringComparison.Ordinal)) { pairs.Add((baseComponent, headComponent)); matchedBase.Add(baseComponent.Purl); matchedHead.Add(headComponent.Purl); } } var baseByIdentity = baseComponents.Values .Where(c => !matchedBase.Contains(c.Purl)) .GroupBy(GetComponentIdentity, StringComparer.Ordinal) .Where(g => g.Count() == 1) .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); var headByIdentity = headComponents.Values .Where(c => !matchedHead.Contains(c.Purl)) .GroupBy(GetComponentIdentity, StringComparer.Ordinal) .Where(g => g.Count() == 1) .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); foreach (var (identity, baseComponent) in baseByIdentity.OrderBy(kv => kv.Key, StringComparer.Ordinal)) { if (!headByIdentity.TryGetValue(identity, out var headComponent)) { continue; } if (string.Equals(baseComponent.Version, headComponent.Version, StringComparison.Ordinal)) { continue; } pairs.Add((baseComponent, headComponent)); matchedBase.Add(baseComponent.Purl); matchedHead.Add(headComponent.Purl); } return pairs; } private static string GetComponentIdentity(Component component) => $"{component.Type}:{component.Name}"; private static ImmutableArray ComputeAddedVulnerabilities( IReadOnlyDictionary baseVulns, IReadOnlyDictionary headVulns) { return headVulns .Where(kv => !baseVulns.ContainsKey(kv.Key)) .OrderBy(kv => kv.Key, StringComparer.Ordinal) .Select(kv => new VulnerabilityDelta( kv.Value.Id, kv.Value.Severity, kv.Value.CvssScore, kv.Value.ComponentPurl, kv.Value.ReachabilityStatus)) .ToImmutableArray(); } private static ImmutableArray ComputeRemovedVulnerabilities( IReadOnlyDictionary baseVulns, IReadOnlyDictionary headVulns) { return baseVulns .Where(kv => !headVulns.ContainsKey(kv.Key)) .OrderBy(kv => kv.Key, StringComparer.Ordinal) .Select(kv => new VulnerabilityDelta( kv.Value.Id, kv.Value.Severity, kv.Value.CvssScore, kv.Value.ComponentPurl, kv.Value.ReachabilityStatus)) .ToImmutableArray(); } private static ImmutableArray ComputeStatusChanges( IReadOnlyDictionary baseVulns, IReadOnlyDictionary headVulns) { var deltas = new List(); foreach (var (id, baseVuln) in baseVulns.OrderBy(kv => kv.Key, StringComparer.Ordinal)) { if (!headVulns.TryGetValue(id, out var headVuln)) { continue; } var oldStatus = baseVuln.Status ?? baseVuln.ReachabilityStatus ?? "unknown"; var newStatus = headVuln.Status ?? headVuln.ReachabilityStatus ?? "unknown"; if (!string.Equals(oldStatus, newStatus, StringComparison.OrdinalIgnoreCase)) { deltas.Add(new VulnerabilityStatusDelta(id, oldStatus, newStatus, null)); } } return deltas.ToImmutableArray(); } private static RiskScoreDelta ComputeRiskScoreDelta(decimal oldScore, decimal newScore) { var change = newScore - oldScore; var percentChange = oldScore > 0 ? (change / oldScore) * 100 : (newScore > 0 ? 100 : 0); var trend = change switch { < 0 => RiskTrend.Improved, > 0 => RiskTrend.Degraded, _ => RiskTrend.Stable }; return new RiskScoreDelta(oldScore, newScore, change, percentChange, trend); } private static DeltaMagnitude ClassifyMagnitude(int totalChanges) => totalChanges switch { 0 => DeltaMagnitude.None, <= 5 => DeltaMagnitude.Minimal, <= 20 => DeltaMagnitude.Small, <= 50 => DeltaMagnitude.Medium, <= 100 => DeltaMagnitude.Large, _ => DeltaMagnitude.Major }; private static VerdictReference CreateVerdictReference(Verdict verdict) => new(verdict.VerdictId, verdict.Digest, verdict.ArtifactRef, verdict.ScannedAt); private static string ComputeDeltaId(Verdict baseVerdict, Verdict headVerdict) { var baseKey = baseVerdict.Digest ?? baseVerdict.VerdictId; var headKey = headVerdict.Digest ?? headVerdict.VerdictId; var input = $"{baseKey}:{headKey}"; var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input)); return Convert.ToHexString(hash).ToLowerInvariant(); } }