sprints completion. new product advisories prepared

This commit is contained in:
master
2026-01-16 16:30:03 +02:00
parent a927d924e3
commit 4ca3ce8fb4
255 changed files with 42434 additions and 1020 deletions

View File

@@ -0,0 +1,672 @@
// <copyright file="SignedSbomArchiveBuilderTests.cs" company="StellaOps">
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_20260112_016_SCANNER_signed_sbom_archive_spec (SBOM-SPEC-011)
// </copyright>
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 Xunit;
namespace StellaOps.Scanner.WebService.Tests;
/// <summary>
/// Tests for <see cref="SignedSbomArchiveBuilder"/>.
/// Sprint: SPRINT_20260112_016_SCANNER_signed_sbom_archive_spec (SBOM-SPEC-011)
/// </summary>
[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<Stream> _streamsToDispose = new();
public SignedSbomArchiveBuilderTests()
{
var timeProvider = new FakeTimeProvider(FixedTime);
_builder = new SignedSbomArchiveBuilder(timeProvider, NullLogger<SignedSbomArchiveBuilder>.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<JsonElement>(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<JsonElement>(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<JsonElement>(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<ArgumentNullException>(() => _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<OperationCanceledException>(
() => _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 = ScanId.CreateNew(),
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<List<string>> ExtractTarGzFileListAsync(Stream stream)
{
var files = new List<string>();
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<string?> 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
}