297 lines
12 KiB
C#
297 lines
12 KiB
C#
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<ComponentDelta> ComputeAddedComponents(
|
|
IReadOnlyDictionary<string, Component> baseComponents,
|
|
IReadOnlyDictionary<string, Component> headComponents,
|
|
ISet<string> 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<ComponentDelta> ComputeRemovedComponents(
|
|
IReadOnlyDictionary<string, Component> baseComponents,
|
|
IReadOnlyDictionary<string, Component> headComponents,
|
|
ISet<string> 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<ComponentVersionDelta> 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<string, Component> baseComponents,
|
|
IReadOnlyDictionary<string, Component> headComponents)
|
|
{
|
|
var pairs = new List<(Component Base, Component Head)>();
|
|
var matchedBase = new HashSet<string>(StringComparer.Ordinal);
|
|
var matchedHead = new HashSet<string>(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<VulnerabilityDelta> ComputeAddedVulnerabilities(
|
|
IReadOnlyDictionary<string, Vulnerability> baseVulns,
|
|
IReadOnlyDictionary<string, Vulnerability> 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<VulnerabilityDelta> ComputeRemovedVulnerabilities(
|
|
IReadOnlyDictionary<string, Vulnerability> baseVulns,
|
|
IReadOnlyDictionary<string, Vulnerability> 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<VulnerabilityStatusDelta> ComputeStatusChanges(
|
|
IReadOnlyDictionary<string, Vulnerability> baseVulns,
|
|
IReadOnlyDictionary<string, Vulnerability> headVulns)
|
|
{
|
|
var deltas = new List<VulnerabilityStatusDelta>();
|
|
|
|
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();
|
|
}
|
|
}
|