namespace StellaOps.Interop.Tests;
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
///
/// Test harness for SBOM interoperability testing.
/// Coordinates Syft, Grype, Trivy, and cosign tools.
///
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");
}
///
/// Generate SBOM using Syft.
///
public async Task 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);
}
///
/// Generate SBOM using Stella scanner.
///
public async Task 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);
}
///
/// Attest SBOM using cosign.
///
public async Task 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);
}
///
/// Scan using Grype from SBOM (no image pull).
///
public async Task 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);
}
///
/// Compare findings between Stella and Grype.
///
public FindingsComparisonResult CompareFindings(
IReadOnlyList stellaFindings,
IReadOnlyList 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 ParseGrypeFindings(string json)
{
// Placeholder: In real implementation, parse Grype JSON output
// For now, return empty list
return Array.Empty();
}
}