Files
git.stella-ops.org/tests/interop/StellaOps.Interop.Tests/InteropTestHarness.cs

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);