274 lines
9.7 KiB
C#
274 lines
9.7 KiB
C#
// -----------------------------------------------------------------------------
|
|
// SbomComparisonLogic.cs
|
|
// Sprint: SPRINT_5100_0008_0001_competitor_parity
|
|
// Task: PARITY-5100-004 - Implement SBOM comparison logic
|
|
// Description: Logic for comparing SBOM outputs between scanners
|
|
// -----------------------------------------------------------------------------
|
|
|
|
using System.Text.Json;
|
|
|
|
namespace StellaOps.Parity.Tests;
|
|
|
|
/// <summary>
|
|
/// Compares SBOM outputs between different scanners.
|
|
/// </summary>
|
|
public sealed class SbomComparisonLogic
|
|
{
|
|
/// <summary>
|
|
/// Compares two SBOM outputs and returns a comparison result.
|
|
/// </summary>
|
|
public SbomComparisonResult Compare(ScannerOutput baseline, ScannerOutput candidate)
|
|
{
|
|
var result = new SbomComparisonResult
|
|
{
|
|
BaselineTool = baseline.ToolName,
|
|
CandidateTool = candidate.ToolName,
|
|
Image = baseline.Image
|
|
};
|
|
|
|
if (baseline.SbomJson is null || candidate.SbomJson is null)
|
|
{
|
|
result.Error = "One or both SBOM outputs are null";
|
|
return result;
|
|
}
|
|
|
|
try
|
|
{
|
|
var baselinePackages = ExtractPackages(baseline.SbomJson);
|
|
var candidatePackages = ExtractPackages(candidate.SbomJson);
|
|
|
|
result.BaselinePackageCount = baselinePackages.Count;
|
|
result.CandidatePackageCount = candidatePackages.Count;
|
|
|
|
// Find packages in baseline but not in candidate
|
|
result.OnlyInBaseline = baselinePackages
|
|
.Where(bp => !candidatePackages.Any(cp => MatchesPackage(bp, cp)))
|
|
.ToList();
|
|
|
|
// Find packages in candidate but not in baseline
|
|
result.OnlyInCandidate = candidatePackages
|
|
.Where(cp => !baselinePackages.Any(bp => MatchesPackage(bp, cp)))
|
|
.ToList();
|
|
|
|
// Find matching packages
|
|
result.MatchingPackages = baselinePackages
|
|
.Where(bp => candidatePackages.Any(cp => MatchesPackage(bp, cp)))
|
|
.ToList();
|
|
|
|
// Calculate metrics
|
|
result.PackageCountDiff = result.CandidatePackageCount - result.BaselinePackageCount;
|
|
result.PackageCountDiffPercent = result.BaselinePackageCount > 0
|
|
? (double)result.PackageCountDiff / result.BaselinePackageCount * 100
|
|
: 0;
|
|
|
|
result.MatchRate = result.BaselinePackageCount > 0
|
|
? (double)result.MatchingPackages.Count / result.BaselinePackageCount * 100
|
|
: 0;
|
|
|
|
// PURL completeness
|
|
result.BaselinePurlCount = baselinePackages.Count(p => !string.IsNullOrEmpty(p.Purl));
|
|
result.CandidatePurlCount = candidatePackages.Count(p => !string.IsNullOrEmpty(p.Purl));
|
|
result.PurlCompletenessBaseline = result.BaselinePackageCount > 0
|
|
? (double)result.BaselinePurlCount / result.BaselinePackageCount * 100
|
|
: 0;
|
|
result.PurlCompletenessCandidate = result.CandidatePackageCount > 0
|
|
? (double)result.CandidatePurlCount / result.CandidatePackageCount * 100
|
|
: 0;
|
|
|
|
// License detection
|
|
result.BaselineLicenseCount = baselinePackages.Count(p => !string.IsNullOrEmpty(p.License));
|
|
result.CandidateLicenseCount = candidatePackages.Count(p => !string.IsNullOrEmpty(p.License));
|
|
|
|
// CPE mapping
|
|
result.BaselineCpeCount = baselinePackages.Count(p => !string.IsNullOrEmpty(p.Cpe));
|
|
result.CandidateCpeCount = candidatePackages.Count(p => !string.IsNullOrEmpty(p.Cpe));
|
|
|
|
result.Success = true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
result.Error = ex.Message;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private List<ExtractedPackage> ExtractPackages(JsonDocument sbomJson)
|
|
{
|
|
var packages = new List<ExtractedPackage>();
|
|
var root = sbomJson.RootElement;
|
|
|
|
// Try SPDX format first
|
|
if (root.TryGetProperty("packages", out var spdxPackages))
|
|
{
|
|
foreach (var pkg in spdxPackages.EnumerateArray())
|
|
{
|
|
packages.Add(ExtractSpdxPackage(pkg));
|
|
}
|
|
}
|
|
// Try CycloneDX format
|
|
else if (root.TryGetProperty("components", out var cdxComponents))
|
|
{
|
|
foreach (var component in cdxComponents.EnumerateArray())
|
|
{
|
|
packages.Add(ExtractCycloneDxPackage(component));
|
|
}
|
|
}
|
|
|
|
return packages;
|
|
}
|
|
|
|
private ExtractedPackage ExtractSpdxPackage(JsonElement pkg)
|
|
{
|
|
var extracted = new ExtractedPackage
|
|
{
|
|
Name = pkg.TryGetProperty("name", out var name) ? name.GetString() ?? "" : "",
|
|
Version = pkg.TryGetProperty("versionInfo", out var version) ? version.GetString() ?? "" : ""
|
|
};
|
|
|
|
// Extract PURL from external refs
|
|
if (pkg.TryGetProperty("externalRefs", out var refs))
|
|
{
|
|
foreach (var refItem in refs.EnumerateArray())
|
|
{
|
|
if (refItem.TryGetProperty("referenceType", out var refType) &&
|
|
refType.GetString()?.Equals("purl", StringComparison.OrdinalIgnoreCase) == true &&
|
|
refItem.TryGetProperty("referenceLocator", out var locator))
|
|
{
|
|
extracted.Purl = locator.GetString();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract license
|
|
if (pkg.TryGetProperty("licenseConcluded", out var license))
|
|
{
|
|
extracted.License = license.GetString();
|
|
}
|
|
|
|
return extracted;
|
|
}
|
|
|
|
private ExtractedPackage ExtractCycloneDxPackage(JsonElement component)
|
|
{
|
|
var extracted = new ExtractedPackage
|
|
{
|
|
Name = component.TryGetProperty("name", out var name) ? name.GetString() ?? "" : "",
|
|
Version = component.TryGetProperty("version", out var version) ? version.GetString() ?? "" : "",
|
|
Purl = component.TryGetProperty("purl", out var purl) ? purl.GetString() : null
|
|
};
|
|
|
|
// Extract license
|
|
if (component.TryGetProperty("licenses", out var licenses))
|
|
{
|
|
var licenseList = new List<string>();
|
|
foreach (var lic in licenses.EnumerateArray())
|
|
{
|
|
if (lic.TryGetProperty("license", out var licObj))
|
|
{
|
|
if (licObj.TryGetProperty("id", out var licId))
|
|
licenseList.Add(licId.GetString() ?? "");
|
|
else if (licObj.TryGetProperty("name", out var licName))
|
|
licenseList.Add(licName.GetString() ?? "");
|
|
}
|
|
}
|
|
extracted.License = string.Join(", ", licenseList);
|
|
}
|
|
|
|
// Extract CPE
|
|
if (component.TryGetProperty("cpe", out var cpe))
|
|
{
|
|
extracted.Cpe = cpe.GetString();
|
|
}
|
|
|
|
return extracted;
|
|
}
|
|
|
|
private static bool MatchesPackage(ExtractedPackage a, ExtractedPackage b)
|
|
{
|
|
// Match by PURL first (most reliable)
|
|
if (!string.IsNullOrEmpty(a.Purl) && !string.IsNullOrEmpty(b.Purl))
|
|
{
|
|
return NormalizePurl(a.Purl) == NormalizePurl(b.Purl);
|
|
}
|
|
|
|
// Fall back to name + version match
|
|
return string.Equals(a.Name, b.Name, StringComparison.OrdinalIgnoreCase) &&
|
|
string.Equals(NormalizeVersion(a.Version), NormalizeVersion(b.Version), StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static string NormalizePurl(string purl)
|
|
{
|
|
// Remove qualifiers and subpath for basic comparison
|
|
var idx = purl.IndexOf('?');
|
|
if (idx > 0)
|
|
purl = purl[..idx];
|
|
idx = purl.IndexOf('#');
|
|
if (idx > 0)
|
|
purl = purl[..idx];
|
|
return purl.ToLowerInvariant();
|
|
}
|
|
|
|
private static string NormalizeVersion(string version)
|
|
{
|
|
// Strip common prefixes and suffixes
|
|
version = version.TrimStart('v', 'V');
|
|
return version.ToLowerInvariant();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of comparing two SBOM outputs.
|
|
/// </summary>
|
|
public sealed class SbomComparisonResult
|
|
{
|
|
public required string BaselineTool { get; init; }
|
|
public required string CandidateTool { get; init; }
|
|
public required string Image { get; init; }
|
|
public bool Success { get; set; }
|
|
public string? Error { get; set; }
|
|
|
|
// Package counts
|
|
public int BaselinePackageCount { get; set; }
|
|
public int CandidatePackageCount { get; set; }
|
|
public int PackageCountDiff { get; set; }
|
|
public double PackageCountDiffPercent { get; set; }
|
|
|
|
// Package matching
|
|
public List<ExtractedPackage> OnlyInBaseline { get; set; } = [];
|
|
public List<ExtractedPackage> OnlyInCandidate { get; set; } = [];
|
|
public List<ExtractedPackage> MatchingPackages { get; set; } = [];
|
|
public double MatchRate { get; set; }
|
|
|
|
// PURL completeness
|
|
public int BaselinePurlCount { get; set; }
|
|
public int CandidatePurlCount { get; set; }
|
|
public double PurlCompletenessBaseline { get; set; }
|
|
public double PurlCompletenessCandidate { get; set; }
|
|
|
|
// License detection
|
|
public int BaselineLicenseCount { get; set; }
|
|
public int CandidateLicenseCount { get; set; }
|
|
|
|
// CPE mapping
|
|
public int BaselineCpeCount { get; set; }
|
|
public int CandidateCpeCount { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracted package information from SBOM.
|
|
/// </summary>
|
|
public sealed class ExtractedPackage
|
|
{
|
|
public required string Name { get; init; }
|
|
public required string Version { get; init; }
|
|
public string? Purl { get; set; }
|
|
public string? License { get; set; }
|
|
public string? Cpe { get; set; }
|
|
public string? Ecosystem { get; set; }
|
|
|
|
public override string ToString() =>
|
|
!string.IsNullOrEmpty(Purl) ? Purl : $"{Name}@{Version}";
|
|
}
|