255 lines
7.7 KiB
C#
255 lines
7.7 KiB
C#
namespace StellaOps.Interop.Tests;
|
|
|
|
using System.Diagnostics;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
|
|
/// <summary>
|
|
/// Test harness for SBOM interoperability testing.
|
|
/// Coordinates Syft, Grype, Trivy, and cosign tools.
|
|
/// </summary>
|
|
public sealed class InteropTestHarness : IAsyncLifetime
|
|
{
|
|
private readonly ToolManager _toolManager;
|
|
private readonly string _workDir;
|
|
|
|
public InteropTestHarness()
|
|
{
|
|
_workDir = Path.Combine(Path.GetTempPath(), $"interop-{Guid.NewGuid():N}");
|
|
_toolManager = new ToolManager(_workDir);
|
|
}
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
Directory.CreateDirectory(_workDir);
|
|
|
|
// Verify tools are available
|
|
await _toolManager.VerifyToolAsync("syft", "--version");
|
|
await _toolManager.VerifyToolAsync("grype", "--version");
|
|
await _toolManager.VerifyToolAsync("cosign", "version");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generate SBOM using Syft.
|
|
/// </summary>
|
|
public async Task<SbomResult> GenerateSbomWithSyft(
|
|
string imageRef,
|
|
SbomFormat format,
|
|
CancellationToken ct = default)
|
|
{
|
|
var formatArg = format switch
|
|
{
|
|
SbomFormat.CycloneDx16 => "cyclonedx-json",
|
|
SbomFormat.Spdx30 => "spdx-json",
|
|
_ => throw new ArgumentException($"Unsupported format: {format}")
|
|
};
|
|
|
|
var outputPath = Path.Combine(_workDir, $"sbom-syft-{format}.json");
|
|
var result = await _toolManager.RunAsync(
|
|
"syft",
|
|
$"{imageRef} -o {formatArg}={outputPath}",
|
|
ct);
|
|
|
|
if (!result.Success)
|
|
return SbomResult.Failed(result.Error ?? "Syft execution failed");
|
|
|
|
var content = await File.ReadAllTextAsync(outputPath, ct);
|
|
var digest = ComputeDigest(content);
|
|
|
|
return new SbomResult(
|
|
Success: true,
|
|
Path: outputPath,
|
|
Format: format,
|
|
Content: content,
|
|
Digest: digest);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generate SBOM using Stella scanner.
|
|
/// </summary>
|
|
public async Task<SbomResult> GenerateSbomWithStella(
|
|
string imageRef,
|
|
SbomFormat format,
|
|
CancellationToken ct = default)
|
|
{
|
|
var formatArg = format switch
|
|
{
|
|
SbomFormat.CycloneDx16 => "cyclonedx",
|
|
SbomFormat.Spdx30 => "spdx",
|
|
_ => throw new ArgumentException($"Unsupported format: {format}")
|
|
};
|
|
|
|
var outputPath = Path.Combine(_workDir, $"stella-sbom-{format}.json");
|
|
var result = await _toolManager.RunAsync(
|
|
"stella",
|
|
$"scan {imageRef} --sbom-format {formatArg} --sbom-output {outputPath}",
|
|
ct);
|
|
|
|
if (!result.Success)
|
|
return SbomResult.Failed(result.Error ?? "Stella execution failed");
|
|
|
|
var content = await File.ReadAllTextAsync(outputPath, ct);
|
|
var digest = ComputeDigest(content);
|
|
|
|
return new SbomResult(
|
|
Success: true,
|
|
Path: outputPath,
|
|
Format: format,
|
|
Content: content,
|
|
Digest: digest);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attest SBOM using cosign.
|
|
/// </summary>
|
|
public async Task<AttestationResult> AttestWithCosign(
|
|
string sbomPath,
|
|
string imageRef,
|
|
CancellationToken ct = default)
|
|
{
|
|
var result = await _toolManager.RunAsync(
|
|
"cosign",
|
|
$"attest --predicate {sbomPath} --type cyclonedx {imageRef} --yes",
|
|
ct);
|
|
|
|
if (!result.Success)
|
|
return AttestationResult.Failed(result.Error ?? "Cosign attestation failed");
|
|
|
|
return new AttestationResult(Success: true, ImageRef: imageRef);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Scan using Grype from SBOM (no image pull).
|
|
/// </summary>
|
|
public async Task<GrypeScanResult> ScanWithGrypeFromSbom(
|
|
string sbomPath,
|
|
CancellationToken ct = default)
|
|
{
|
|
var outputPath = Path.Combine(_workDir, "grype-findings.json");
|
|
var result = await _toolManager.RunAsync(
|
|
"grype",
|
|
$"sbom:{sbomPath} -o json --file {outputPath}",
|
|
ct);
|
|
|
|
if (!result.Success)
|
|
return GrypeScanResult.Failed(result.Error ?? "Grype scan failed");
|
|
|
|
var content = await File.ReadAllTextAsync(outputPath, ct);
|
|
var findings = ParseGrypeFindings(content);
|
|
|
|
return new GrypeScanResult(
|
|
Success: true,
|
|
Findings: findings,
|
|
RawOutput: content);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compare findings between Stella and Grype.
|
|
/// </summary>
|
|
public FindingsComparisonResult CompareFindings(
|
|
IReadOnlyList<Finding> stellaFindings,
|
|
IReadOnlyList<GrypeFinding> grypeFindings,
|
|
decimal tolerancePercent = 5)
|
|
{
|
|
var stellaVulns = stellaFindings
|
|
.Select(f => (f.VulnerabilityId, f.PackagePurl))
|
|
.ToHashSet();
|
|
|
|
var grypeVulns = grypeFindings
|
|
.Select(f => (f.VulnerabilityId, f.PackagePurl))
|
|
.ToHashSet();
|
|
|
|
var onlyInStella = stellaVulns.Except(grypeVulns).ToList();
|
|
var onlyInGrype = grypeVulns.Except(stellaVulns).ToList();
|
|
var inBoth = stellaVulns.Intersect(grypeVulns).ToList();
|
|
|
|
var totalUnique = stellaVulns.Union(grypeVulns).Count();
|
|
var parityPercent = totalUnique > 0
|
|
? (decimal)inBoth.Count / totalUnique * 100
|
|
: 100;
|
|
|
|
return new FindingsComparisonResult(
|
|
ParityPercent: parityPercent,
|
|
IsWithinTolerance: parityPercent >= (100 - tolerancePercent),
|
|
StellaTotalFindings: stellaFindings.Count,
|
|
GrypeTotalFindings: grypeFindings.Count,
|
|
MatchingFindings: inBoth.Count,
|
|
OnlyInStella: onlyInStella.Count,
|
|
OnlyInGrype: onlyInGrype.Count,
|
|
OnlyInStellaDetails: onlyInStella,
|
|
OnlyInGrypeDetails: onlyInGrype);
|
|
}
|
|
|
|
public Task DisposeAsync()
|
|
{
|
|
if (Directory.Exists(_workDir))
|
|
Directory.Delete(_workDir, recursive: true);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private static string ComputeDigest(string content) =>
|
|
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content))).ToLowerInvariant();
|
|
|
|
private static IReadOnlyList<GrypeFinding> ParseGrypeFindings(string json)
|
|
{
|
|
// Placeholder: In real implementation, parse Grype JSON output
|
|
// For now, return empty list
|
|
return Array.Empty<GrypeFinding>();
|
|
}
|
|
}
|
|
|
|
public enum SbomFormat
|
|
{
|
|
CycloneDx16,
|
|
Spdx30
|
|
}
|
|
|
|
public sealed record SbomResult(
|
|
bool Success,
|
|
string? Path = null,
|
|
SbomFormat? Format = null,
|
|
string? Content = null,
|
|
string? Digest = null,
|
|
string? Error = null)
|
|
{
|
|
public static SbomResult Failed(string error) => new(false, Error: error);
|
|
}
|
|
|
|
public sealed record AttestationResult(
|
|
bool Success,
|
|
string? ImageRef = null,
|
|
string? Error = null)
|
|
{
|
|
public static AttestationResult Failed(string error) => new(false, Error: error);
|
|
}
|
|
|
|
public sealed record GrypeScanResult(
|
|
bool Success,
|
|
IReadOnlyList<GrypeFinding>? Findings = null,
|
|
string? RawOutput = null,
|
|
string? Error = null)
|
|
{
|
|
public static GrypeScanResult Failed(string error) => new(false, Error: error);
|
|
}
|
|
|
|
public sealed record FindingsComparisonResult(
|
|
decimal ParityPercent,
|
|
bool IsWithinTolerance,
|
|
int StellaTotalFindings,
|
|
int GrypeTotalFindings,
|
|
int MatchingFindings,
|
|
int OnlyInStella,
|
|
int OnlyInGrype,
|
|
IReadOnlyList<(string VulnId, string Purl)> OnlyInStellaDetails,
|
|
IReadOnlyList<(string VulnId, string Purl)> OnlyInGrypeDetails);
|
|
|
|
public sealed record Finding(
|
|
string VulnerabilityId,
|
|
string PackagePurl,
|
|
string Severity);
|
|
|
|
public sealed record GrypeFinding(
|
|
string VulnerabilityId,
|
|
string PackagePurl,
|
|
string Severity);
|