chore(sprints): archive 20260226 advisories and expand deterministic tests
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user