288 lines
11 KiB
C#
288 lines
11 KiB
C#
// -----------------------------------------------------------------------------
|
|
// VulnerabilityComparisonLogic.cs
|
|
// Sprint: SPRINT_5100_0008_0001_competitor_parity
|
|
// Task: PARITY-5100-005 - Implement vulnerability finding comparison logic
|
|
// Description: Logic for comparing vulnerability findings between scanners
|
|
// -----------------------------------------------------------------------------
|
|
|
|
using System.Text.Json;
|
|
|
|
namespace StellaOps.Parity.Tests;
|
|
|
|
/// <summary>
|
|
/// Compares vulnerability findings between different scanners.
|
|
/// </summary>
|
|
public sealed class VulnerabilityComparisonLogic
|
|
{
|
|
/// <summary>
|
|
/// Compares vulnerability findings from two scanner outputs.
|
|
/// </summary>
|
|
public VulnerabilityComparisonResult Compare(ScannerOutput baseline, ScannerOutput candidate)
|
|
{
|
|
var result = new VulnerabilityComparisonResult
|
|
{
|
|
BaselineTool = baseline.ToolName,
|
|
CandidateTool = candidate.ToolName,
|
|
Image = baseline.Image
|
|
};
|
|
|
|
if (baseline.FindingsJson is null || candidate.FindingsJson is null)
|
|
{
|
|
result.Error = "One or both findings outputs are null";
|
|
return result;
|
|
}
|
|
|
|
try
|
|
{
|
|
var baselineFindings = ExtractFindings(baseline.FindingsJson, baseline.ToolName);
|
|
var candidateFindings = ExtractFindings(candidate.FindingsJson, candidate.ToolName);
|
|
|
|
result.BaselineCveCount = baselineFindings.Count;
|
|
result.CandidateCveCount = candidateFindings.Count;
|
|
|
|
// Find CVEs in baseline but not in candidate (false negatives for candidate)
|
|
result.OnlyInBaseline = baselineFindings
|
|
.Where(bf => !candidateFindings.Any(cf => cf.CveId == bf.CveId))
|
|
.ToList();
|
|
|
|
// Find CVEs in candidate but not in baseline (potential false positives)
|
|
result.OnlyInCandidate = candidateFindings
|
|
.Where(cf => !baselineFindings.Any(bf => bf.CveId == cf.CveId))
|
|
.ToList();
|
|
|
|
// Find matching CVEs
|
|
result.MatchingCves = baselineFindings
|
|
.Where(bf => candidateFindings.Any(cf => cf.CveId == bf.CveId))
|
|
.ToList();
|
|
|
|
// Calculate severity distribution
|
|
result.BaselineSeverityDistribution = CalculateSeverityDistribution(baselineFindings);
|
|
result.CandidateSeverityDistribution = CalculateSeverityDistribution(candidateFindings);
|
|
|
|
// Calculate recall (what percentage of baseline CVEs were found)
|
|
result.Recall = result.BaselineCveCount > 0
|
|
? (double)result.MatchingCves.Count / result.BaselineCveCount * 100
|
|
: 100;
|
|
|
|
// Calculate precision (what percentage of candidate CVEs are true positives)
|
|
// Note: This is relative to baseline, not ground truth
|
|
result.Precision = result.CandidateCveCount > 0
|
|
? (double)result.MatchingCves.Count / result.CandidateCveCount * 100
|
|
: 100;
|
|
|
|
// F1 score
|
|
if (result.Precision + result.Recall > 0)
|
|
{
|
|
result.F1Score = 2 * (result.Precision * result.Recall) / (result.Precision + result.Recall);
|
|
}
|
|
|
|
// Calculate false positive rate (CVEs only in candidate)
|
|
result.FalsePositiveRate = result.CandidateCveCount > 0
|
|
? (double)result.OnlyInCandidate.Count / result.CandidateCveCount * 100
|
|
: 0;
|
|
|
|
// Calculate false negative rate (CVEs only in baseline, missed by candidate)
|
|
result.FalseNegativeRate = result.BaselineCveCount > 0
|
|
? (double)result.OnlyInBaseline.Count / result.BaselineCveCount * 100
|
|
: 0;
|
|
|
|
result.Success = true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
result.Error = ex.Message;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private List<ExtractedFinding> ExtractFindings(JsonDocument findingsJson, string toolName)
|
|
{
|
|
var findings = new List<ExtractedFinding>();
|
|
var root = findingsJson.RootElement;
|
|
|
|
if (toolName.Equals("grype", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
findings.AddRange(ExtractGrypeFindings(root));
|
|
}
|
|
else if (toolName.Equals("trivy", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
findings.AddRange(ExtractTrivyFindings(root));
|
|
}
|
|
|
|
return findings;
|
|
}
|
|
|
|
private IEnumerable<ExtractedFinding> ExtractGrypeFindings(JsonElement root)
|
|
{
|
|
if (!root.TryGetProperty("matches", out var matches))
|
|
yield break;
|
|
|
|
foreach (var match in matches.EnumerateArray())
|
|
{
|
|
if (!match.TryGetProperty("vulnerability", out var vuln))
|
|
continue;
|
|
|
|
var cveId = vuln.TryGetProperty("id", out var id) ? id.GetString() : null;
|
|
if (string.IsNullOrEmpty(cveId))
|
|
continue;
|
|
|
|
var severity = vuln.TryGetProperty("severity", out var sev)
|
|
? ParseSeverity(sev.GetString())
|
|
: VulnerabilitySeverity.Unknown;
|
|
|
|
var packageName = "";
|
|
var packageVersion = "";
|
|
if (match.TryGetProperty("artifact", out var artifact))
|
|
{
|
|
packageName = artifact.TryGetProperty("name", out var name) ? name.GetString() ?? "" : "";
|
|
packageVersion = artifact.TryGetProperty("version", out var version) ? version.GetString() ?? "" : "";
|
|
}
|
|
|
|
yield return new ExtractedFinding
|
|
{
|
|
CveId = cveId,
|
|
Severity = severity,
|
|
PackageName = packageName,
|
|
PackageVersion = packageVersion,
|
|
FixedVersion = vuln.TryGetProperty("fix", out var fix) && fix.TryGetProperty("versions", out var fixVer)
|
|
? string.Join(", ", fixVer.EnumerateArray().Select(v => v.GetString()))
|
|
: null
|
|
};
|
|
}
|
|
}
|
|
|
|
private IEnumerable<ExtractedFinding> ExtractTrivyFindings(JsonElement root)
|
|
{
|
|
if (!root.TryGetProperty("Results", out var results))
|
|
yield break;
|
|
|
|
foreach (var result in results.EnumerateArray())
|
|
{
|
|
if (!result.TryGetProperty("Vulnerabilities", out var vulnerabilities))
|
|
continue;
|
|
|
|
foreach (var vuln in vulnerabilities.EnumerateArray())
|
|
{
|
|
var cveId = vuln.TryGetProperty("VulnerabilityID", out var id) ? id.GetString() : null;
|
|
if (string.IsNullOrEmpty(cveId))
|
|
continue;
|
|
|
|
var severity = vuln.TryGetProperty("Severity", out var sev)
|
|
? ParseSeverity(sev.GetString())
|
|
: VulnerabilitySeverity.Unknown;
|
|
|
|
yield return new ExtractedFinding
|
|
{
|
|
CveId = cveId,
|
|
Severity = severity,
|
|
PackageName = vuln.TryGetProperty("PkgName", out var name) ? name.GetString() ?? "" : "",
|
|
PackageVersion = vuln.TryGetProperty("InstalledVersion", out var version) ? version.GetString() ?? "" : "",
|
|
FixedVersion = vuln.TryGetProperty("FixedVersion", out var fix) ? fix.GetString() : null
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
private static VulnerabilitySeverity ParseSeverity(string? severity)
|
|
{
|
|
return severity?.ToUpperInvariant() switch
|
|
{
|
|
"CRITICAL" => VulnerabilitySeverity.Critical,
|
|
"HIGH" => VulnerabilitySeverity.High,
|
|
"MEDIUM" => VulnerabilitySeverity.Medium,
|
|
"LOW" => VulnerabilitySeverity.Low,
|
|
"NEGLIGIBLE" or "NONE" => VulnerabilitySeverity.None,
|
|
_ => VulnerabilitySeverity.Unknown
|
|
};
|
|
}
|
|
|
|
private static SeverityDistribution CalculateSeverityDistribution(List<ExtractedFinding> findings)
|
|
{
|
|
return new SeverityDistribution
|
|
{
|
|
Critical = findings.Count(f => f.Severity == VulnerabilitySeverity.Critical),
|
|
High = findings.Count(f => f.Severity == VulnerabilitySeverity.High),
|
|
Medium = findings.Count(f => f.Severity == VulnerabilitySeverity.Medium),
|
|
Low = findings.Count(f => f.Severity == VulnerabilitySeverity.Low),
|
|
None = findings.Count(f => f.Severity == VulnerabilitySeverity.None),
|
|
Unknown = findings.Count(f => f.Severity == VulnerabilitySeverity.Unknown)
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of comparing vulnerability findings.
|
|
/// </summary>
|
|
public sealed class VulnerabilityComparisonResult
|
|
{
|
|
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; }
|
|
|
|
// CVE counts
|
|
public int BaselineCveCount { get; set; }
|
|
public int CandidateCveCount { get; set; }
|
|
|
|
// Matching
|
|
public List<ExtractedFinding> OnlyInBaseline { get; set; } = [];
|
|
public List<ExtractedFinding> OnlyInCandidate { get; set; } = [];
|
|
public List<ExtractedFinding> MatchingCves { get; set; } = [];
|
|
|
|
// Accuracy metrics (relative to baseline)
|
|
public double Recall { get; set; }
|
|
public double Precision { get; set; }
|
|
public double F1Score { get; set; }
|
|
public double FalsePositiveRate { get; set; }
|
|
public double FalseNegativeRate { get; set; }
|
|
|
|
// Severity distribution
|
|
public SeverityDistribution BaselineSeverityDistribution { get; set; } = new();
|
|
public SeverityDistribution CandidateSeverityDistribution { get; set; } = new();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracted vulnerability finding.
|
|
/// </summary>
|
|
public sealed class ExtractedFinding
|
|
{
|
|
public required string CveId { get; init; }
|
|
public required VulnerabilitySeverity Severity { get; init; }
|
|
public required string PackageName { get; init; }
|
|
public required string PackageVersion { get; init; }
|
|
public string? FixedVersion { get; set; }
|
|
public double? CvssScore { get; set; }
|
|
|
|
public override string ToString() => $"{CveId} ({Severity}) in {PackageName}@{PackageVersion}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Severity distribution counts.
|
|
/// </summary>
|
|
public sealed class SeverityDistribution
|
|
{
|
|
public int Critical { get; set; }
|
|
public int High { get; set; }
|
|
public int Medium { get; set; }
|
|
public int Low { get; set; }
|
|
public int None { get; set; }
|
|
public int Unknown { get; set; }
|
|
|
|
public int Total => Critical + High + Medium + Low + None + Unknown;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Vulnerability severity levels.
|
|
/// </summary>
|
|
public enum VulnerabilitySeverity
|
|
{
|
|
Unknown = 0,
|
|
None = 1,
|
|
Low = 2,
|
|
Medium = 3,
|
|
High = 4,
|
|
Critical = 5
|
|
}
|