Files
git.stella-ops.org/tests/parity/StellaOps.Parity.Tests/SbomComparisonLogic.cs
2025-12-24 12:38:34 +02:00

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}";
}