using System.Formats.Tar; using System.IO.Compression; using System.Text; using System.Text.Json; using Microsoft.Extensions.Logging.Abstractions; using StellaOps.Cli.Services; using StellaOps.Cli.Services.Models; namespace StellaOps.Cli.Tests; public sealed class AttestationBundleVerifierTests : IDisposable { private readonly string _tempDir; private readonly AttestationBundleVerifier _verifier; public AttestationBundleVerifierTests() { _tempDir = Path.Combine(Path.GetTempPath(), $"attest-bundle-test-{Guid.NewGuid():N}"); Directory.CreateDirectory(_tempDir); _verifier = new AttestationBundleVerifier(NullLogger.Instance); } public void Dispose() { if (Directory.Exists(_tempDir)) { Directory.Delete(_tempDir, recursive: true); } } [Fact] public async Task VerifyAsync_FileNotFound_ReturnsFileNotFoundCode() { var options = new AttestationBundleVerifyOptions( Path.Combine(_tempDir, "nonexistent.tgz"), Offline: true); var result = await _verifier.VerifyAsync(options, CancellationToken.None); Assert.False(result.Success); Assert.Equal(AttestationBundleExitCodes.FileNotFound, result.ExitCode); } [Fact] public async Task VerifyAsync_ValidBundle_ReturnsSuccess() { var bundlePath = await CreateValidBundleAsync(); var options = new AttestationBundleVerifyOptions(bundlePath, Offline: true); var result = await _verifier.VerifyAsync(options, CancellationToken.None); Assert.True(result.Success); Assert.Equal(AttestationBundleExitCodes.Success, result.ExitCode); Assert.Equal("verified", result.Status); } [Fact] public async Task VerifyAsync_ValidBundle_ReturnsMetadata() { var bundlePath = await CreateValidBundleAsync(); var options = new AttestationBundleVerifyOptions(bundlePath, Offline: true); var result = await _verifier.VerifyAsync(options, CancellationToken.None); Assert.True(result.Success); Assert.NotNull(result.ExportId); Assert.NotNull(result.AttestationId); Assert.NotNull(result.RootHash); Assert.StartsWith("sha256:", result.RootHash); } [Fact] public async Task VerifyAsync_CorruptedArchive_ReturnsFormatError() { var bundlePath = Path.Combine(_tempDir, "corrupted.tgz"); await File.WriteAllBytesAsync(bundlePath, Encoding.UTF8.GetBytes("not a valid tgz")); var options = new AttestationBundleVerifyOptions(bundlePath, Offline: true); var result = await _verifier.VerifyAsync(options, CancellationToken.None); Assert.False(result.Success); Assert.Equal(AttestationBundleExitCodes.FormatError, result.ExitCode); } [Fact] public async Task VerifyAsync_ChecksumMismatch_ReturnsChecksumMismatchCode() { var bundlePath = await CreateBundleWithBadChecksumAsync(); var options = new AttestationBundleVerifyOptions(bundlePath, Offline: true); var result = await _verifier.VerifyAsync(options, CancellationToken.None); Assert.False(result.Success); Assert.Equal(AttestationBundleExitCodes.ChecksumMismatch, result.ExitCode); } [Fact] public async Task VerifyAsync_ExternalChecksumMismatch_ReturnsChecksumMismatchCode() { var bundlePath = await CreateValidBundleAsync(); var checksumPath = bundlePath + ".sha256"; await File.WriteAllTextAsync(checksumPath, "0000000000000000000000000000000000000000000000000000000000000000 " + Path.GetFileName(bundlePath)); var options = new AttestationBundleVerifyOptions(bundlePath, Offline: true); var result = await _verifier.VerifyAsync(options, CancellationToken.None); Assert.False(result.Success); Assert.Equal(AttestationBundleExitCodes.ChecksumMismatch, result.ExitCode); } [Fact] public async Task VerifyAsync_MissingTransparency_WhenNotOffline_ReturnsMissingTransparencyCode() { var bundlePath = await CreateBundleWithoutTransparencyAsync(); var options = new AttestationBundleVerifyOptions( bundlePath, Offline: false, VerifyTransparency: true); var result = await _verifier.VerifyAsync(options, CancellationToken.None); Assert.False(result.Success); Assert.Equal(AttestationBundleExitCodes.MissingTransparency, result.ExitCode); } [Fact] public async Task VerifyAsync_MissingTransparency_WhenOffline_ReturnsSuccess() { var bundlePath = await CreateBundleWithoutTransparencyAsync(); var options = new AttestationBundleVerifyOptions( bundlePath, Offline: true, VerifyTransparency: true); var result = await _verifier.VerifyAsync(options, CancellationToken.None); Assert.True(result.Success); Assert.Equal(AttestationBundleExitCodes.Success, result.ExitCode); } [Fact] public async Task VerifyAsync_MissingDssePayload_ReturnsSignatureFailure() { var bundlePath = await CreateBundleWithMissingDssePayloadAsync(); var options = new AttestationBundleVerifyOptions(bundlePath, Offline: true); var result = await _verifier.VerifyAsync(options, CancellationToken.None); Assert.False(result.Success); Assert.Equal(AttestationBundleExitCodes.SignatureFailure, result.ExitCode); } [Fact] public async Task ImportAsync_ValidBundle_ReturnsSuccess() { var bundlePath = await CreateValidBundleAsync(); var options = new AttestationBundleImportOptions( bundlePath, Tenant: "test-tenant", Namespace: "test-namespace", Offline: true); var result = await _verifier.ImportAsync(options, CancellationToken.None); Assert.True(result.Success); Assert.Equal(AttestationBundleExitCodes.Success, result.ExitCode); Assert.Equal("imported", result.Status); } [Fact] public async Task ImportAsync_InvalidBundle_ReturnsVerificationFailed() { var bundlePath = Path.Combine(_tempDir, "invalid.tgz"); await File.WriteAllBytesAsync(bundlePath, Encoding.UTF8.GetBytes("not valid")); var options = new AttestationBundleImportOptions( bundlePath, Tenant: "test-tenant", Offline: true); var result = await _verifier.ImportAsync(options, CancellationToken.None); Assert.False(result.Success); Assert.Equal("verification_failed", result.Status); } [Fact] public async Task ImportAsync_InheritsTenantFromMetadata() { var bundlePath = await CreateValidBundleAsync(); var options = new AttestationBundleImportOptions( bundlePath, Tenant: null, // Not specified Offline: true); var result = await _verifier.ImportAsync(options, CancellationToken.None); Assert.True(result.Success); Assert.NotNull(result.TenantId); // Should come from bundle metadata } private async Task CreateValidBundleAsync() { var bundlePath = Path.Combine(_tempDir, $"valid-bundle-{Guid.NewGuid():N}.tgz"); var exportId = Guid.NewGuid().ToString("D"); var attestationId = Guid.NewGuid().ToString("D"); var tenantId = Guid.NewGuid().ToString("D"); // Create statement JSON var statement = new { _type = "https://in-toto.io/Statement/v1", predicateType = "https://stellaops.io/attestations/vuln-scan/v1", subject = new[] { new { name = "test-image:latest", digest = new Dictionary { ["sha256"] = "abc123" } } }, predicate = new { } }; var statementJson = JsonSerializer.Serialize(statement); var statementBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(statementJson)); // Create DSSE envelope var dsse = new { payloadType = "application/vnd.in-toto+json", payload = statementBase64, signatures = new[] { new { keyid = "key-001", sig = "fake-signature-for-test" } } }; var dsseJson = JsonSerializer.Serialize(dsse); // Create metadata var metadata = new { version = "attestation-bundle/v1", exportId, attestationId, tenantId, createdAtUtc = DateTimeOffset.UtcNow.ToString("O"), rootHash = "abc123def456", statementVersion = "v1" }; var metadataJson = JsonSerializer.Serialize(metadata); // Create transparency entries var transparencyNdjson = "{\"logIndex\":1,\"logId\":\"test\"}\n"; // Calculate checksums var dsseHash = ComputeHash(dsseJson); var statementHash = ComputeHash(statementJson); var metadataHash = ComputeHash(metadataJson); var transparencyHash = ComputeHash(transparencyNdjson); var checksums = new StringBuilder(); checksums.AppendLine("# Attestation bundle checksums (sha256)"); checksums.AppendLine($"{dsseHash} attestation.dsse.json"); checksums.AppendLine($"{metadataHash} metadata.json"); checksums.AppendLine($"{statementHash} statement.json"); checksums.AppendLine($"{transparencyHash} transparency.ndjson"); var checksumsText = checksums.ToString(); // Create archive await using var fileStream = File.Create(bundlePath); await using var gzipStream = new GZipStream(fileStream, CompressionLevel.SmallestSize); await using var tarWriter = new TarWriter(gzipStream, TarEntryFormat.Pax); await WriteEntryAsync(tarWriter, "attestation.dsse.json", dsseJson); await WriteEntryAsync(tarWriter, "checksums.txt", checksumsText); await WriteEntryAsync(tarWriter, "metadata.json", metadataJson); await WriteEntryAsync(tarWriter, "statement.json", statementJson); await WriteEntryAsync(tarWriter, "transparency.ndjson", transparencyNdjson); return bundlePath; } private async Task CreateBundleWithoutTransparencyAsync() { var bundlePath = Path.Combine(_tempDir, $"no-transparency-{Guid.NewGuid():N}.tgz"); var statement = new { _type = "https://in-toto.io/Statement/v1", predicateType = "https://stellaops.io/attestations/vuln-scan/v1", subject = new[] { new { name = "test", digest = new Dictionary { ["sha256"] = "abc" } } } }; var statementJson = JsonSerializer.Serialize(statement); var statementBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(statementJson)); var dsse = new { payloadType = "application/vnd.in-toto+json", payload = statementBase64, signatures = new[] { new { keyid = "key-001", sig = "fake-sig" } } }; var dsseJson = JsonSerializer.Serialize(dsse); var metadata = new { version = "attestation-bundle/v1", exportId = Guid.NewGuid().ToString("D"), attestationId = Guid.NewGuid().ToString("D"), tenantId = Guid.NewGuid().ToString("D"), rootHash = "abc123" }; var metadataJson = JsonSerializer.Serialize(metadata); var dsseHash = ComputeHash(dsseJson); var statementHash = ComputeHash(statementJson); var metadataHash = ComputeHash(metadataJson); var checksums = $"# Checksums\n{dsseHash} attestation.dsse.json\n{metadataHash} metadata.json\n{statementHash} statement.json\n"; await using var fileStream = File.Create(bundlePath); await using var gzipStream = new GZipStream(fileStream, CompressionLevel.SmallestSize); await using var tarWriter = new TarWriter(gzipStream, TarEntryFormat.Pax); await WriteEntryAsync(tarWriter, "attestation.dsse.json", dsseJson); await WriteEntryAsync(tarWriter, "checksums.txt", checksums); await WriteEntryAsync(tarWriter, "metadata.json", metadataJson); await WriteEntryAsync(tarWriter, "statement.json", statementJson); // No transparency.ndjson return bundlePath; } private async Task CreateBundleWithBadChecksumAsync() { var bundlePath = Path.Combine(_tempDir, $"bad-checksum-{Guid.NewGuid():N}.tgz"); var dsseJson = "{\"payloadType\":\"test\",\"payload\":\"dGVzdA==\",\"signatures\":[{\"keyid\":\"k\",\"sig\":\"s\"}]}"; var statementJson = "{\"_type\":\"test\"}"; var metadataJson = "{\"version\":\"v1\"}"; // Intentionally wrong checksum var checksums = "0000000000000000000000000000000000000000000000000000000000000000 attestation.dsse.json\n"; await using var fileStream = File.Create(bundlePath); await using var gzipStream = new GZipStream(fileStream, CompressionLevel.SmallestSize); await using var tarWriter = new TarWriter(gzipStream, TarEntryFormat.Pax); await WriteEntryAsync(tarWriter, "attestation.dsse.json", dsseJson); await WriteEntryAsync(tarWriter, "checksums.txt", checksums); await WriteEntryAsync(tarWriter, "metadata.json", metadataJson); await WriteEntryAsync(tarWriter, "statement.json", statementJson); return bundlePath; } private async Task CreateBundleWithMissingDssePayloadAsync() { var bundlePath = Path.Combine(_tempDir, $"no-dsse-payload-{Guid.NewGuid():N}.tgz"); // DSSE without payload var dsseJson = "{\"payloadType\":\"test\",\"signatures\":[]}"; var statementJson = "{\"_type\":\"test\"}"; var metadataJson = "{\"version\":\"v1\"}"; var dsseHash = ComputeHash(dsseJson); var statementHash = ComputeHash(statementJson); var metadataHash = ComputeHash(metadataJson); var checksums = $"{dsseHash} attestation.dsse.json\n{metadataHash} metadata.json\n{statementHash} statement.json\n"; await using var fileStream = File.Create(bundlePath); await using var gzipStream = new GZipStream(fileStream, CompressionLevel.SmallestSize); await using var tarWriter = new TarWriter(gzipStream, TarEntryFormat.Pax); await WriteEntryAsync(tarWriter, "attestation.dsse.json", dsseJson); await WriteEntryAsync(tarWriter, "checksums.txt", checksums); await WriteEntryAsync(tarWriter, "metadata.json", metadataJson); await WriteEntryAsync(tarWriter, "statement.json", statementJson); return bundlePath; } private static async Task WriteEntryAsync(TarWriter writer, string name, string content) { var bytes = Encoding.UTF8.GetBytes(content); using var dataStream = new MemoryStream(bytes); var entry = new PaxTarEntry(TarEntryType.RegularFile, name) { DataStream = dataStream }; await writer.WriteEntryAsync(entry); } private static string ComputeHash(string content) { var bytes = Encoding.UTF8.GetBytes(content); var hash = System.Security.Cryptography.SHA256.HashData(bytes); return Convert.ToHexString(hash).ToLowerInvariant(); } }