// ----------------------------------------------------------------------------- // 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; /// /// Compares SBOM outputs between different scanners. /// public sealed class SbomComparisonLogic { /// /// Compares two SBOM outputs and returns a comparison result. /// 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 ExtractPackages(JsonDocument sbomJson) { var packages = new List(); 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(); 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(); } } /// /// Result of comparing two SBOM outputs. /// 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 OnlyInBaseline { get; set; } = []; public List OnlyInCandidate { get; set; } = []; public List 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; } } /// /// Extracted package information from SBOM. /// 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}"; }