Add property-based tests for SBOM/VEX document ordering and Unicode normalization determinism

- Implement `SbomVexOrderingDeterminismProperties` for testing component list and vulnerability metadata hash consistency.
- Create `UnicodeNormalizationDeterminismProperties` to validate NFC normalization and Unicode string handling.
- Add project file for `StellaOps.Testing.Determinism.Properties` with necessary dependencies.
- Introduce CI/CD template validation tests including YAML syntax checks and documentation content verification.
- Create validation script for CI/CD templates ensuring all required files and structures are present.
This commit is contained in:
StellaOps Bot
2025-12-26 15:17:15 +02:00
parent 7792749bb4
commit 907783f625
354 changed files with 79727 additions and 1346 deletions

View File

@@ -0,0 +1,517 @@
// -----------------------------------------------------------------------------
// KeylessSigningIntegrationTests.cs
// Sprint: SPRINT_20251226_001_SIGNER_fulcio_keyless_client
// Tasks: 0017, 0018 - Integration tests for full keyless signing flow
// Description: End-to-end integration tests with mock Fulcio server
// -----------------------------------------------------------------------------
using System.Net;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using StellaOps.Signer.Core;
using StellaOps.Signer.Keyless;
using Xunit;
namespace StellaOps.Signer.Tests.Keyless;
/// <summary>
/// Integration tests for the full keyless signing flow.
/// Validates the complete pipeline: OIDC token -> Fulcio cert -> DSSE signing.
/// </summary>
public sealed class KeylessSigningIntegrationTests : IDisposable
{
private readonly MockFulcioServer _mockFulcio;
private readonly SignerKeylessOptions _options;
private readonly List<IDisposable> _disposables = [];
public KeylessSigningIntegrationTests()
{
_mockFulcio = new MockFulcioServer();
_options = new SignerKeylessOptions
{
Enabled = true,
Fulcio = new FulcioOptions
{
Url = "https://fulcio.test",
Timeout = TimeSpan.FromSeconds(30),
Retries = 3,
BackoffBase = TimeSpan.FromMilliseconds(10),
BackoffMax = TimeSpan.FromMilliseconds(100)
},
Algorithms = new AlgorithmOptions
{
Preferred = KeylessAlgorithms.EcdsaP256,
Allowed = [KeylessAlgorithms.EcdsaP256, KeylessAlgorithms.Ed25519]
},
Certificate = new CertificateOptions
{
ValidateChain = false, // Disable for tests with self-signed certs
RequireSct = false
},
Identity = new IdentityOptions
{
ExpectedIssuers = [],
ExpectedSubjectPatterns = []
}
};
}
public void Dispose()
{
foreach (var d in _disposables)
d.Dispose();
_mockFulcio.Dispose();
}
[Fact]
public async Task FullKeylessFlow_ValidOidcToken_ProducesDsseBundle()
{
// Arrange
var keyGenerator = new EphemeralKeyGenerator(NullLogger<EphemeralKeyGenerator>.Instance);
var fulcioClient = CreateMockFulcioClient();
var tokenProvider = CreateMockTokenProvider("test@example.com");
var signer = new KeylessDsseSigner(
keyGenerator,
fulcioClient,
tokenProvider,
Options.Create(_options),
NullLogger<KeylessDsseSigner>.Instance);
_disposables.Add(signer);
var request = CreateSigningRequest();
var entitlement = CreateEntitlement();
var caller = CreateCallerContext();
// Act
var bundle = await signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
bundle.Should().NotBeNull();
bundle.Envelope.Should().NotBeNull();
bundle.Envelope.PayloadType.Should().Be("application/vnd.in-toto+json");
bundle.Envelope.Payload.Should().NotBeNullOrEmpty();
bundle.Envelope.Signatures.Should().HaveCount(1);
bundle.Envelope.Signatures[0].Sig.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task FullKeylessFlow_ProducesValidInTotoStatement()
{
// Arrange
var keyGenerator = new EphemeralKeyGenerator(NullLogger<EphemeralKeyGenerator>.Instance);
var fulcioClient = CreateMockFulcioClient();
var tokenProvider = CreateMockTokenProvider("test@example.com");
var signer = new KeylessDsseSigner(
keyGenerator,
fulcioClient,
tokenProvider,
Options.Create(_options),
NullLogger<KeylessDsseSigner>.Instance);
_disposables.Add(signer);
var request = CreateSigningRequest();
var entitlement = CreateEntitlement();
var caller = CreateCallerContext();
// Act
var bundle = await signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert - decode and validate the in-toto statement
var payloadBytes = Convert.FromBase64String(bundle.Envelope.Payload);
var statement = JsonDocument.Parse(payloadBytes);
statement.RootElement.GetProperty("_type").GetString()
.Should().Be("https://in-toto.io/Statement/v1");
statement.RootElement.GetProperty("subject").GetArrayLength()
.Should().BeGreaterThan(0);
}
[Fact]
public async Task FullKeylessFlow_IncludesCertificateChain()
{
// Arrange
var keyGenerator = new EphemeralKeyGenerator(NullLogger<EphemeralKeyGenerator>.Instance);
var fulcioClient = CreateMockFulcioClient();
var tokenProvider = CreateMockTokenProvider("test@example.com");
var signer = new KeylessDsseSigner(
keyGenerator,
fulcioClient,
tokenProvider,
Options.Create(_options),
NullLogger<KeylessDsseSigner>.Instance);
_disposables.Add(signer);
var request = CreateSigningRequest();
var entitlement = CreateEntitlement();
var caller = CreateCallerContext();
// Act
var bundle = await signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
bundle.Metadata.CertificateChain.Should().NotBeNullOrEmpty();
bundle.Metadata.CertificateChain.Should().HaveCountGreaterOrEqualTo(1);
}
[Fact]
public async Task FullKeylessFlow_IncludesSigningIdentity()
{
// Arrange
var keyGenerator = new EphemeralKeyGenerator(NullLogger<EphemeralKeyGenerator>.Instance);
var fulcioClient = CreateMockFulcioClient();
var tokenProvider = CreateMockTokenProvider("ci@github.com");
var signer = new KeylessDsseSigner(
keyGenerator,
fulcioClient,
tokenProvider,
Options.Create(_options),
NullLogger<KeylessDsseSigner>.Instance);
_disposables.Add(signer);
var request = CreateSigningRequest();
var entitlement = CreateEntitlement();
var caller = CreateCallerContext();
// Act
var bundle = await signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
bundle.Metadata.Identity.Should().NotBeNull();
bundle.Metadata.Identity.Mode.Should().Be("keyless");
bundle.Metadata.Identity.Subject.Should().Be("ci@github.com");
}
[Fact]
public async Task FullKeylessFlow_EachSigningProducesDifferentSignature()
{
// Arrange - ephemeral keys mean different signatures each time
var keyGenerator = new EphemeralKeyGenerator(NullLogger<EphemeralKeyGenerator>.Instance);
var fulcioClient = CreateMockFulcioClient();
var tokenProvider = CreateMockTokenProvider("test@example.com");
var signer = new KeylessDsseSigner(
keyGenerator,
fulcioClient,
tokenProvider,
Options.Create(_options),
NullLogger<KeylessDsseSigner>.Instance);
_disposables.Add(signer);
var request = CreateSigningRequest();
var entitlement = CreateEntitlement();
var caller = CreateCallerContext();
// Act
var bundle1 = await signer.SignAsync(request, entitlement, caller, CancellationToken.None);
var bundle2 = await signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert - different ephemeral keys = different signatures
bundle1.Envelope.Signatures[0].Sig.Should()
.NotBe(bundle2.Envelope.Signatures[0].Sig,
"each signing should use a new ephemeral key");
}
[Fact]
public async Task FullKeylessFlow_FulcioUnavailable_ThrowsException()
{
// Arrange
var keyGenerator = new EphemeralKeyGenerator(NullLogger<EphemeralKeyGenerator>.Instance);
var fulcioClient = Substitute.For<IFulcioClient>();
fulcioClient.GetCertificateAsync(Arg.Any<FulcioCertificateRequest>(), Arg.Any<CancellationToken>())
.Returns<FulcioCertificateResult>(_ => throw new FulcioUnavailableException(
"https://fulcio.test", "Service unavailable"));
var tokenProvider = CreateMockTokenProvider("test@example.com");
var signer = new KeylessDsseSigner(
keyGenerator,
fulcioClient,
tokenProvider,
Options.Create(_options),
NullLogger<KeylessDsseSigner>.Instance);
_disposables.Add(signer);
var request = CreateSigningRequest();
var entitlement = CreateEntitlement();
var caller = CreateCallerContext();
// Act
var act = async () => await signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<FulcioUnavailableException>();
}
[Fact]
public async Task FullKeylessFlow_OidcTokenInvalid_ThrowsException()
{
// Arrange
var keyGenerator = new EphemeralKeyGenerator(NullLogger<EphemeralKeyGenerator>.Instance);
var fulcioClient = CreateMockFulcioClient();
var tokenProvider = Substitute.For<IOidcTokenProvider>();
tokenProvider.AcquireTokenAsync(Arg.Any<CancellationToken>())
.Returns<OidcTokenResult>(_ => throw new OidcTokenAcquisitionException(
"https://auth.test", "Token expired"));
var signer = new KeylessDsseSigner(
keyGenerator,
fulcioClient,
tokenProvider,
Options.Create(_options),
NullLogger<KeylessDsseSigner>.Instance);
_disposables.Add(signer);
var request = CreateSigningRequest();
var entitlement = CreateEntitlement();
var caller = CreateCallerContext();
// Act
var act = async () => await signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<OidcTokenAcquisitionException>();
}
[Fact]
public async Task SignedBundle_CanBeVerified_WithEmbeddedCertificate()
{
// Arrange
var keyGenerator = new EphemeralKeyGenerator(NullLogger<EphemeralKeyGenerator>.Instance);
var fulcioClient = CreateMockFulcioClient();
var tokenProvider = CreateMockTokenProvider("test@example.com");
var signer = new KeylessDsseSigner(
keyGenerator,
fulcioClient,
tokenProvider,
Options.Create(_options),
NullLogger<KeylessDsseSigner>.Instance);
_disposables.Add(signer);
var request = CreateSigningRequest();
var entitlement = CreateEntitlement();
var caller = CreateCallerContext();
// Act
var bundle = await signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert - the bundle should contain all data needed for verification
bundle.Should().NotBeNull();
bundle.Metadata.CertificateChain.Should().NotBeEmpty(
"bundle must include certificate chain for verification");
bundle.Envelope.Signatures[0].Sig.Should().NotBeNullOrEmpty(
"bundle must include signature");
bundle.Envelope.Payload.Should().NotBeNullOrEmpty(
"bundle must include payload for verification");
// Verify the certificate chain can be parsed
var leafCertBase64 = bundle.Metadata.CertificateChain.First();
var act = () =>
{
var pemContent = Encoding.UTF8.GetString(Convert.FromBase64String(leafCertBase64));
return true;
};
act.Should().NotThrow("certificate should be valid base64");
}
[Fact]
public async Task MultipleSubjects_AllIncludedInStatement()
{
// Arrange
var keyGenerator = new EphemeralKeyGenerator(NullLogger<EphemeralKeyGenerator>.Instance);
var fulcioClient = CreateMockFulcioClient();
var tokenProvider = CreateMockTokenProvider("test@example.com");
var signer = new KeylessDsseSigner(
keyGenerator,
fulcioClient,
tokenProvider,
Options.Create(_options),
NullLogger<KeylessDsseSigner>.Instance);
_disposables.Add(signer);
// Create request with multiple subjects
var subjects = new List<SigningSubject>
{
new("artifact-1", new Dictionary<string, string> { ["sha256"] = "abc123" }),
new("artifact-2", new Dictionary<string, string> { ["sha256"] = "def456" }),
new("artifact-3", new Dictionary<string, string> { ["sha256"] = "ghi789" })
};
var predicate = JsonDocument.Parse("{\"verdict\": \"pass\"}");
var request = new SigningRequest(
Subjects: subjects,
PredicateType: "application/vnd.in-toto+json",
Predicate: predicate,
ScannerImageDigest: "sha256:test",
ProofOfEntitlement: new ProofOfEntitlement(SignerPoEFormat.Jwt, "test"),
Options: new SigningOptions(SigningMode.Keyless, null, "full"));
var entitlement = CreateEntitlement();
var caller = CreateCallerContext();
// Act
var bundle = await signer.SignAsync(request, entitlement, caller, CancellationToken.None);
// Assert
var payloadBytes = Convert.FromBase64String(bundle.Envelope.Payload);
var statement = JsonDocument.Parse(payloadBytes);
statement.RootElement.GetProperty("subject").GetArrayLength().Should().Be(3);
}
// Helper methods
private IFulcioClient CreateMockFulcioClient()
{
var client = Substitute.For<IFulcioClient>();
client.GetCertificateAsync(Arg.Any<FulcioCertificateRequest>(), Arg.Any<CancellationToken>())
.Returns(callInfo =>
{
var request = callInfo.Arg<FulcioCertificateRequest>();
return _mockFulcio.IssueCertificate(request);
});
return client;
}
private static IOidcTokenProvider CreateMockTokenProvider(string subject)
{
var provider = Substitute.For<IOidcTokenProvider>();
provider.AcquireTokenAsync(Arg.Any<CancellationToken>())
.Returns(new OidcTokenResult
{
IdentityToken = $"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3Rlc3QuYXV0aCIsInN1YiI6Intsubject}\",\"ZXhwIjo5OTk5OTk5OTk5fQ.sig",
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1),
Subject = subject,
Email = subject
});
return provider;
}
private static SigningRequest CreateSigningRequest()
{
var predicate = JsonDocument.Parse("""
{
"verdict": "pass",
"gates": [{"name": "drift", "result": "pass"}]
}
""");
return new SigningRequest(
Subjects:
[
new SigningSubject("test-artifact", new Dictionary<string, string>
{
["sha256"] = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
})
],
PredicateType: "application/vnd.in-toto+json",
Predicate: predicate,
ScannerImageDigest: "sha256:abc123",
ProofOfEntitlement: new ProofOfEntitlement(SignerPoEFormat.Jwt, "test-poe"),
Options: new SigningOptions(SigningMode.Keyless, null, "full"));
}
private static ProofOfEntitlementResult CreateEntitlement()
{
return new ProofOfEntitlementResult(
LicenseId: "test-license",
CustomerId: "test-customer",
Plan: "enterprise",
MaxArtifactBytes: 1000000,
QpsLimit: 100,
QpsRemaining: 50,
ExpiresAtUtc: DateTimeOffset.UtcNow.AddDays(30));
}
private static CallerContext CreateCallerContext()
{
return new CallerContext(
Subject: "test@test.com",
Tenant: "test-tenant",
Scopes: ["signer:sign"],
Audiences: ["signer"],
SenderBinding: null,
ClientCertificateThumbprint: null);
}
/// <summary>
/// Mock Fulcio server for integration testing.
/// </summary>
private sealed class MockFulcioServer : IDisposable
{
private readonly X509Certificate2 _rootCa;
private readonly RSA _rootKey;
public MockFulcioServer()
{
_rootKey = RSA.Create(2048);
var request = new CertificateRequest(
"CN=Mock Fulcio Root CA, O=Test",
_rootKey,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
request.CertificateExtensions.Add(
new X509BasicConstraintsExtension(true, false, 0, true));
_rootCa = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddYears(-1),
DateTimeOffset.UtcNow.AddYears(10));
}
public FulcioCertificateResult IssueCertificate(FulcioCertificateRequest request)
{
// Create a leaf certificate signed by our mock CA
using var leafKey = RSA.Create(2048);
var leafRequest = new CertificateRequest(
"CN=Test Subject, O=Test",
leafKey,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
// Add Fulcio OIDC issuer extension
var issuerOid = new Oid("1.3.6.1.4.1.57264.1.1");
var issuerBytes = Encoding.UTF8.GetBytes("https://test.auth");
leafRequest.CertificateExtensions.Add(new X509Extension(issuerOid, issuerBytes, false));
var serial = new byte[16];
RandomNumberGenerator.Fill(serial);
var leafCert = leafRequest.Create(
_rootCa.CopyWithPrivateKey(_rootKey),
DateTimeOffset.UtcNow.AddMinutes(-1),
DateTimeOffset.UtcNow.AddMinutes(10),
serial);
return new FulcioCertificateResult(
Certificate: leafCert.RawData,
CertificateChain: [_rootCa.RawData],
SignedCertificateTimestamp: "mock-sct",
NotBefore: new DateTimeOffset(leafCert.NotBefore, TimeSpan.Zero),
NotAfter: new DateTimeOffset(leafCert.NotAfter, TimeSpan.Zero),
Identity: new FulcioIdentity(
Issuer: "https://test.auth",
Subject: "test@test.com",
SubjectAlternativeName: "test@test.com"));
}
public void Dispose()
{
_rootCa.Dispose();
_rootKey.Dispose();
}
}
}