// // SPDX-License-Identifier: BUSL-1.1 // Sprint: SPRINT_20260112_016_SCANNER_signed_sbom_archive_spec (SBOM-SPEC-011) // using System.IO.Compression; using System.Security.Cryptography; using System.Text; using System.Text.Json; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Time.Testing; using StellaOps.Scanner.WebService.Domain; using StellaOps.Scanner.WebService.Services; using StellaOps.TestKit; using FakeTimeProvider = Microsoft.Extensions.Time.Testing.FakeTimeProvider; using Xunit; namespace StellaOps.Scanner.WebService.Tests; /// /// Tests for . /// Sprint: SPRINT_20260112_016_SCANNER_signed_sbom_archive_spec (SBOM-SPEC-011) /// [Trait("Category", TestCategories.Unit)] public sealed class SignedSbomArchiveBuilderTests : IDisposable { private static readonly DateTimeOffset FixedTime = new(2026, 1, 16, 10, 30, 0, TimeSpan.Zero); private readonly SignedSbomArchiveBuilder _builder; private readonly List _streamsToDispose = new(); public SignedSbomArchiveBuilderTests() { var timeProvider = new FakeTimeProvider(FixedTime); _builder = new SignedSbomArchiveBuilder(timeProvider, NullLogger.Instance); } public void Dispose() { foreach (var stream in _streamsToDispose) { stream.Dispose(); } } #region Archive Structure Tests [Fact] public async Task BuildAsync_WithMinimalInput_CreatesValidArchive() { // Arrange var request = CreateMinimalRequest(); // Act var result = await _builder.BuildAsync(request); _streamsToDispose.Add(result.Stream); // Assert Assert.NotNull(result); Assert.True(result.Size > 0); Assert.StartsWith("signed-sbom-", result.FileName); Assert.EndsWith(".tar.gz", result.FileName); Assert.Equal("application/gzip", result.ContentType); } [Fact] public async Task BuildAsync_IncludesMandatoryFiles() { // Arrange var request = CreateMinimalRequest(); // Act var result = await _builder.BuildAsync(request); _streamsToDispose.Add(result.Stream); // Assert - Extract and verify file list var files = await ExtractTarGzFileListAsync(result.Stream); Assert.Contains(files, f => f.EndsWith("manifest.json")); Assert.Contains(files, f => f.EndsWith("metadata.json")); Assert.Contains(files, f => f.EndsWith("sbom.spdx.json") || f.EndsWith("sbom.cdx.json")); Assert.Contains(files, f => f.EndsWith("sbom.dsse.json")); Assert.Contains(files, f => f.EndsWith("certs/signing-cert.pem")); Assert.Contains(files, f => f.EndsWith("VERIFY.md")); } [Fact] public async Task BuildAsync_WithSpdxFormat_UsesSpdxFileName() { // Arrange var request = CreateMinimalRequest() with { SbomFormat = "spdx-2.3" }; // Act var result = await _builder.BuildAsync(request); _streamsToDispose.Add(result.Stream); // Assert var files = await ExtractTarGzFileListAsync(result.Stream); Assert.Contains(files, f => f.EndsWith("sbom.spdx.json")); Assert.DoesNotContain(files, f => f.EndsWith("sbom.cdx.json")); } [Fact] public async Task BuildAsync_WithCycloneDxFormat_UsesCdxFileName() { // Arrange var request = CreateMinimalRequest() with { SbomFormat = "cyclonedx-1.7" }; // Act var result = await _builder.BuildAsync(request); _streamsToDispose.Add(result.Stream); // Assert var files = await ExtractTarGzFileListAsync(result.Stream); Assert.Contains(files, f => f.EndsWith("sbom.cdx.json")); Assert.DoesNotContain(files, f => f.EndsWith("sbom.spdx.json")); } #endregion #region Optional Content Tests [Fact] public async Task BuildAsync_WithSigningChain_IncludesChainFile() { // Arrange var request = CreateMinimalRequest() with { SigningChainPem = "-----BEGIN CERTIFICATE-----\nCHAIN\n-----END CERTIFICATE-----" }; // Act var result = await _builder.BuildAsync(request); _streamsToDispose.Add(result.Stream); // Assert var files = await ExtractTarGzFileListAsync(result.Stream); Assert.Contains(files, f => f.EndsWith("certs/signing-chain.pem")); } [Fact] public async Task BuildAsync_WithFulcioRoot_IncludesFulcioRootFile() { // Arrange var request = CreateMinimalRequest() with { FulcioRootPem = "-----BEGIN CERTIFICATE-----\nFULCIO\n-----END CERTIFICATE-----" }; // Act var result = await _builder.BuildAsync(request); _streamsToDispose.Add(result.Stream); // Assert var files = await ExtractTarGzFileListAsync(result.Stream); Assert.Contains(files, f => f.EndsWith("certs/fulcio-root.pem")); } [Fact] public async Task BuildAsync_WithRekorProof_IncludesRekorFiles() { // Arrange var request = CreateMinimalRequest() with { IncludeRekorProof = true, RekorInclusionProofBytes = Encoding.UTF8.GetBytes("{\"proof\": \"test\"}"), RekorCheckpointBytes = Encoding.UTF8.GetBytes("checkpoint"), RekorPublicKeyPem = "-----BEGIN PUBLIC KEY-----\nREKOR\n-----END PUBLIC KEY-----", RekorLogIndex = 12345678 }; // Act var result = await _builder.BuildAsync(request); _streamsToDispose.Add(result.Stream); // Assert var files = await ExtractTarGzFileListAsync(result.Stream); Assert.Contains(files, f => f.EndsWith("rekor-proof/inclusion-proof.json")); Assert.Contains(files, f => f.EndsWith("rekor-proof/checkpoint.sig")); Assert.Contains(files, f => f.EndsWith("rekor-proof/rekor-public.pem")); Assert.Equal(12345678, result.RekorLogIndex); } [Fact] public async Task BuildAsync_WithRekorProofDisabled_ExcludesRekorFiles() { // Arrange var request = CreateMinimalRequest() with { IncludeRekorProof = false }; // Act var result = await _builder.BuildAsync(request); _streamsToDispose.Add(result.Stream); // Assert var files = await ExtractTarGzFileListAsync(result.Stream); Assert.DoesNotContain(files, f => f.Contains("rekor-proof/")); } [Fact] public async Task BuildAsync_WithSchemas_IncludesSchemasReadme() { // Arrange var request = CreateMinimalRequest() with { IncludeSchemas = true }; // Act var result = await _builder.BuildAsync(request); _streamsToDispose.Add(result.Stream); // Assert var files = await ExtractTarGzFileListAsync(result.Stream); Assert.Contains(files, f => f.EndsWith("schemas/README.md")); } [Fact] public async Task BuildAsync_WithoutSchemas_ExcludesSchemasDirectory() { // Arrange var request = CreateMinimalRequest() with { IncludeSchemas = false }; // Act var result = await _builder.BuildAsync(request); _streamsToDispose.Add(result.Stream); // Assert var files = await ExtractTarGzFileListAsync(result.Stream); Assert.DoesNotContain(files, f => f.Contains("schemas/")); } #endregion #region Digest and Hash Tests [Fact] public async Task BuildAsync_ComputesCorrectSbomDigest() { // Arrange var sbomContent = "{\"spdxVersion\": \"SPDX-2.3\"}"; var sbomBytes = Encoding.UTF8.GetBytes(sbomContent); var expectedDigest = ComputeSha256Hex(sbomBytes); var request = CreateMinimalRequest() with { SbomBytes = sbomBytes }; // Act var result = await _builder.BuildAsync(request); _streamsToDispose.Add(result.Stream); // Assert Assert.Equal(expectedDigest, result.SbomDigest); } [Fact] public async Task BuildAsync_ComputesNonEmptyArchiveDigest() { // Arrange var request = CreateMinimalRequest(); // Act var result = await _builder.BuildAsync(request); _streamsToDispose.Add(result.Stream); // Assert Assert.NotNull(result.ArchiveDigest); Assert.Equal(64, result.ArchiveDigest.Length); // SHA-256 hex string length Assert.Matches("^[a-f0-9]{64}$", result.ArchiveDigest); } [Fact] public async Task BuildAsync_ComputesNonEmptyMerkleRoot() { // Arrange var request = CreateMinimalRequest(); // Act var result = await _builder.BuildAsync(request); _streamsToDispose.Add(result.Stream); // Assert Assert.NotNull(result.MerkleRoot); Assert.StartsWith("sha256:", result.MerkleRoot); } #endregion #region Determinism Tests [Fact] public async Task BuildAsync_SameInput_ProducesSameSbomDigest() { // Arrange var request = CreateMinimalRequest(); // Act var result1 = await _builder.BuildAsync(request); _streamsToDispose.Add(result1.Stream); var result2 = await _builder.BuildAsync(request); _streamsToDispose.Add(result2.Stream); // Assert Assert.Equal(result1.SbomDigest, result2.SbomDigest); } [Fact] public async Task BuildAsync_SameInput_ProducesSameMerkleRoot() { // Arrange var request = CreateMinimalRequest(); // Act var result1 = await _builder.BuildAsync(request); _streamsToDispose.Add(result1.Stream); var result2 = await _builder.BuildAsync(request); _streamsToDispose.Add(result2.Stream); // Assert Assert.Equal(result1.MerkleRoot, result2.MerkleRoot); } #endregion #region Metadata Tests [Fact] public async Task BuildAsync_MetadataContainsRequiredFields() { // Arrange var request = CreateMinimalRequest() with { ImageRef = "ghcr.io/test/image:v1.0.0", ImageDigest = "sha256:abc123", SbomFormat = "spdx-2.3", ComponentCount = 10, PackageCount = 5, FileCount = 100, SignatureIssuer = "https://accounts.google.com", SignatureSubject = "test@example.com" }; // Act var result = await _builder.BuildAsync(request); _streamsToDispose.Add(result.Stream); // Assert - Extract and parse metadata.json var metadataJson = await ExtractFileContentAsync(result.Stream, "metadata.json"); Assert.NotNull(metadataJson); var metadata = JsonSerializer.Deserialize(metadataJson); Assert.Equal("1.0.0", metadata.GetProperty("schemaVersion").GetString()); Assert.True(metadata.TryGetProperty("stellaOps", out _)); Assert.True(metadata.TryGetProperty("generation", out _)); Assert.True(metadata.TryGetProperty("input", out _)); Assert.True(metadata.TryGetProperty("sbom", out _)); Assert.True(metadata.TryGetProperty("signature", out _)); var input = metadata.GetProperty("input"); Assert.Equal("ghcr.io/test/image:v1.0.0", input.GetProperty("imageRef").GetString()); Assert.Equal("sha256:abc123", input.GetProperty("imageDigest").GetString()); var sbom = metadata.GetProperty("sbom"); Assert.Equal("spdx-2.3", sbom.GetProperty("format").GetString()); Assert.Equal(10, sbom.GetProperty("componentCount").GetInt32()); } #endregion #region Manifest Tests [Fact] public async Task BuildAsync_ManifestListsAllFiles() { // Arrange var request = CreateMinimalRequest() with { IncludeSchemas = true }; // Act var result = await _builder.BuildAsync(request); _streamsToDispose.Add(result.Stream); // Assert - Extract and parse manifest.json var manifestJson = await ExtractFileContentAsync(result.Stream, "manifest.json"); Assert.NotNull(manifestJson); var manifest = JsonSerializer.Deserialize(manifestJson); Assert.Equal("1.0.0", manifest.GetProperty("schemaVersion").GetString()); Assert.True(manifest.TryGetProperty("archiveId", out _)); Assert.True(manifest.TryGetProperty("generatedAt", out _)); Assert.True(manifest.TryGetProperty("files", out _)); Assert.True(manifest.TryGetProperty("merkleRoot", out _)); Assert.True(manifest.TryGetProperty("totalFiles", out _)); Assert.True(manifest.TryGetProperty("totalSize", out _)); var files = manifest.GetProperty("files"); Assert.True(files.GetArrayLength() > 0); // Verify each file entry has required fields foreach (var file in files.EnumerateArray()) { Assert.True(file.TryGetProperty("path", out _)); Assert.True(file.TryGetProperty("sha256", out _)); Assert.True(file.TryGetProperty("size", out _)); Assert.True(file.TryGetProperty("mediaType", out _)); } } [Fact] public async Task BuildAsync_ManifestFileHashesAreValid() { // Arrange var sbomContent = "{\"test\": \"sbom\"}"; var request = CreateMinimalRequest() with { SbomBytes = Encoding.UTF8.GetBytes(sbomContent), SbomFormat = "spdx-2.3" }; // Act var result = await _builder.BuildAsync(request); _streamsToDispose.Add(result.Stream); // Assert var manifestJson = await ExtractFileContentAsync(result.Stream, "manifest.json"); var manifest = JsonSerializer.Deserialize(manifestJson); var files = manifest.GetProperty("files"); var sbomEntry = files.EnumerateArray() .FirstOrDefault(f => f.GetProperty("path").GetString()?.EndsWith("sbom.spdx.json") == true); Assert.NotNull(sbomEntry.GetProperty("sha256").GetString()); // Verify SBOM hash matches expected var expectedHash = ComputeSha256Hex(Encoding.UTF8.GetBytes(sbomContent)); Assert.Equal(expectedHash, sbomEntry.GetProperty("sha256").GetString()); } #endregion #region VERIFY.md Tests [Fact] public async Task BuildAsync_VerifyMdContainsVerificationInstructions() { // Arrange var request = CreateMinimalRequest() with { SbomFormat = "spdx-2.3", RekorLogIndex = 12345678 }; // Act var result = await _builder.BuildAsync(request); _streamsToDispose.Add(result.Stream); // Assert var verifyMd = await ExtractFileContentAsync(result.Stream, "VERIFY.md"); Assert.NotNull(verifyMd); Assert.Contains("# SBOM Archive Verification", verifyMd); Assert.Contains("Quick Verification", verifyMd); Assert.Contains("Signature Verification", verifyMd); Assert.Contains("cosign verify-blob", verifyMd); Assert.Contains("sbom.spdx.json", verifyMd); } [Fact] public async Task BuildAsync_VerifyMdIncludesRekorSectionWhenAvailable() { // Arrange var request = CreateMinimalRequest() with { IncludeRekorProof = true, RekorLogIndex = 12345678 }; // Act var result = await _builder.BuildAsync(request); _streamsToDispose.Add(result.Stream); // Assert var verifyMd = await ExtractFileContentAsync(result.Stream, "VERIFY.md"); Assert.Contains("Rekor Transparency Log", verifyMd); Assert.Contains("12345678", verifyMd); Assert.Contains("rekor-cli verify", verifyMd); } [Fact] public async Task BuildAsync_VerifyMdIncludesFileHashTable() { // Arrange var request = CreateMinimalRequest(); // Act var result = await _builder.BuildAsync(request); _streamsToDispose.Add(result.Stream); // Assert var verifyMd = await ExtractFileContentAsync(result.Stream, "VERIFY.md"); Assert.Contains("Archive Contents", verifyMd); Assert.Contains("| File | Size | SHA-256 |", verifyMd); Assert.Contains("Merkle Root", verifyMd); } #endregion #region Error Handling Tests [Fact] public async Task BuildAsync_WithNullRequest_ThrowsArgumentNullException() { // Act & Assert await Assert.ThrowsAsync(() => _builder.BuildAsync(null!)); } [Fact] public async Task BuildAsync_SupportsCancellation() { // Arrange var request = CreateMinimalRequest(); using var cts = new CancellationTokenSource(); cts.Cancel(); // Act & Assert await Assert.ThrowsAsync( () => _builder.BuildAsync(request, cts.Token)); } #endregion #region Test Helpers private static SignedSbomArchiveRequest CreateMinimalRequest() { var sbomBytes = Encoding.UTF8.GetBytes("{\"spdxVersion\": \"SPDX-2.3\", \"packages\": []}"); var dsseBytes = Encoding.UTF8.GetBytes(""" { "payloadType": "application/vnd.in-toto+json", "payload": "base64-encoded-payload", "signatures": [{"sig": "test-signature"}] } """); var certPem = """ -----BEGIN CERTIFICATE----- MIIBkTCB+wIJAKHBfFmJ/r7CMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRl c3RjYTAeFw0yNjAxMTYwMDAwMDBaFw0yNzAxMTYwMDAwMDBaMBExDzANBgNVBAMM BnRlc3RjYTBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC5Q2QRqzFVcFm5AwQKDQCu xK5nMPVPu9F4Nz7Q3z5F5w5F5w5F5w5F5w5F5w5F5w5F5w5F5w5F5w5F5w5F5w5F AgMBAAGjUDBOMB0GA1UdDgQWBBQExample0MB8GA1UdIwQYMBaAFExample0MAwGA 1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADQQExample -----END CERTIFICATE----- """; return new SignedSbomArchiveRequest { ScanId = new ScanId("scan-test-001"), SbomBytes = sbomBytes, SbomFormat = "spdx-2.3", DsseEnvelopeBytes = dsseBytes, SigningCertPem = certPem, ImageRef = "ghcr.io/test/image:latest", ImageDigest = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", ComponentCount = 5, PackageCount = 3, FileCount = 20, IncludeRekorProof = false, IncludeSchemas = false }; } private static async Task> ExtractTarGzFileListAsync(Stream stream) { var files = new List(); stream.Position = 0; await using var gzipStream = new GZipStream(stream, CompressionMode.Decompress, leaveOpen: true); using var memoryStream = new MemoryStream(); await gzipStream.CopyToAsync(memoryStream); memoryStream.Position = 0; var buffer = new byte[512]; while (memoryStream.Position < memoryStream.Length - 1024) { var bytesRead = await memoryStream.ReadAsync(buffer.AsMemory(0, 512)); if (bytesRead < 512) break; // Check for end-of-archive marker (all zeros) if (buffer.All(b => b == 0)) break; // Extract file name from header (first 100 bytes) var nameEnd = Array.IndexOf(buffer, (byte)0); if (nameEnd < 0) nameEnd = 100; var fileName = Encoding.ASCII.GetString(buffer, 0, Math.Min(nameEnd, 100)).TrimEnd('\0'); if (!string.IsNullOrEmpty(fileName)) { files.Add(fileName); } // Get file size from header (bytes 124-135, octal) var sizeStr = Encoding.ASCII.GetString(buffer, 124, 11).Trim('\0', ' '); var fileSize = string.IsNullOrEmpty(sizeStr) ? 0 : Convert.ToInt64(sizeStr, 8); // Skip file content (rounded up to 512-byte boundary) var paddedSize = ((fileSize + 511) / 512) * 512; memoryStream.Position += paddedSize; } stream.Position = 0; return files; } private static async Task ExtractFileContentAsync(Stream stream, string fileNamePattern) { stream.Position = 0; await using var gzipStream = new GZipStream(stream, CompressionMode.Decompress, leaveOpen: true); using var memoryStream = new MemoryStream(); await gzipStream.CopyToAsync(memoryStream); memoryStream.Position = 0; var headerBuffer = new byte[512]; while (memoryStream.Position < memoryStream.Length - 1024) { var bytesRead = await memoryStream.ReadAsync(headerBuffer.AsMemory(0, 512)); if (bytesRead < 512) break; // Check for end-of-archive marker if (headerBuffer.All(b => b == 0)) break; // Extract file name var nameEnd = Array.IndexOf(headerBuffer, (byte)0); if (nameEnd < 0) nameEnd = 100; var fileName = Encoding.ASCII.GetString(headerBuffer, 0, Math.Min(nameEnd, 100)).TrimEnd('\0'); // Get file size var sizeStr = Encoding.ASCII.GetString(headerBuffer, 124, 11).Trim('\0', ' '); var fileSize = string.IsNullOrEmpty(sizeStr) ? 0 : Convert.ToInt64(sizeStr, 8); if (fileName.EndsWith(fileNamePattern)) { var contentBuffer = new byte[fileSize]; await memoryStream.ReadAsync(contentBuffer.AsMemory(0, (int)fileSize)); stream.Position = 0; return Encoding.UTF8.GetString(contentBuffer); } // Skip file content var paddedSize = ((fileSize + 511) / 512) * 512; memoryStream.Position += paddedSize - fileSize; // We haven't read content, so skip entire padded block memoryStream.Position += fileSize; } stream.Position = 0; return null; } private static string ComputeSha256Hex(byte[] data) { var hash = SHA256.HashData(data); return Convert.ToHexString(hash).ToLowerInvariant(); } #endregion }