Files
git.stella-ops.org/src/Cli/__Tests/StellaOps.Cli.Tests/AttestationBundleVerifierTests.cs
StellaOps Bot 7c24ed96ee up
2025-12-07 22:49:53 +02:00

407 lines
15 KiB
C#

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<AttestationBundleVerifier>.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<string> 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<string, string> { ["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<string> 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<string, string> { ["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<string> 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<string> 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();
}
}