chore(sprints): archive 20260226 advisories and expand deterministic tests

This commit is contained in:
master
2026-03-04 03:09:23 +02:00
parent 4fe8eb56ae
commit aaad8104cb
35 changed files with 4686 additions and 1 deletions

View File

@@ -0,0 +1,359 @@
using System.CommandLine;
using System.Formats.Tar;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Attestor.Core.Verification;
using StellaOps.Cli.Commands;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Cli.Tests.Commands;
[Trait("Category", TestCategories.Unit)]
public sealed class Sprint222ProofVerificationTests : IDisposable
{
private readonly string _tempRoot;
public Sprint222ProofVerificationTests()
{
_tempRoot = Path.Combine(Path.GetTempPath(), $"stellaops-sprint222-{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempRoot);
}
public void Dispose()
{
try
{
if (Directory.Exists(_tempRoot))
{
Directory.Delete(_tempRoot, recursive: true);
}
}
catch
{
// Best effort cleanup.
}
}
[Fact]
public async Task SbomVerify_WithValidRsaSignature_ReportsVerified()
{
var (archivePath, trustRootPath) = CreateSignedSbomArchive(tamperSignature: false);
var verboseOption = new Option<bool>("--verbose");
var root = new RootCommand { SbomCommandGroup.BuildSbomCommand(verboseOption, CancellationToken.None) };
var result = await InvokeAsync(root, $"sbom verify --archive \"{archivePath}\" --trust-root \"{trustRootPath}\" --format json");
result.ExitCode.Should().Be(0, $"stdout: {result.StdOut}\nstderr: {result.StdErr}");
using var json = JsonDocument.Parse(result.StdOut);
var dsseCheck = FindCheck(json.RootElement, "DSSE envelope signature");
dsseCheck.GetProperty("passed").GetBoolean().Should().BeTrue();
dsseCheck.GetProperty("details").GetString().Should().Contain("dsse-signature-verified");
}
[Fact]
public async Task SbomVerify_WithTamperedSignature_ReturnsDeterministicFailureReason()
{
var (archivePath, trustRootPath) = CreateSignedSbomArchive(tamperSignature: true);
var verboseOption = new Option<bool>("--verbose");
var root = new RootCommand { SbomCommandGroup.BuildSbomCommand(verboseOption, CancellationToken.None) };
var result = await InvokeAsync(root, $"sbom verify --archive \"{archivePath}\" --trust-root \"{trustRootPath}\" --format json");
result.ExitCode.Should().Be(1, $"stdout: {result.StdOut}\nstderr: {result.StdErr}");
using var json = JsonDocument.Parse(result.StdOut);
var dsseCheck = FindCheck(json.RootElement, "DSSE envelope signature");
dsseCheck.GetProperty("passed").GetBoolean().Should().BeFalse();
dsseCheck.GetProperty("details").GetString().Should().Contain("dsse-signature-verification-failed");
}
[Fact]
public async Task SbomVerify_WithoutTrustRoot_ReturnsDeterministicTrustRootMissingReason()
{
var (archivePath, _) = CreateSignedSbomArchive(tamperSignature: false);
var verboseOption = new Option<bool>("--verbose");
var root = new RootCommand { SbomCommandGroup.BuildSbomCommand(verboseOption, CancellationToken.None) };
var result = await InvokeAsync(root, $"sbom verify --archive \"{archivePath}\" --format json");
result.ExitCode.Should().Be(1, $"stdout: {result.StdOut}\nstderr: {result.StdErr}");
using var json = JsonDocument.Parse(result.StdOut);
var dsseCheck = FindCheck(json.RootElement, "DSSE envelope signature");
dsseCheck.GetProperty("passed").GetBoolean().Should().BeFalse();
dsseCheck.GetProperty("details").GetString().Should().Be(
"trust-root-missing: supply --trust-root with trusted key/certificate material");
}
[Fact]
public async Task BundleVerify_WithValidRekorCheckpoint_ValidatesInclusion()
{
var (bundleDir, checkpointPath) = CreateBundleWithProof(mismatchCheckpointRoot: false);
var verboseOption = new Option<bool>("--verbose");
var services = new ServiceCollection().BuildServiceProvider();
var verifyCommand = BundleVerifyCommand.BuildVerifyBundleEnhancedCommand(services, verboseOption, CancellationToken.None);
var root = new RootCommand { verifyCommand };
var result = await InvokeAsync(
root,
$"verify --bundle \"{bundleDir}\" --rekor-checkpoint \"{checkpointPath}\" --output json --offline");
result.ExitCode.Should().Be(0);
using var json = JsonDocument.Parse(result.StdOut);
var inclusionCheck = FindCheck(json.RootElement, "rekor:inclusion");
inclusionCheck.GetProperty("passed").GetBoolean().Should().BeTrue();
inclusionCheck.GetProperty("message").GetString().Should().Contain("Inclusion verified");
}
[Fact]
public async Task BundleVerify_WithMismatchedCheckpointRoot_FailsDeterministically()
{
var (bundleDir, checkpointPath) = CreateBundleWithProof(mismatchCheckpointRoot: true);
var verboseOption = new Option<bool>("--verbose");
var services = new ServiceCollection().BuildServiceProvider();
var verifyCommand = BundleVerifyCommand.BuildVerifyBundleEnhancedCommand(services, verboseOption, CancellationToken.None);
var root = new RootCommand { verifyCommand };
var result = await InvokeAsync(
root,
$"verify --bundle \"{bundleDir}\" --rekor-checkpoint \"{checkpointPath}\" --output json --offline");
result.ExitCode.Should().Be(1);
using var json = JsonDocument.Parse(result.StdOut);
var inclusionCheck = FindCheck(json.RootElement, "rekor:inclusion");
inclusionCheck.GetProperty("passed").GetBoolean().Should().BeFalse();
inclusionCheck.GetProperty("message").GetString().Should().Be("proof-root-hash-mismatch-with-checkpoint");
}
[Fact]
public async Task BundleVerify_WithMissingCheckpointPath_FailsDeterministically()
{
var (bundleDir, _) = CreateBundleWithProof(mismatchCheckpointRoot: false);
var missingCheckpointPath = Path.Combine(_tempRoot, $"missing-checkpoint-{Guid.NewGuid():N}.json");
var verboseOption = new Option<bool>("--verbose");
var services = new ServiceCollection().BuildServiceProvider();
var verifyCommand = BundleVerifyCommand.BuildVerifyBundleEnhancedCommand(services, verboseOption, CancellationToken.None);
var root = new RootCommand { verifyCommand };
var result = await InvokeAsync(
root,
$"verify --bundle \"{bundleDir}\" --rekor-checkpoint \"{missingCheckpointPath}\" --output json --offline");
result.ExitCode.Should().Be(1);
using var json = JsonDocument.Parse(result.StdOut);
var inclusionCheck = FindCheck(json.RootElement, "rekor:inclusion");
inclusionCheck.GetProperty("passed").GetBoolean().Should().BeFalse();
inclusionCheck.GetProperty("message").GetString().Should().StartWith("checkpoint-not-found:");
}
private (string ArchivePath, string TrustRootPath) CreateSignedSbomArchive(bool tamperSignature)
{
var archivePath = Path.Combine(_tempRoot, $"sbom-{Guid.NewGuid():N}.tar.gz");
var trustRootPath = Path.Combine(_tempRoot, $"trust-{Guid.NewGuid():N}.pem");
var sbomJson = JsonSerializer.Serialize(new
{
spdxVersion = "SPDX-2.3",
SPDXID = "SPDXRef-DOCUMENT",
name = "test-sbom"
});
using var rsa = RSA.Create(2048);
var publicPem = rsa.ExportSubjectPublicKeyInfoPem();
File.WriteAllText(trustRootPath, publicPem);
var payloadBytes = Encoding.UTF8.GetBytes(sbomJson);
var payloadBase64 = Convert.ToBase64String(payloadBytes);
var pae = BuildDssePae("application/vnd.in-toto+json", payloadBytes);
var signature = rsa.SignData(pae, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
if (tamperSignature)
{
signature[0] ^= 0xFF;
}
var dsseJson = JsonSerializer.Serialize(new
{
payloadType = "application/vnd.in-toto+json",
payload = payloadBase64,
signatures = new[]
{
new { keyid = "test-key", sig = Convert.ToBase64String(signature) }
}
});
var files = new Dictionary<string, string>
{
["sbom.spdx.json"] = sbomJson,
["sbom.dsse.json"] = dsseJson
};
files["manifest.json"] = JsonSerializer.Serialize(new
{
schemaVersion = "1.0.0",
files = files.Select(entry => new
{
path = entry.Key,
sha256 = ComputeSha256Hex(entry.Value)
}).ToArray()
});
using var fileStream = File.Create(archivePath);
using var gzipStream = new GZipStream(fileStream, CompressionLevel.SmallestSize);
using var tarWriter = new TarWriter(gzipStream, TarEntryFormat.Ustar);
foreach (var (name, content) in files.OrderBy(kv => kv.Key, StringComparer.Ordinal))
{
var entry = new UstarTarEntry(TarEntryType.RegularFile, name)
{
DataStream = new MemoryStream(Encoding.UTF8.GetBytes(content), writable: false),
ModificationTime = new DateTimeOffset(2026, 2, 26, 0, 0, 0, TimeSpan.Zero),
Mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead
};
tarWriter.WriteEntry(entry);
}
return (archivePath, trustRootPath);
}
private (string BundleDir, string CheckpointPath) CreateBundleWithProof(bool mismatchCheckpointRoot)
{
var bundleDir = Path.Combine(_tempRoot, $"bundle-{Guid.NewGuid():N}");
Directory.CreateDirectory(bundleDir);
var artifactPath = Path.Combine(bundleDir, "attestations", "sample.txt");
Directory.CreateDirectory(Path.GetDirectoryName(artifactPath)!);
File.WriteAllText(artifactPath, "sample-artifact");
var artifactDigest = $"sha256:{ComputeSha256Hex("sample-artifact")}";
var leafHash = SHA256.HashData(Encoding.UTF8.GetBytes("leaf-hash"));
var siblingHash = SHA256.HashData(Encoding.UTF8.GetBytes("sibling-hash"));
var rootHash = MerkleProofVerifier.HashInterior(leafHash, siblingHash);
var checkpointRootHash = mismatchCheckpointRoot
? SHA256.HashData(Encoding.UTF8.GetBytes("different-root"))
: rootHash;
var proofJson = JsonSerializer.Serialize(new
{
logIndex = 0,
treeSize = 2,
leafHash = Convert.ToHexString(leafHash).ToLowerInvariant(),
hashes = new[] { Convert.ToHexString(siblingHash).ToLowerInvariant() },
rootHash = Convert.ToHexString(rootHash).ToLowerInvariant()
});
File.WriteAllText(Path.Combine(bundleDir, "rekor.proof.json"), proofJson);
var manifestJson = JsonSerializer.Serialize(new
{
schemaVersion = "2.0",
bundle = new
{
image = "registry.example.com/test:1.0",
digest = "sha256:testdigest",
artifacts = new[]
{
new
{
path = "attestations/sample.txt",
digest = artifactDigest,
mediaType = "text/plain"
}
}
},
verify = new
{
expectations = new { payloadTypes = Array.Empty<string>() }
}
});
File.WriteAllText(Path.Combine(bundleDir, "manifest.json"), manifestJson);
var checkpointPath = Path.Combine(_tempRoot, $"checkpoint-{Guid.NewGuid():N}.json");
var checkpointJson = JsonSerializer.Serialize(new
{
treeSize = 2,
rootHash = Convert.ToHexString(checkpointRootHash).ToLowerInvariant()
});
File.WriteAllText(checkpointPath, checkpointJson);
return (bundleDir, checkpointPath);
}
private static async Task<CommandInvocationResult> InvokeAsync(RootCommand root, string args)
{
var stdout = new StringWriter();
var stderr = new StringWriter();
var originalOut = Console.Out;
var originalErr = Console.Error;
var originalExitCode = Environment.ExitCode;
Environment.ExitCode = 0;
try
{
Console.SetOut(stdout);
Console.SetError(stderr);
var parseResult = root.Parse(args);
var returnCode = await parseResult.InvokeAsync().ConfigureAwait(false);
var exitCode = returnCode != 0 ? returnCode : Environment.ExitCode;
return new CommandInvocationResult(stdout.ToString(), stderr.ToString(), exitCode);
}
finally
{
Console.SetOut(originalOut);
Console.SetError(originalErr);
Environment.ExitCode = originalExitCode;
}
}
private static byte[] BuildDssePae(string payloadType, byte[] payload)
{
var header = Encoding.UTF8.GetBytes("DSSEv1");
var payloadTypeBytes = Encoding.UTF8.GetBytes(payloadType);
var payloadTypeLengthBytes = Encoding.UTF8.GetBytes(payloadTypeBytes.Length.ToString());
var payloadLengthBytes = Encoding.UTF8.GetBytes(payload.Length.ToString());
var space = new[] { (byte)' ' };
var output = new byte[
header.Length + space.Length + payloadTypeLengthBytes.Length + space.Length +
payloadTypeBytes.Length + space.Length + payloadLengthBytes.Length + space.Length +
payload.Length];
var offset = 0;
Buffer.BlockCopy(header, 0, output, offset, header.Length); offset += header.Length;
Buffer.BlockCopy(space, 0, output, offset, space.Length); offset += space.Length;
Buffer.BlockCopy(payloadTypeLengthBytes, 0, output, offset, payloadTypeLengthBytes.Length); offset += payloadTypeLengthBytes.Length;
Buffer.BlockCopy(space, 0, output, offset, space.Length); offset += space.Length;
Buffer.BlockCopy(payloadTypeBytes, 0, output, offset, payloadTypeBytes.Length); offset += payloadTypeBytes.Length;
Buffer.BlockCopy(space, 0, output, offset, space.Length); offset += space.Length;
Buffer.BlockCopy(payloadLengthBytes, 0, output, offset, payloadLengthBytes.Length); offset += payloadLengthBytes.Length;
Buffer.BlockCopy(space, 0, output, offset, space.Length); offset += space.Length;
Buffer.BlockCopy(payload, 0, output, offset, payload.Length);
return output;
}
private static string ComputeSha256Hex(string content)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static JsonElement FindCheck(JsonElement root, string checkName)
{
var checks = root.GetProperty("checks");
foreach (var check in checks.EnumerateArray())
{
if (check.TryGetProperty("name", out var name) &&
string.Equals(name.GetString(), checkName, StringComparison.Ordinal))
{
return check;
}
}
throw new Xunit.Sdk.XunitException($"Check '{checkName}' not found in output.");
}
private sealed record CommandInvocationResult(string StdOut, string StdErr, int ExitCode);
}