5100* tests strengthtenen work
This commit is contained in:
273
tests/parity/StellaOps.Parity.Tests/SbomComparisonLogic.cs
Normal file
273
tests/parity/StellaOps.Parity.Tests/SbomComparisonLogic.cs
Normal file
@@ -0,0 +1,273 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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}";
|
||||
}
|
||||
Reference in New Issue
Block a user