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