Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -8,6 +8,9 @@ using StellaOps.Attestor.Envelope;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Attestation.Tests;
|
||||
|
||||
public class DsseHelperTests
|
||||
{
|
||||
private sealed class FakeSigner : IAuthoritySigner
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.Attestation/StellaOps.Attestation.csproj" />
|
||||
<PackageReference Include="xunit" Version="2.5.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -1,352 +0,0 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DsseCosignCompatibilityTestFixture.cs
|
||||
// Sprint: SPRINT_8200_0001_0002_dsse_roundtrip_testing
|
||||
// Tasks: DSSE-8200-013, DSSE-8200-014, DSSE-8200-015
|
||||
// Description: Test fixture for cosign compatibility testing with mock Fulcio/Rekor
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Attestor.Envelope.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Test fixture for cosign compatibility tests.
|
||||
/// Provides mock Fulcio certificates and Rekor entries for offline testing.
|
||||
/// </summary>
|
||||
public sealed class DsseCosignCompatibilityTestFixture : IDisposable
|
||||
{
|
||||
private readonly ECDsa _signingKey;
|
||||
private readonly X509Certificate2 _certificate;
|
||||
private readonly string _keyId;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new fixture with mock Fulcio-style certificate.
|
||||
/// </summary>
|
||||
public DsseCosignCompatibilityTestFixture()
|
||||
{
|
||||
_signingKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
_keyId = $"cosign-test-{Guid.NewGuid():N}";
|
||||
_certificate = CreateMockFulcioCertificate(_signingKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the mock Fulcio certificate.
|
||||
/// </summary>
|
||||
public X509Certificate2 Certificate => _certificate;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the signing key.
|
||||
/// </summary>
|
||||
public ECDsa SigningKey => _signingKey;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the key ID.
|
||||
/// </summary>
|
||||
public string KeyId => _keyId;
|
||||
|
||||
// DSSE-8200-014: Mock Fulcio certificate generation
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mock certificate mimicking Fulcio's structure for testing.
|
||||
/// </summary>
|
||||
public static X509Certificate2 CreateMockFulcioCertificate(
|
||||
ECDsa key,
|
||||
string subject = "test@example.com",
|
||||
string issuer = "https://oauth2.sigstore.dev/auth",
|
||||
DateTimeOffset? validFrom = null,
|
||||
DateTimeOffset? validTo = null)
|
||||
{
|
||||
validFrom ??= DateTimeOffset.UtcNow.AddMinutes(-5);
|
||||
validTo ??= DateTimeOffset.UtcNow.AddMinutes(15); // Fulcio certs are short-lived (~20 min)
|
||||
|
||||
var request = new CertificateRequest(
|
||||
new X500DistinguishedName($"CN={subject}"),
|
||||
key,
|
||||
HashAlgorithmName.SHA256);
|
||||
|
||||
// Add extensions similar to Fulcio
|
||||
request.CertificateExtensions.Add(
|
||||
new X509KeyUsageExtension(
|
||||
X509KeyUsageFlags.DigitalSignature,
|
||||
critical: true));
|
||||
|
||||
request.CertificateExtensions.Add(
|
||||
new X509EnhancedKeyUsageExtension(
|
||||
new OidCollection { new Oid("1.3.6.1.5.5.7.3.3") }, // Code Signing
|
||||
critical: false));
|
||||
|
||||
// Add Subject Alternative Name (SAN) for identity
|
||||
var sanBuilder = new SubjectAlternativeNameBuilder();
|
||||
sanBuilder.AddEmailAddress(subject);
|
||||
request.CertificateExtensions.Add(sanBuilder.Build());
|
||||
|
||||
// Create self-signed cert (in real Fulcio this would be CA-signed)
|
||||
return request.CreateSelfSigned(validFrom.Value, validTo.Value);
|
||||
}
|
||||
|
||||
// DSSE-8200-013: Cosign-compatible envelope creation
|
||||
|
||||
/// <summary>
|
||||
/// Signs a payload and creates a cosign-compatible DSSE envelope.
|
||||
/// </summary>
|
||||
public DsseEnvelope SignCosignCompatible(
|
||||
ReadOnlySpan<byte> payload,
|
||||
string payloadType = "application/vnd.in-toto+json")
|
||||
{
|
||||
// Build PAE (Pre-Authentication Encoding)
|
||||
var pae = BuildPae(payloadType, payload);
|
||||
|
||||
// Sign with EC key (ES256 - what cosign uses)
|
||||
var signatureBytes = _signingKey.SignData(pae, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
|
||||
|
||||
// Base64 encode signature as cosign expects
|
||||
var signatureBase64 = Convert.ToBase64String(signatureBytes);
|
||||
|
||||
var signature = new DsseSignature(signatureBase64, _keyId);
|
||||
return new DsseEnvelope(payloadType, payload.ToArray(), [signature]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Sigstore bundle structure for testing.
|
||||
/// </summary>
|
||||
public CosignCompatibilityBundle CreateBundle(DsseEnvelope envelope, bool includeRekorEntry = false)
|
||||
{
|
||||
var certPem = ExportCertificateToPem(_certificate);
|
||||
var certChain = new List<string> { certPem };
|
||||
|
||||
MockRekorEntry? rekorEntry = null;
|
||||
if (includeRekorEntry)
|
||||
{
|
||||
rekorEntry = CreateMockRekorEntry(envelope);
|
||||
}
|
||||
|
||||
return new CosignCompatibilityBundle(
|
||||
envelope,
|
||||
certChain,
|
||||
rekorEntry);
|
||||
}
|
||||
|
||||
// DSSE-8200-015: Mock Rekor entry for offline verification
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mock Rekor transparency log entry for testing.
|
||||
/// </summary>
|
||||
public MockRekorEntry CreateMockRekorEntry(
|
||||
DsseEnvelope envelope,
|
||||
long logIndex = 12345678,
|
||||
long? treeSize = null)
|
||||
{
|
||||
treeSize ??= logIndex + 1000;
|
||||
|
||||
// Serialize envelope to get canonicalized body
|
||||
var serializationResult = DsseEnvelopeSerializer.Serialize(envelope, new DsseEnvelopeSerializationOptions
|
||||
{
|
||||
EmitCompactJson = true,
|
||||
EmitExpandedJson = false
|
||||
});
|
||||
|
||||
var canonicalizedBody = serializationResult.CompactJson ?? [];
|
||||
var bodyBase64 = Convert.ToBase64String(canonicalizedBody);
|
||||
|
||||
// Compute leaf hash (SHA256 of the canonicalized body)
|
||||
var leafHash = SHA256.HashData(canonicalizedBody);
|
||||
|
||||
// Generate synthetic Merkle proof
|
||||
var (proofHashes, rootHash) = GenerateSyntheticMerkleProof(leafHash, logIndex, treeSize.Value);
|
||||
|
||||
var integratedTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
|
||||
return new MockRekorEntry(
|
||||
LogIndex: logIndex,
|
||||
LogId: "rekor.sigstore.dev",
|
||||
IntegratedTime: integratedTime,
|
||||
CanonicalizedBody: bodyBase64,
|
||||
InclusionProof: new MockInclusionProof(
|
||||
LogIndex: logIndex,
|
||||
TreeSize: treeSize.Value,
|
||||
RootHash: Convert.ToBase64String(rootHash),
|
||||
Hashes: proofHashes.ConvertAll(h => Convert.ToBase64String(h)),
|
||||
Checkpoint: $"rekor.sigstore.dev - {treeSize}\n{Convert.ToBase64String(rootHash)}"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that an envelope has the structure expected by cosign.
|
||||
/// </summary>
|
||||
public static CosignStructureValidationResult ValidateCosignStructure(DsseEnvelope envelope)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
// Check payload type
|
||||
if (string.IsNullOrEmpty(envelope.PayloadType))
|
||||
{
|
||||
errors.Add("payloadType is required");
|
||||
}
|
||||
|
||||
// Check payload is present
|
||||
if (envelope.Payload.Length == 0)
|
||||
{
|
||||
errors.Add("payload is required");
|
||||
}
|
||||
|
||||
// Check signatures
|
||||
if (envelope.Signatures.Count == 0)
|
||||
{
|
||||
errors.Add("at least one signature is required");
|
||||
}
|
||||
|
||||
foreach (var sig in envelope.Signatures)
|
||||
{
|
||||
// Signature should be base64-encoded
|
||||
if (string.IsNullOrEmpty(sig.Signature))
|
||||
{
|
||||
errors.Add("signature value is required");
|
||||
}
|
||||
else if (!IsValidBase64(sig.Signature))
|
||||
{
|
||||
errors.Add($"signature is not valid base64: {sig.Signature[..Math.Min(20, sig.Signature.Length)]}...");
|
||||
}
|
||||
}
|
||||
|
||||
return new CosignStructureValidationResult(errors.Count == 0, errors);
|
||||
}
|
||||
|
||||
private static byte[] BuildPae(string payloadType, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
// PAE = "DSSEv1" || SP || len(type) || SP || type || SP || len(payload) || SP || payload
|
||||
const string prefix = "DSSEv1 ";
|
||||
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
|
||||
var buffer = new List<byte>();
|
||||
buffer.AddRange(Encoding.UTF8.GetBytes(prefix));
|
||||
buffer.AddRange(Encoding.UTF8.GetBytes(typeBytes.Length.ToString()));
|
||||
buffer.Add((byte)' ');
|
||||
buffer.AddRange(typeBytes);
|
||||
buffer.Add((byte)' ');
|
||||
buffer.AddRange(Encoding.UTF8.GetBytes(payload.Length.ToString()));
|
||||
buffer.Add((byte)' ');
|
||||
buffer.AddRange(payload.ToArray());
|
||||
|
||||
return buffer.ToArray();
|
||||
}
|
||||
|
||||
private static string ExportCertificateToPem(X509Certificate2 cert)
|
||||
{
|
||||
var certBytes = cert.Export(X509ContentType.Cert);
|
||||
var base64 = Convert.ToBase64String(certBytes);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("-----BEGIN CERTIFICATE-----");
|
||||
for (var i = 0; i < base64.Length; i += 64)
|
||||
{
|
||||
sb.AppendLine(base64.Substring(i, Math.Min(64, base64.Length - i)));
|
||||
}
|
||||
sb.AppendLine("-----END CERTIFICATE-----");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static (List<byte[]> proofHashes, byte[] rootHash) GenerateSyntheticMerkleProof(
|
||||
byte[] leafHash,
|
||||
long logIndex,
|
||||
long treeSize)
|
||||
{
|
||||
// Generate a synthetic but valid Merkle proof structure
|
||||
var proofHashes = new List<byte[]>();
|
||||
var currentHash = leafHash;
|
||||
|
||||
// Compute tree height
|
||||
var height = (int)Math.Ceiling(Math.Log2(Math.Max(treeSize, 2)));
|
||||
|
||||
// Generate sibling hashes for each level
|
||||
var random = new Random((int)(logIndex % int.MaxValue)); // Deterministic from logIndex
|
||||
var siblingBytes = new byte[32];
|
||||
|
||||
for (var level = 0; level < height; level++)
|
||||
{
|
||||
random.NextBytes(siblingBytes);
|
||||
proofHashes.Add((byte[])siblingBytes.Clone());
|
||||
|
||||
// Compute parent hash (simplified - real Merkle tree would be more complex)
|
||||
var combined = new byte[64];
|
||||
if ((logIndex >> level) % 2 == 0)
|
||||
{
|
||||
currentHash.CopyTo(combined, 0);
|
||||
siblingBytes.CopyTo(combined, 32);
|
||||
}
|
||||
else
|
||||
{
|
||||
siblingBytes.CopyTo(combined, 0);
|
||||
currentHash.CopyTo(combined, 32);
|
||||
}
|
||||
currentHash = SHA256.HashData(combined);
|
||||
}
|
||||
|
||||
return (proofHashes, currentHash);
|
||||
}
|
||||
|
||||
private static bool IsValidBase64(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Convert.FromBase64String(value);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_signingKey.Dispose();
|
||||
_certificate.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of cosign structure validation.
|
||||
/// </summary>
|
||||
public sealed record CosignStructureValidationResult(bool IsValid, List<string> Errors);
|
||||
|
||||
/// <summary>
|
||||
/// Test bundle with Fulcio certificate chain for cosign compatibility testing.
|
||||
/// </summary>
|
||||
public sealed record CosignCompatibilityBundle(
|
||||
DsseEnvelope Envelope,
|
||||
List<string> CertificateChain,
|
||||
MockRekorEntry? RekorEntry);
|
||||
|
||||
/// <summary>
|
||||
/// Mock Rekor transparency log entry for testing.
|
||||
/// </summary>
|
||||
public sealed record MockRekorEntry(
|
||||
long LogIndex,
|
||||
string LogId,
|
||||
long IntegratedTime,
|
||||
string CanonicalizedBody,
|
||||
MockInclusionProof InclusionProof);
|
||||
|
||||
/// <summary>
|
||||
/// Mock Merkle inclusion proof for testing.
|
||||
/// </summary>
|
||||
public sealed record MockInclusionProof(
|
||||
long LogIndex,
|
||||
long TreeSize,
|
||||
string RootHash,
|
||||
List<string> Hashes,
|
||||
string Checkpoint);
|
||||
@@ -1,423 +0,0 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DsseCosignCompatibilityTests.cs
|
||||
// Sprint: SPRINT_8200_0001_0002_dsse_roundtrip_testing
|
||||
// Tasks: DSSE-8200-013, DSSE-8200-014, DSSE-8200-015
|
||||
// Description: Cosign compatibility tests with mock Fulcio/Rekor (no CLI required)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Envelope.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for cosign compatibility without requiring external cosign CLI.
|
||||
/// Validates envelope structure, Fulcio certificate handling, and Rekor entry format.
|
||||
/// </summary>
|
||||
public sealed class DsseCosignCompatibilityTests : IDisposable
|
||||
{
|
||||
private readonly DsseCosignCompatibilityTestFixture _fixture;
|
||||
|
||||
public DsseCosignCompatibilityTests()
|
||||
{
|
||||
_fixture = new DsseCosignCompatibilityTestFixture();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// DSSE-8200-013: Cosign-compatible envelope structure tests
|
||||
// ==========================================================================
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EnvelopeStructure_HasRequiredFields_ForCosignVerification()
|
||||
{
|
||||
// Arrange
|
||||
var payload = CreateTestInTotoStatement();
|
||||
|
||||
// Act
|
||||
var envelope = _fixture.SignCosignCompatible(payload);
|
||||
|
||||
// Assert - Validate cosign-expected structure
|
||||
var result = DsseCosignCompatibilityTestFixture.ValidateCosignStructure(envelope);
|
||||
Assert.True(result.IsValid, $"Structure validation failed: {string.Join(", ", result.Errors)}");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EnvelopePayload_IsBase64Encoded_InSerializedForm()
|
||||
{
|
||||
// Arrange
|
||||
var payload = CreateTestInTotoStatement();
|
||||
var envelope = _fixture.SignCosignCompatible(payload);
|
||||
|
||||
// Act
|
||||
var serialized = DsseEnvelopeSerializer.Serialize(envelope, new DsseEnvelopeSerializationOptions
|
||||
{
|
||||
EmitCompactJson = true
|
||||
});
|
||||
|
||||
var json = JsonDocument.Parse(serialized.CompactJson!);
|
||||
|
||||
// Assert - payload should be base64-encoded in the JSON
|
||||
var payloadField = json.RootElement.GetProperty("payload").GetString();
|
||||
Assert.NotNull(payloadField);
|
||||
Assert.DoesNotContain("\n", payloadField); // No newlines in base64
|
||||
|
||||
// Verify it decodes back to original
|
||||
var decoded = Convert.FromBase64String(payloadField);
|
||||
Assert.Equal(payload, decoded);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EnvelopeSignature_IsBase64Encoded_InSerializedForm()
|
||||
{
|
||||
// Arrange
|
||||
var payload = CreateTestInTotoStatement();
|
||||
var envelope = _fixture.SignCosignCompatible(payload);
|
||||
|
||||
// Act
|
||||
var serialized = DsseEnvelopeSerializer.Serialize(envelope, new DsseEnvelopeSerializationOptions
|
||||
{
|
||||
EmitCompactJson = true
|
||||
});
|
||||
|
||||
var json = JsonDocument.Parse(serialized.CompactJson!);
|
||||
|
||||
// Assert - signatures array exists with valid base64
|
||||
var signatures = json.RootElement.GetProperty("signatures");
|
||||
Assert.Equal(JsonValueKind.Array, signatures.ValueKind);
|
||||
Assert.True(signatures.GetArrayLength() >= 1);
|
||||
|
||||
var firstSig = signatures[0];
|
||||
var sigValue = firstSig.GetProperty("sig").GetString();
|
||||
Assert.NotNull(sigValue);
|
||||
|
||||
// Verify it's valid base64
|
||||
var sigBytes = Convert.FromBase64String(sigValue);
|
||||
Assert.True(sigBytes.Length > 0);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EnvelopePayloadType_IsCorrectMimeType_ForInToto()
|
||||
{
|
||||
// Arrange
|
||||
var payload = CreateTestInTotoStatement();
|
||||
|
||||
// Act
|
||||
var envelope = _fixture.SignCosignCompatible(payload, "application/vnd.in-toto+json");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("application/vnd.in-toto+json", envelope.PayloadType);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EnvelopeSerialization_ProducesValidJson_WithoutWhitespace()
|
||||
{
|
||||
// Arrange
|
||||
var payload = CreateTestInTotoStatement();
|
||||
var envelope = _fixture.SignCosignCompatible(payload);
|
||||
|
||||
// Act
|
||||
var serialized = DsseEnvelopeSerializer.Serialize(envelope, new DsseEnvelopeSerializationOptions
|
||||
{
|
||||
EmitCompactJson = true
|
||||
});
|
||||
|
||||
var json = Encoding.UTF8.GetString(serialized.CompactJson!);
|
||||
|
||||
// Assert - compact JSON should not have unnecessary whitespace
|
||||
Assert.DoesNotContain("\n", json);
|
||||
Assert.DoesNotContain(" ", json); // No double spaces
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// DSSE-8200-014: Fulcio certificate chain tests
|
||||
// ==========================================================================
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void FulcioCertificate_HasCodeSigningEku()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cert = _fixture.Certificate;
|
||||
|
||||
// Assert - Certificate should have Code Signing EKU
|
||||
var hasCodeSigning = false;
|
||||
foreach (var ext in cert.Extensions)
|
||||
{
|
||||
if (ext is X509EnhancedKeyUsageExtension eku)
|
||||
{
|
||||
foreach (var oid in eku.EnhancedKeyUsages)
|
||||
{
|
||||
if (oid.Value == "1.3.6.1.5.5.7.3.3") // Code Signing
|
||||
{
|
||||
hasCodeSigning = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Assert.True(hasCodeSigning, "Certificate should have Code Signing EKU");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void FulcioCertificate_HasDigitalSignatureKeyUsage()
|
||||
{
|
||||
// Arrange & Act
|
||||
var cert = _fixture.Certificate;
|
||||
|
||||
// Assert
|
||||
var keyUsage = cert.Extensions["2.5.29.15"] as X509KeyUsageExtension;
|
||||
Assert.NotNull(keyUsage);
|
||||
Assert.True(keyUsage.KeyUsages.HasFlag(X509KeyUsageFlags.DigitalSignature));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void FulcioCertificate_IsShortLived()
|
||||
{
|
||||
// Arrange - Fulcio certs are typically valid for ~20 minutes
|
||||
|
||||
// Act
|
||||
var cert = _fixture.Certificate;
|
||||
var validity = cert.NotAfter - cert.NotBefore;
|
||||
|
||||
// Assert - Should be less than 24 hours (Fulcio's short-lived nature)
|
||||
Assert.True(validity.TotalHours <= 24, $"Certificate validity ({validity.TotalHours}h) should be <= 24 hours");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void BundleWithCertificate_HasValidPemFormat()
|
||||
{
|
||||
// Arrange
|
||||
var payload = CreateTestInTotoStatement();
|
||||
var envelope = _fixture.SignCosignCompatible(payload);
|
||||
|
||||
// Act
|
||||
var bundle = _fixture.CreateBundle(envelope);
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(bundle.CertificateChain);
|
||||
var certPem = bundle.CertificateChain[0];
|
||||
Assert.StartsWith("-----BEGIN CERTIFICATE-----", certPem);
|
||||
Assert.Contains("-----END CERTIFICATE-----", certPem);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// DSSE-8200-015: Rekor transparency log offline verification tests
|
||||
// ==========================================================================
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RekorEntry_HasValidLogIndex()
|
||||
{
|
||||
// Arrange
|
||||
var payload = CreateTestInTotoStatement();
|
||||
var envelope = _fixture.SignCosignCompatible(payload);
|
||||
|
||||
// Act
|
||||
var rekorEntry = _fixture.CreateMockRekorEntry(envelope);
|
||||
|
||||
// Assert
|
||||
Assert.True(rekorEntry.LogIndex >= 0);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RekorEntry_HasValidIntegratedTime()
|
||||
{
|
||||
// Arrange
|
||||
var payload = CreateTestInTotoStatement();
|
||||
var envelope = _fixture.SignCosignCompatible(payload);
|
||||
|
||||
// Act
|
||||
var rekorEntry = _fixture.CreateMockRekorEntry(envelope);
|
||||
var integratedTime = DateTimeOffset.FromUnixTimeSeconds(rekorEntry.IntegratedTime);
|
||||
|
||||
// Assert - Should be within reasonable range
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
Assert.True(integratedTime <= now.AddMinutes(1), "Integrated time should not be in the future");
|
||||
Assert.True(integratedTime >= now.AddHours(-1), "Integrated time should not be too old");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RekorEntry_HasValidInclusionProof()
|
||||
{
|
||||
// Arrange
|
||||
var payload = CreateTestInTotoStatement();
|
||||
var envelope = _fixture.SignCosignCompatible(payload);
|
||||
|
||||
// Act
|
||||
var rekorEntry = _fixture.CreateMockRekorEntry(envelope, logIndex: 12345);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(rekorEntry.InclusionProof);
|
||||
Assert.Equal(12345, rekorEntry.InclusionProof.LogIndex);
|
||||
Assert.True(rekorEntry.InclusionProof.TreeSize > rekorEntry.InclusionProof.LogIndex);
|
||||
Assert.NotEmpty(rekorEntry.InclusionProof.RootHash);
|
||||
Assert.NotEmpty(rekorEntry.InclusionProof.Hashes);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RekorEntry_CanonicalizedBody_IsBase64Encoded()
|
||||
{
|
||||
// Arrange
|
||||
var payload = CreateTestInTotoStatement();
|
||||
var envelope = _fixture.SignCosignCompatible(payload);
|
||||
|
||||
// Act
|
||||
var rekorEntry = _fixture.CreateMockRekorEntry(envelope);
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(rekorEntry.CanonicalizedBody);
|
||||
var decoded = Convert.FromBase64String(rekorEntry.CanonicalizedBody);
|
||||
Assert.True(decoded.Length > 0);
|
||||
|
||||
// Should be valid JSON
|
||||
var json = JsonDocument.Parse(decoded);
|
||||
Assert.NotNull(json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RekorEntry_InclusionProof_HashesAreBase64()
|
||||
{
|
||||
// Arrange
|
||||
var payload = CreateTestInTotoStatement();
|
||||
var envelope = _fixture.SignCosignCompatible(payload);
|
||||
|
||||
// Act
|
||||
var rekorEntry = _fixture.CreateMockRekorEntry(envelope);
|
||||
|
||||
// Assert
|
||||
foreach (var hash in rekorEntry.InclusionProof.Hashes)
|
||||
{
|
||||
var decoded = Convert.FromBase64String(hash);
|
||||
Assert.Equal(32, decoded.Length); // SHA-256 hash length
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void BundleWithRekor_ContainsValidTransparencyEntry()
|
||||
{
|
||||
// Arrange
|
||||
var payload = CreateTestInTotoStatement();
|
||||
var envelope = _fixture.SignCosignCompatible(payload);
|
||||
|
||||
// Act
|
||||
var bundle = _fixture.CreateBundle(envelope, includeRekorEntry: true);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(bundle.RekorEntry);
|
||||
Assert.NotEmpty(bundle.RekorEntry.LogId);
|
||||
Assert.True(bundle.RekorEntry.LogIndex >= 0);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RekorEntry_CheckpointFormat_IsValid()
|
||||
{
|
||||
// Arrange
|
||||
var payload = CreateTestInTotoStatement();
|
||||
var envelope = _fixture.SignCosignCompatible(payload);
|
||||
|
||||
// Act
|
||||
var rekorEntry = _fixture.CreateMockRekorEntry(envelope);
|
||||
|
||||
// Assert - Checkpoint should contain log ID and root hash
|
||||
Assert.NotEmpty(rekorEntry.InclusionProof.Checkpoint);
|
||||
Assert.Contains("rekor.sigstore.dev", rekorEntry.InclusionProof.Checkpoint);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Integration tests
|
||||
// ==========================================================================
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void FullBundle_SignVerifyRoundtrip_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var payload = CreateTestInTotoStatement();
|
||||
|
||||
// Act - Create complete bundle
|
||||
var envelope = _fixture.SignCosignCompatible(payload);
|
||||
var bundle = _fixture.CreateBundle(envelope, includeRekorEntry: true);
|
||||
|
||||
// Assert - All components present and valid
|
||||
Assert.NotNull(bundle.Envelope);
|
||||
Assert.NotEmpty(bundle.CertificateChain);
|
||||
Assert.NotNull(bundle.RekorEntry);
|
||||
|
||||
// Verify envelope structure
|
||||
var structureResult = DsseCosignCompatibilityTestFixture.ValidateCosignStructure(envelope);
|
||||
Assert.True(structureResult.IsValid);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DeterministicSigning_SamePayload_ProducesConsistentEnvelope()
|
||||
{
|
||||
// Arrange
|
||||
var payload = CreateTestInTotoStatement();
|
||||
|
||||
// Act - Sign same payload twice with same key
|
||||
var envelope1 = _fixture.SignCosignCompatible(payload);
|
||||
var envelope2 = _fixture.SignCosignCompatible(payload);
|
||||
|
||||
// Assert - Payload type and payload should be identical
|
||||
Assert.Equal(envelope1.PayloadType, envelope2.PayloadType);
|
||||
Assert.Equal(envelope1.Payload.ToArray(), envelope2.Payload.ToArray());
|
||||
|
||||
// Note: Signatures may differ if using randomized ECDSA
|
||||
// (which is the default for security), so we only verify structure
|
||||
Assert.Equal(envelope1.Signatures.Count, envelope2.Signatures.Count);
|
||||
using StellaOps.TestKit;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Helpers
|
||||
// ==========================================================================
|
||||
|
||||
private static byte[] CreateTestInTotoStatement()
|
||||
{
|
||||
var statement = new
|
||||
{
|
||||
_type = "https://in-toto.io/Statement/v0.1",
|
||||
predicateType = "https://stellaops.io/attestations/reachability/v1",
|
||||
subject = new[]
|
||||
{
|
||||
new { name = "test-artifact", digest = new { sha256 = "abc123" } }
|
||||
},
|
||||
predicate = new
|
||||
{
|
||||
graphType = "reachability",
|
||||
nodeCount = 100,
|
||||
edgeCount = 250,
|
||||
timestamp = DateTimeOffset.UtcNow.ToString("O")
|
||||
}
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(statement, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_fixture.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
using EnvelopeModel = StellaOps.Attestor.Envelope;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Attestor.Envelope.Tests;
|
||||
|
||||
public sealed class DsseEnvelopeSerializerTests
|
||||
{
|
||||
private static readonly byte[] SamplePayload = Encoding.UTF8.GetBytes("deterministic-dsse-payload");
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Serialize_ProducesDeterministicCompactJson_ForSignaturePermutations()
|
||||
{
|
||||
var signatures = new[]
|
||||
{
|
||||
EnvelopeModel.DsseSignature.FromBytes(Convert.FromHexString("0A1B2C3D4E5F60718293A4B5C6D7E8F9"), "tenant-z"),
|
||||
EnvelopeModel.DsseSignature.FromBytes(Convert.FromHexString("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"), null),
|
||||
EnvelopeModel.DsseSignature.FromBytes(Convert.FromHexString("00112233445566778899AABBCCDDEEFF"), "tenant-a"),
|
||||
EnvelopeModel.DsseSignature.FromBytes(Convert.FromHexString("1234567890ABCDEF1234567890ABCDEF"), "tenant-b")
|
||||
};
|
||||
|
||||
var baselineEnvelope = new EnvelopeModel.DsseEnvelope("application/vnd.stellaops.test+json", SamplePayload, signatures);
|
||||
var baseline = EnvelopeModel.DsseEnvelopeSerializer.Serialize(baselineEnvelope);
|
||||
baseline.CompactJson.Should().NotBeNull();
|
||||
var baselineJson = Encoding.UTF8.GetString(baseline.CompactJson!);
|
||||
|
||||
var rng = new Random(12345);
|
||||
for (var iteration = 0; iteration < 32; iteration++)
|
||||
{
|
||||
var shuffled = signatures.OrderBy(_ => rng.Next()).ToArray();
|
||||
var envelope = new EnvelopeModel.DsseEnvelope("application/vnd.stellaops.test+json", SamplePayload, shuffled);
|
||||
var result = EnvelopeModel.DsseEnvelopeSerializer.Serialize(envelope);
|
||||
|
||||
result.CompactJson.Should().NotBeNull();
|
||||
var json = Encoding.UTF8.GetString(result.CompactJson!);
|
||||
json.Should().Be(baselineJson, "canonical JSON must be deterministic regardless of signature insertion order");
|
||||
|
||||
result.PayloadSha256.Should().Be(
|
||||
Convert.ToHexString(SHA256.HashData(SamplePayload)).ToLowerInvariant(),
|
||||
"payload hash must reflect the raw payload bytes");
|
||||
|
||||
using var document = JsonDocument.Parse(result.CompactJson!);
|
||||
using StellaOps.TestKit;
|
||||
var keyIds = document.RootElement
|
||||
.GetProperty("signatures")
|
||||
.EnumerateArray()
|
||||
.Select(element => element.TryGetProperty("keyid", out var key) ? key.GetString() : null)
|
||||
.ToArray();
|
||||
|
||||
keyIds.Should().Equal(new string?[] { null, "tenant-a", "tenant-b", "tenant-z" },
|
||||
"signatures must be ordered by key identifier (null first) for canonical output");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,354 +0,0 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DsseNegativeTests.cs
|
||||
// Sprint: SPRINT_8200_0001_0002_dsse_roundtrip_testing
|
||||
// Tasks: DSSE-8200-016, DSSE-8200-017, DSSE-8200-018
|
||||
// Description: DSSE negative/error handling tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Envelope.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Negative tests for DSSE envelope verification.
|
||||
/// Validates error handling for expired certs, wrong keys, and malformed data.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Trait("Category", "DsseNegative")]
|
||||
public sealed class DsseNegativeTests : IDisposable
|
||||
{
|
||||
private readonly DsseRoundtripTestFixture _fixture;
|
||||
|
||||
public DsseNegativeTests()
|
||||
{
|
||||
_fixture = new DsseRoundtripTestFixture();
|
||||
}
|
||||
|
||||
// DSSE-8200-016: Expired certificate → verify fails with clear error
|
||||
// Note: Testing certificate expiry requires X.509 certificate infrastructure.
|
||||
// These tests use simulated scenarios or self-signed certs.
|
||||
|
||||
[Fact]
|
||||
public void Verify_WithExpiredCertificateSimulation_FailsGracefully()
|
||||
{
|
||||
// Arrange - Sign with the fixture (simulates current key)
|
||||
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
|
||||
var envelope = _fixture.Sign(payload);
|
||||
|
||||
// Simulate "expired" by creating a verification with a different key
|
||||
// In production, certificate expiry would be checked by the verifier
|
||||
using var expiredFixture = new DsseRoundtripTestFixture();
|
||||
|
||||
// Act - Verify with "expired" key (different fixture)
|
||||
var verified = expiredFixture.Verify(envelope);
|
||||
var detailedResult = expiredFixture.VerifyDetailed(envelope);
|
||||
|
||||
// Assert
|
||||
verified.Should().BeFalse("verification with different key should fail");
|
||||
detailedResult.IsValid.Should().BeFalse();
|
||||
detailedResult.SignatureResults.Should().Contain(r => !r.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_SignatureFromRevokedKey_FailsWithDetailedError()
|
||||
{
|
||||
// Arrange - Create envelope with one key
|
||||
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
|
||||
using var originalFixture = new DsseRoundtripTestFixture();
|
||||
var envelope = originalFixture.Sign(payload);
|
||||
|
||||
// Act - Try to verify with different key (simulates key revocation scenario)
|
||||
using var differentFixture = new DsseRoundtripTestFixture();
|
||||
var result = differentFixture.VerifyDetailed(envelope);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.SignatureResults.Should().HaveCount(1);
|
||||
result.SignatureResults[0].IsValid.Should().BeFalse();
|
||||
result.SignatureResults[0].FailureReason.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
// DSSE-8200-017: Wrong key type → verify fails
|
||||
|
||||
[Fact]
|
||||
public void Verify_WithWrongKeyType_Fails()
|
||||
{
|
||||
// Arrange - Sign with P-256
|
||||
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
|
||||
var envelope = _fixture.Sign(payload);
|
||||
|
||||
// Act - Try to verify with P-384 key (wrong curve)
|
||||
using var wrongCurveKey = ECDsa.Create(ECCurve.NamedCurves.nistP384);
|
||||
using var wrongCurveFixture = new DsseRoundtripTestFixture(wrongCurveKey, "p384-key");
|
||||
var verified = wrongCurveFixture.Verify(envelope);
|
||||
|
||||
// Assert
|
||||
verified.Should().BeFalse("verification with wrong curve should fail");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_WithMismatchedKeyId_SkipsSignature()
|
||||
{
|
||||
// Arrange
|
||||
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
|
||||
var envelope = _fixture.Sign(payload);
|
||||
|
||||
// Act - Create fixture with different key ID
|
||||
using var differentKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
using var differentIdFixture = new DsseRoundtripTestFixture(differentKey, "completely-different-key-id");
|
||||
var result = differentIdFixture.VerifyDetailed(envelope);
|
||||
|
||||
// Assert - Should skip due to key ID mismatch (unless keyId is null)
|
||||
result.IsValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_WithNullKeyId_MatchesAnyKey()
|
||||
{
|
||||
// Arrange - Create signature with null key ID
|
||||
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
|
||||
var pae = BuildPae("application/vnd.in-toto+json", payload);
|
||||
|
||||
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var signatureBytes = key.SignData(pae, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
|
||||
var signature = DsseSignature.FromBytes(signatureBytes, null); // null key ID
|
||||
|
||||
var envelope = new DsseEnvelope("application/vnd.in-toto+json", payload, [signature]);
|
||||
|
||||
// Act - Verify with same key but different fixture (null keyId should still match)
|
||||
using var verifyFixture = new DsseRoundtripTestFixture(key, "any-key-id");
|
||||
var verified = verifyFixture.Verify(envelope);
|
||||
|
||||
// Assert - null keyId in signature should be attempted with any verifying key
|
||||
verified.Should().BeTrue("null keyId should allow verification attempt");
|
||||
}
|
||||
|
||||
// DSSE-8200-018: Truncated/malformed envelope → parse fails gracefully
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_TruncatedJson_ThrowsJsonException()
|
||||
{
|
||||
// Arrange
|
||||
var validJson = """{"payloadType":"application/vnd.in-toto+json","payload":"dGVzdA==","signatures":[{"sig":"YWJj""";
|
||||
|
||||
// Act & Assert
|
||||
var act = () => DsseRoundtripTestFixture.DeserializeFromBytes(Encoding.UTF8.GetBytes(validJson));
|
||||
act.Should().Throw<JsonException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_MissingPayloadType_ThrowsKeyNotFoundException()
|
||||
{
|
||||
// Arrange
|
||||
var invalidJson = """{"payload":"dGVzdA==","signatures":[{"sig":"YWJj"}]}""";
|
||||
|
||||
// Act & Assert - GetProperty throws KeyNotFoundException when key is missing
|
||||
var act = () => DsseRoundtripTestFixture.DeserializeFromBytes(Encoding.UTF8.GetBytes(invalidJson));
|
||||
act.Should().Throw<KeyNotFoundException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_MissingPayload_ThrowsKeyNotFoundException()
|
||||
{
|
||||
// Arrange
|
||||
var invalidJson = """{"payloadType":"application/vnd.in-toto+json","signatures":[{"sig":"YWJj"}]}""";
|
||||
|
||||
// Act & Assert - GetProperty throws KeyNotFoundException when key is missing
|
||||
var act = () => DsseRoundtripTestFixture.DeserializeFromBytes(Encoding.UTF8.GetBytes(invalidJson));
|
||||
act.Should().Throw<KeyNotFoundException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_MissingSignatures_ThrowsKeyNotFoundException()
|
||||
{
|
||||
// Arrange
|
||||
var invalidJson = """{"payloadType":"application/vnd.in-toto+json","payload":"dGVzdA=="}""";
|
||||
|
||||
// Act & Assert - GetProperty throws KeyNotFoundException when key is missing
|
||||
var act = () => DsseRoundtripTestFixture.DeserializeFromBytes(Encoding.UTF8.GetBytes(invalidJson));
|
||||
act.Should().Throw<KeyNotFoundException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_EmptySignaturesArray_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var invalidJson = """{"payloadType":"application/vnd.in-toto+json","payload":"dGVzdA==","signatures":[]}""";
|
||||
|
||||
// Act & Assert
|
||||
var act = () => DsseRoundtripTestFixture.DeserializeFromBytes(Encoding.UTF8.GetBytes(invalidJson));
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithMessage("*signature*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_InvalidBase64Payload_ThrowsFormatException()
|
||||
{
|
||||
// Arrange
|
||||
var invalidJson = """{"payloadType":"application/vnd.in-toto+json","payload":"not-valid-base64!!!","signatures":[{"sig":"YWJj"}]}""";
|
||||
|
||||
// Act & Assert
|
||||
var act = () => DsseRoundtripTestFixture.DeserializeFromBytes(Encoding.UTF8.GetBytes(invalidJson));
|
||||
act.Should().Throw<FormatException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_MissingSignatureInSignature_ThrowsKeyNotFoundException()
|
||||
{
|
||||
// Arrange
|
||||
var invalidJson = """{"payloadType":"application/vnd.in-toto+json","payload":"dGVzdA==","signatures":[{"keyid":"key-1"}]}""";
|
||||
|
||||
// Act & Assert - GetProperty throws KeyNotFoundException when key is missing
|
||||
var act = () => DsseRoundtripTestFixture.DeserializeFromBytes(Encoding.UTF8.GetBytes(invalidJson));
|
||||
act.Should().Throw<KeyNotFoundException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_EmptyPayload_Succeeds()
|
||||
{
|
||||
// Arrange - Empty payload is technically valid base64
|
||||
var validJson = """{"payloadType":"application/vnd.in-toto+json","payload":"","signatures":[{"sig":"YWJj"}]}""";
|
||||
|
||||
// Act
|
||||
var envelope = DsseRoundtripTestFixture.DeserializeFromBytes(Encoding.UTF8.GetBytes(validJson));
|
||||
|
||||
// Assert
|
||||
envelope.Payload.Length.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_InvalidBase64Signature_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
|
||||
var invalidSig = new DsseSignature("not-valid-base64!!!", _fixture.KeyId);
|
||||
var envelope = new DsseEnvelope("application/vnd.in-toto+json", payload, [invalidSig]);
|
||||
|
||||
// Act
|
||||
var verified = _fixture.Verify(envelope);
|
||||
|
||||
// Assert
|
||||
verified.Should().BeFalse("invalid base64 signature should not verify");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_MalformedSignatureBytes_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
|
||||
var malformedSig = DsseSignature.FromBytes([0x01, 0x02, 0x03], _fixture.KeyId); // Too short for ECDSA
|
||||
var envelope = new DsseEnvelope("application/vnd.in-toto+json", payload, [malformedSig]);
|
||||
|
||||
// Act
|
||||
var verified = _fixture.Verify(envelope);
|
||||
|
||||
// Assert
|
||||
verified.Should().BeFalse("malformed signature bytes should not verify");
|
||||
}
|
||||
|
||||
// Bundle negative tests
|
||||
|
||||
[Fact]
|
||||
public void BundleDeserialize_TruncatedJson_ThrowsJsonException()
|
||||
{
|
||||
// Arrange
|
||||
var truncated = """{"mediaType":"application/vnd.dev.sigstore""";
|
||||
|
||||
// Act & Assert
|
||||
var act = () => SigstoreTestBundle.Deserialize(Encoding.UTF8.GetBytes(truncated));
|
||||
act.Should().Throw<JsonException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BundleDeserialize_MissingDsseEnvelope_ThrowsKeyNotFoundException()
|
||||
{
|
||||
// Arrange
|
||||
var missingEnvelope = """{"mediaType":"test","verificationMaterial":{"publicKey":{"hint":"k","rawBytes":"YWJj"},"algorithm":"ES256"}}""";
|
||||
|
||||
// Act & Assert - GetProperty throws KeyNotFoundException when key is missing
|
||||
var act = () => SigstoreTestBundle.Deserialize(Encoding.UTF8.GetBytes(missingEnvelope));
|
||||
act.Should().Throw<KeyNotFoundException>();
|
||||
}
|
||||
|
||||
// Edge cases
|
||||
|
||||
[Fact]
|
||||
public void Sign_EmptyPayload_FailsValidation()
|
||||
{
|
||||
// Arrange
|
||||
var emptyPayload = Array.Empty<byte>();
|
||||
|
||||
// Act & Assert - DsseEnvelope allows empty payload (technically), but signing behavior depends on PAE
|
||||
// Note: Empty payload is unusual but not necessarily invalid in DSSE spec
|
||||
var envelope = _fixture.Sign(emptyPayload);
|
||||
var verified = _fixture.Verify(envelope);
|
||||
|
||||
envelope.Payload.Length.Should().Be(0);
|
||||
verified.Should().BeTrue("empty payload is valid DSSE");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_ModifiedPayloadType_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
|
||||
var envelope = _fixture.Sign(payload);
|
||||
|
||||
// Act - Create new envelope with modified payloadType
|
||||
var modifiedEnvelope = new DsseEnvelope(
|
||||
"application/vnd.different-type+json", // Different type
|
||||
envelope.Payload,
|
||||
envelope.Signatures);
|
||||
|
||||
// Assert
|
||||
_fixture.Verify(modifiedEnvelope).Should().BeFalse("modified payloadType changes PAE and invalidates signature");
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private static byte[] BuildPae(string payloadType, byte[] payload)
|
||||
{
|
||||
const string preamble = "DSSEv1 ";
|
||||
|
||||
var payloadTypeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
var payloadTypeLenStr = payloadTypeBytes.Length.ToString();
|
||||
var payloadLenStr = payload.Length.ToString();
|
||||
|
||||
var totalLength = preamble.Length
|
||||
+ payloadTypeLenStr.Length + 1 + payloadTypeBytes.Length + 1
|
||||
+ payloadLenStr.Length + 1 + payload.Length;
|
||||
|
||||
var pae = new byte[totalLength];
|
||||
var offset = 0;
|
||||
|
||||
Encoding.UTF8.GetBytes(preamble, pae.AsSpan(offset));
|
||||
offset += preamble.Length;
|
||||
|
||||
Encoding.UTF8.GetBytes(payloadTypeLenStr, pae.AsSpan(offset));
|
||||
offset += payloadTypeLenStr.Length;
|
||||
pae[offset++] = (byte)' ';
|
||||
|
||||
payloadTypeBytes.CopyTo(pae.AsSpan(offset));
|
||||
offset += payloadTypeBytes.Length;
|
||||
pae[offset++] = (byte)' ';
|
||||
|
||||
Encoding.UTF8.GetBytes(payloadLenStr, pae.AsSpan(offset));
|
||||
offset += payloadLenStr.Length;
|
||||
pae[offset++] = (byte)' ';
|
||||
|
||||
payload.CopyTo(pae.AsSpan(offset));
|
||||
|
||||
return pae;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_fixture.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,364 +0,0 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DsseRebundleTests.cs
|
||||
// Sprint: SPRINT_8200_0001_0002_dsse_roundtrip_testing
|
||||
// Tasks: DSSE-8200-007, DSSE-8200-008, DSSE-8200-009
|
||||
// Description: DSSE re-bundling verification tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Envelope.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for DSSE envelope re-bundling operations.
|
||||
/// Validates sign → bundle → extract → re-bundle → verify cycles.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Trait("Category", "DsseRebundle")]
|
||||
public sealed class DsseRebundleTests : IDisposable
|
||||
{
|
||||
private readonly DsseRoundtripTestFixture _fixture;
|
||||
|
||||
public DsseRebundleTests()
|
||||
{
|
||||
_fixture = new DsseRoundtripTestFixture();
|
||||
}
|
||||
|
||||
// DSSE-8200-007: Full round-trip through bundle
|
||||
|
||||
[Fact]
|
||||
public void SignBundleExtractRebundleVerify_FullRoundTrip_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
|
||||
var envelope = _fixture.Sign(payload);
|
||||
_fixture.Verify(envelope).Should().BeTrue("original envelope should verify");
|
||||
|
||||
// Act - Bundle
|
||||
var bundle1 = _fixture.CreateSigstoreBundle(envelope);
|
||||
var bundleBytes = bundle1.Serialize();
|
||||
|
||||
// Act - Extract
|
||||
var extractedBundle = SigstoreTestBundle.Deserialize(bundleBytes);
|
||||
var extractedEnvelope = DsseRoundtripTestFixture.ExtractFromBundle(extractedBundle);
|
||||
|
||||
// Act - Re-bundle
|
||||
var rebundle = _fixture.CreateSigstoreBundle(extractedEnvelope);
|
||||
var rebundleBytes = rebundle.Serialize();
|
||||
|
||||
// Act - Extract again and verify
|
||||
var finalBundle = SigstoreTestBundle.Deserialize(rebundleBytes);
|
||||
var finalEnvelope = DsseRoundtripTestFixture.ExtractFromBundle(finalBundle);
|
||||
var finalVerified = _fixture.Verify(finalEnvelope);
|
||||
|
||||
// Assert
|
||||
finalVerified.Should().BeTrue("re-bundled envelope should verify");
|
||||
finalEnvelope.Payload.ToArray().Should().BeEquivalentTo(envelope.Payload.ToArray());
|
||||
finalEnvelope.PayloadType.Should().Be(envelope.PayloadType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignBundleExtractRebundleVerify_WithBundleKey_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
|
||||
var envelope = _fixture.Sign(payload);
|
||||
|
||||
// Act - Bundle with embedded key
|
||||
var bundle = _fixture.CreateSigstoreBundle(envelope);
|
||||
|
||||
// Act - Extract and verify using bundle's embedded key
|
||||
var extractedEnvelope = DsseRoundtripTestFixture.ExtractFromBundle(bundle);
|
||||
var verifiedWithBundleKey = DsseRoundtripTestFixture.VerifyWithBundleKey(extractedEnvelope, bundle);
|
||||
|
||||
// Assert
|
||||
verifiedWithBundleKey.Should().BeTrue("envelope should verify with bundle's embedded key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bundle_PreservesEnvelopeIntegrity()
|
||||
{
|
||||
// Arrange
|
||||
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
|
||||
var envelope = _fixture.Sign(payload);
|
||||
var originalBytes = DsseRoundtripTestFixture.SerializeToBytes(envelope);
|
||||
|
||||
// Act
|
||||
var bundle = _fixture.CreateSigstoreBundle(envelope);
|
||||
var extractedEnvelope = DsseRoundtripTestFixture.ExtractFromBundle(bundle);
|
||||
var extractedBytes = DsseRoundtripTestFixture.SerializeToBytes(extractedEnvelope);
|
||||
|
||||
// Assert - Envelope bytes should be identical
|
||||
extractedBytes.Should().BeEquivalentTo(originalBytes, "bundling should not modify envelope");
|
||||
}
|
||||
|
||||
// DSSE-8200-008: Archive to tar.gz → extract → verify
|
||||
|
||||
[Fact]
|
||||
public async Task SignBundleArchiveExtractVerify_ThroughGzipArchive_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
|
||||
var envelope = _fixture.Sign(payload);
|
||||
var bundle = _fixture.CreateSigstoreBundle(envelope);
|
||||
var bundleBytes = bundle.Serialize();
|
||||
|
||||
var archivePath = Path.Combine(Path.GetTempPath(), $"dsse-archive-{Guid.NewGuid():N}.tar.gz");
|
||||
var extractPath = Path.Combine(Path.GetTempPath(), $"dsse-extract-{Guid.NewGuid():N}");
|
||||
|
||||
try
|
||||
{
|
||||
// Act - Archive to gzip file
|
||||
await using (var fileStream = File.Create(archivePath))
|
||||
await using (var gzipStream = new GZipStream(fileStream, CompressionLevel.Optimal))
|
||||
{
|
||||
await gzipStream.WriteAsync(bundleBytes);
|
||||
}
|
||||
|
||||
// Act - Extract from gzip file
|
||||
Directory.CreateDirectory(extractPath);
|
||||
await using (var fileStream = File.OpenRead(archivePath))
|
||||
await using (var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress))
|
||||
await using (var memoryStream = new MemoryStream())
|
||||
{
|
||||
await gzipStream.CopyToAsync(memoryStream);
|
||||
var extractedBundleBytes = memoryStream.ToArray();
|
||||
|
||||
// Act - Deserialize and verify
|
||||
var extractedBundle = SigstoreTestBundle.Deserialize(extractedBundleBytes);
|
||||
var extractedEnvelope = DsseRoundtripTestFixture.ExtractFromBundle(extractedBundle);
|
||||
var verified = _fixture.Verify(extractedEnvelope);
|
||||
|
||||
// Assert
|
||||
verified.Should().BeTrue("envelope should verify after archive round-trip");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(archivePath); } catch { }
|
||||
try { Directory.Delete(extractPath, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignBundleArchiveExtractVerify_ThroughMultipleFiles_PreservesIntegrity()
|
||||
{
|
||||
// Arrange
|
||||
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
|
||||
var envelope = _fixture.Sign(payload);
|
||||
var bundle = _fixture.CreateSigstoreBundle(envelope);
|
||||
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"dsse-multi-{Guid.NewGuid():N}");
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
// Act - Save envelope and bundle as separate files
|
||||
var envelopePath = Path.Combine(tempDir, "envelope.json");
|
||||
var bundlePath = Path.Combine(tempDir, "bundle.json");
|
||||
|
||||
await File.WriteAllBytesAsync(envelopePath, DsseRoundtripTestFixture.SerializeToBytes(envelope));
|
||||
await File.WriteAllBytesAsync(bundlePath, bundle.Serialize());
|
||||
|
||||
// Act - Reload both
|
||||
var reloadedEnvelopeBytes = await File.ReadAllBytesAsync(envelopePath);
|
||||
var reloadedBundleBytes = await File.ReadAllBytesAsync(bundlePath);
|
||||
|
||||
var reloadedEnvelope = DsseRoundtripTestFixture.DeserializeFromBytes(reloadedEnvelopeBytes);
|
||||
var reloadedBundle = SigstoreTestBundle.Deserialize(reloadedBundleBytes);
|
||||
var extractedFromBundle = DsseRoundtripTestFixture.ExtractFromBundle(reloadedBundle);
|
||||
|
||||
// Assert - Both should verify and be equivalent
|
||||
_fixture.Verify(reloadedEnvelope).Should().BeTrue("reloaded envelope should verify");
|
||||
_fixture.Verify(extractedFromBundle).Should().BeTrue("extracted envelope should verify");
|
||||
|
||||
reloadedEnvelope.Payload.ToArray().Should().BeEquivalentTo(extractedFromBundle.Payload.ToArray());
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(tempDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
// DSSE-8200-009: Multi-signature envelope round-trip
|
||||
|
||||
[Fact]
|
||||
public void MultiSignatureEnvelope_BundleExtractVerify_AllSignaturesPreserved()
|
||||
{
|
||||
// Arrange - Create envelope with multiple signatures
|
||||
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
|
||||
|
||||
using var key1 = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
using var key2 = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
using var key3 = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
|
||||
var sig1 = CreateSignature(key1, payload, "key-1");
|
||||
var sig2 = CreateSignature(key2, payload, "key-2");
|
||||
var sig3 = CreateSignature(key3, payload, "key-3");
|
||||
|
||||
var multiSigEnvelope = new DsseEnvelope(
|
||||
"application/vnd.in-toto+json",
|
||||
payload,
|
||||
[sig1, sig2, sig3]);
|
||||
|
||||
// Act - Bundle
|
||||
var bundle = _fixture.CreateSigstoreBundle(multiSigEnvelope);
|
||||
var bundleBytes = bundle.Serialize();
|
||||
|
||||
// Act - Extract
|
||||
var extractedBundle = SigstoreTestBundle.Deserialize(bundleBytes);
|
||||
var extractedEnvelope = DsseRoundtripTestFixture.ExtractFromBundle(extractedBundle);
|
||||
|
||||
// Assert - All signatures preserved
|
||||
extractedEnvelope.Signatures.Should().HaveCount(3);
|
||||
extractedEnvelope.Signatures.Select(s => s.KeyId)
|
||||
.Should().BeEquivalentTo(["key-1", "key-2", "key-3"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultiSignatureEnvelope_SignatureOrderIsCanonical()
|
||||
{
|
||||
// Arrange - Create signatures in non-alphabetical order
|
||||
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
|
||||
|
||||
using var keyZ = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
using var keyA = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
using var keyM = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
|
||||
var sigZ = CreateSignature(keyZ, payload, "z-key");
|
||||
var sigA = CreateSignature(keyA, payload, "a-key");
|
||||
var sigM = CreateSignature(keyM, payload, "m-key");
|
||||
|
||||
// Act - Create envelope with out-of-order signatures
|
||||
var envelope1 = new DsseEnvelope("application/vnd.in-toto+json", payload, [sigZ, sigA, sigM]);
|
||||
var envelope2 = new DsseEnvelope("application/vnd.in-toto+json", payload, [sigA, sigM, sigZ]);
|
||||
var envelope3 = new DsseEnvelope("application/vnd.in-toto+json", payload, [sigM, sigZ, sigA]);
|
||||
|
||||
// Assert - All should have canonical (alphabetical) signature order
|
||||
var expectedOrder = new[] { "a-key", "m-key", "z-key" };
|
||||
envelope1.Signatures.Select(s => s.KeyId).Should().Equal(expectedOrder);
|
||||
envelope2.Signatures.Select(s => s.KeyId).Should().Equal(expectedOrder);
|
||||
envelope3.Signatures.Select(s => s.KeyId).Should().Equal(expectedOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultiSignatureEnvelope_SerializationIsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
|
||||
|
||||
using var key1 = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
using var key2 = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
|
||||
var sig1 = CreateSignature(key1, payload, "key-1");
|
||||
var sig2 = CreateSignature(key2, payload, "key-2");
|
||||
|
||||
// Act - Create envelopes with different signature order
|
||||
var envelopeA = new DsseEnvelope("application/vnd.in-toto+json", payload, [sig1, sig2]);
|
||||
var envelopeB = new DsseEnvelope("application/vnd.in-toto+json", payload, [sig2, sig1]);
|
||||
|
||||
var bytesA = DsseRoundtripTestFixture.SerializeToBytes(envelopeA);
|
||||
var bytesB = DsseRoundtripTestFixture.SerializeToBytes(envelopeB);
|
||||
|
||||
// Assert - Serialization should be identical due to canonical ordering
|
||||
bytesA.Should().BeEquivalentTo(bytesB, "canonical ordering should produce identical serialization");
|
||||
}
|
||||
|
||||
// Bundle integrity tests
|
||||
|
||||
[Fact]
|
||||
public void Bundle_TamperingDetected_VerificationFails()
|
||||
{
|
||||
// Arrange
|
||||
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
|
||||
var envelope = _fixture.Sign(payload);
|
||||
var bundle = _fixture.CreateSigstoreBundle(envelope);
|
||||
|
||||
// Act - Extract and tamper with envelope
|
||||
var extractedEnvelope = DsseRoundtripTestFixture.ExtractFromBundle(bundle);
|
||||
var tamperedPayload = extractedEnvelope.Payload.ToArray();
|
||||
tamperedPayload[0] ^= 0xFF;
|
||||
|
||||
var tamperedEnvelope = new DsseEnvelope(
|
||||
extractedEnvelope.PayloadType,
|
||||
tamperedPayload,
|
||||
extractedEnvelope.Signatures);
|
||||
|
||||
// Assert - Tampered envelope should not verify with bundle key
|
||||
var verifiedWithBundleKey = DsseRoundtripTestFixture.VerifyWithBundleKey(tamperedEnvelope, bundle);
|
||||
verifiedWithBundleKey.Should().BeFalse("tampered envelope should not verify");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bundle_DifferentKey_VerificationFails()
|
||||
{
|
||||
// Arrange
|
||||
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
|
||||
var envelope = _fixture.Sign(payload);
|
||||
var bundle = _fixture.CreateSigstoreBundle(envelope);
|
||||
|
||||
// Act - Create a different fixture with different key
|
||||
using var differentFixture = new DsseRoundtripTestFixture();
|
||||
var differentBundle = differentFixture.CreateSigstoreBundle(envelope);
|
||||
|
||||
// Assert - Original envelope should not verify with different key
|
||||
var verified = DsseRoundtripTestFixture.VerifyWithBundleKey(envelope, differentBundle);
|
||||
verified.Should().BeFalse("envelope should not verify with wrong key");
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private static DsseSignature CreateSignature(ECDsa key, byte[] payload, string keyId)
|
||||
{
|
||||
var pae = BuildPae("application/vnd.in-toto+json", payload);
|
||||
var signatureBytes = key.SignData(pae, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
|
||||
return DsseSignature.FromBytes(signatureBytes, keyId);
|
||||
}
|
||||
|
||||
private static byte[] BuildPae(string payloadType, byte[] payload)
|
||||
{
|
||||
const string preamble = "DSSEv1 ";
|
||||
|
||||
var payloadTypeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
var payloadTypeLenStr = payloadTypeBytes.Length.ToString();
|
||||
var payloadLenStr = payload.Length.ToString();
|
||||
|
||||
var totalLength = preamble.Length
|
||||
+ payloadTypeLenStr.Length + 1 + payloadTypeBytes.Length + 1
|
||||
+ payloadLenStr.Length + 1 + payload.Length;
|
||||
|
||||
var pae = new byte[totalLength];
|
||||
var offset = 0;
|
||||
|
||||
Encoding.UTF8.GetBytes(preamble, pae.AsSpan(offset));
|
||||
offset += preamble.Length;
|
||||
|
||||
Encoding.UTF8.GetBytes(payloadTypeLenStr, pae.AsSpan(offset));
|
||||
offset += payloadTypeLenStr.Length;
|
||||
pae[offset++] = (byte)' ';
|
||||
|
||||
payloadTypeBytes.CopyTo(pae.AsSpan(offset));
|
||||
offset += payloadTypeBytes.Length;
|
||||
pae[offset++] = (byte)' ';
|
||||
|
||||
Encoding.UTF8.GetBytes(payloadLenStr, pae.AsSpan(offset));
|
||||
offset += payloadLenStr.Length;
|
||||
pae[offset++] = (byte)' ';
|
||||
|
||||
payload.CopyTo(pae.AsSpan(offset));
|
||||
|
||||
return pae;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_fixture.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,503 +0,0 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DsseRoundtripTestFixture.cs
|
||||
// Sprint: SPRINT_8200_0001_0002_dsse_roundtrip_testing
|
||||
// Tasks: DSSE-8200-001, DSSE-8200-002, DSSE-8200-003
|
||||
// Description: Test fixture providing DSSE signing, verification, and round-trip helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Attestor.Envelope.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Test fixture for DSSE round-trip verification tests.
|
||||
/// Provides key generation, signing, verification, and serialization helpers.
|
||||
/// </summary>
|
||||
public sealed class DsseRoundtripTestFixture : IDisposable
|
||||
{
|
||||
private readonly ECDsa _signingKey;
|
||||
private readonly string _keyId;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new test fixture with a fresh ECDSA P-256 key pair.
|
||||
/// </summary>
|
||||
public DsseRoundtripTestFixture()
|
||||
: this(ECDsa.Create(ECCurve.NamedCurves.nistP256), $"test-key-{Guid.NewGuid():N}")
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a test fixture with a specified key and key ID.
|
||||
/// </summary>
|
||||
public DsseRoundtripTestFixture(ECDsa signingKey, string keyId)
|
||||
{
|
||||
_signingKey = signingKey ?? throw new ArgumentNullException(nameof(signingKey));
|
||||
_keyId = keyId ?? throw new ArgumentNullException(nameof(keyId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the key ID associated with the signing key.
|
||||
/// </summary>
|
||||
public string KeyId => _keyId;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the public key bytes in X.509 SubjectPublicKeyInfo format.
|
||||
/// </summary>
|
||||
public ReadOnlyMemory<byte> PublicKeyBytes => _signingKey.ExportSubjectPublicKeyInfo();
|
||||
|
||||
// DSSE-8200-001: Core signing and verification helpers
|
||||
|
||||
/// <summary>
|
||||
/// Signs a payload and creates a DSSE envelope.
|
||||
/// Uses ECDSA P-256 with SHA-256 (ES256).
|
||||
/// </summary>
|
||||
public DsseEnvelope Sign(ReadOnlySpan<byte> payload, string payloadType = "application/vnd.in-toto+json")
|
||||
{
|
||||
// Build PAE (Pre-Authentication Encoding) as per DSSE spec
|
||||
// PAE = "DSSEv1" || len(payloadType) || payloadType || len(payload) || payload
|
||||
var pae = BuildPae(payloadType, payload);
|
||||
|
||||
// Sign the PAE
|
||||
var signatureBytes = _signingKey.SignData(pae, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
|
||||
|
||||
var signature = DsseSignature.FromBytes(signatureBytes, _keyId);
|
||||
return new DsseEnvelope(payloadType, payload.ToArray(), [signature]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signs a JSON-serializable payload and creates a DSSE envelope.
|
||||
/// </summary>
|
||||
public DsseEnvelope SignJson<T>(T payload, string payloadType = "application/vnd.in-toto+json")
|
||||
{
|
||||
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(payload, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
});
|
||||
return Sign(payloadBytes, payloadType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a DSSE envelope signature using the fixture's public key.
|
||||
/// Returns true if at least one signature verifies.
|
||||
/// </summary>
|
||||
public bool Verify(DsseEnvelope envelope)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelope);
|
||||
|
||||
var pae = BuildPae(envelope.PayloadType, envelope.Payload.Span);
|
||||
|
||||
foreach (var sig in envelope.Signatures)
|
||||
{
|
||||
// Match by key ID if specified
|
||||
if (sig.KeyId != null && sig.KeyId != _keyId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var signatureBytes = Convert.FromBase64String(sig.Signature);
|
||||
if (_signingKey.VerifyData(pae, signatureBytes, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
// Invalid base64, skip
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
// Invalid signature format, skip
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a verification result with detailed information.
|
||||
/// </summary>
|
||||
public DsseVerificationResult VerifyDetailed(DsseEnvelope envelope)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelope);
|
||||
|
||||
var pae = BuildPae(envelope.PayloadType, envelope.Payload.Span);
|
||||
var results = new List<SignatureVerificationResult>();
|
||||
|
||||
foreach (var sig in envelope.Signatures)
|
||||
{
|
||||
var result = VerifySingleSignature(sig, pae);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
var anyValid = results.Exists(r => r.IsValid);
|
||||
return new DsseVerificationResult(anyValid, results);
|
||||
}
|
||||
|
||||
// DSSE-8200-002: Serialization and persistence helpers
|
||||
|
||||
/// <summary>
|
||||
/// Serializes a DSSE envelope to canonical JSON bytes.
|
||||
/// </summary>
|
||||
public static byte[] SerializeToBytes(DsseEnvelope envelope)
|
||||
{
|
||||
var result = DsseEnvelopeSerializer.Serialize(envelope, new DsseEnvelopeSerializationOptions
|
||||
{
|
||||
EmitCompactJson = true,
|
||||
EmitExpandedJson = false
|
||||
});
|
||||
|
||||
return result.CompactJson ?? throw new InvalidOperationException("Serialization failed to produce compact JSON.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes a DSSE envelope from canonical JSON bytes.
|
||||
/// </summary>
|
||||
public static DsseEnvelope DeserializeFromBytes(ReadOnlySpan<byte> json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json.ToArray());
|
||||
var root = doc.RootElement;
|
||||
|
||||
var payloadType = root.GetProperty("payloadType").GetString()
|
||||
?? throw new JsonException("Missing payloadType");
|
||||
|
||||
var payloadBase64 = root.GetProperty("payload").GetString()
|
||||
?? throw new JsonException("Missing payload");
|
||||
|
||||
var payload = Convert.FromBase64String(payloadBase64);
|
||||
|
||||
var signatures = new List<DsseSignature>();
|
||||
foreach (var sigElement in root.GetProperty("signatures").EnumerateArray())
|
||||
{
|
||||
var sig = sigElement.GetProperty("sig").GetString()
|
||||
?? throw new JsonException("Missing sig in signature");
|
||||
|
||||
sigElement.TryGetProperty("keyid", out var keyIdElement);
|
||||
var keyId = keyIdElement.ValueKind == JsonValueKind.String ? keyIdElement.GetString() : null;
|
||||
|
||||
signatures.Add(new DsseSignature(sig, keyId));
|
||||
}
|
||||
|
||||
return new DsseEnvelope(payloadType, payload, signatures);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists a DSSE envelope to a file.
|
||||
/// </summary>
|
||||
public static async Task SaveToFileAsync(DsseEnvelope envelope, string filePath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var bytes = SerializeToBytes(envelope);
|
||||
await File.WriteAllBytesAsync(filePath, bytes, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads a DSSE envelope from a file.
|
||||
/// </summary>
|
||||
public static async Task<DsseEnvelope> LoadFromFileAsync(string filePath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var bytes = await File.ReadAllBytesAsync(filePath, cancellationToken);
|
||||
return DeserializeFromBytes(bytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs a full round-trip: serialize to file, reload, deserialize.
|
||||
/// </summary>
|
||||
public static async Task<DsseEnvelope> RoundtripThroughFileAsync(
|
||||
DsseEnvelope envelope,
|
||||
string? tempPath = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
tempPath ??= Path.Combine(Path.GetTempPath(), $"dsse-roundtrip-{Guid.NewGuid():N}.json");
|
||||
|
||||
try
|
||||
{
|
||||
await SaveToFileAsync(envelope, tempPath, cancellationToken);
|
||||
return await LoadFromFileAsync(tempPath, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(tempPath); } catch { /* Best effort cleanup */ }
|
||||
}
|
||||
}
|
||||
|
||||
// DSSE-8200-003: Sigstore bundle wrapper helpers
|
||||
|
||||
/// <summary>
|
||||
/// Creates a minimal Sigstore-compatible bundle containing the DSSE envelope.
|
||||
/// This is a simplified version for testing; production bundles need additional metadata.
|
||||
/// </summary>
|
||||
public SigstoreTestBundle CreateSigstoreBundle(DsseEnvelope envelope)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelope);
|
||||
|
||||
var envelopeJson = SerializeToBytes(envelope);
|
||||
var publicKeyDer = _signingKey.ExportSubjectPublicKeyInfo();
|
||||
|
||||
return new SigstoreTestBundle(
|
||||
MediaType: "application/vnd.dev.sigstore.bundle.v0.3+json",
|
||||
DsseEnvelope: envelopeJson,
|
||||
PublicKey: publicKeyDer,
|
||||
KeyId: _keyId,
|
||||
Algorithm: "ES256");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a DSSE envelope from a Sigstore test bundle.
|
||||
/// </summary>
|
||||
public static DsseEnvelope ExtractFromBundle(SigstoreTestBundle bundle)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
return DeserializeFromBytes(bundle.DsseEnvelope);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a DSSE envelope using the public key embedded in a bundle.
|
||||
/// </summary>
|
||||
public static bool VerifyWithBundleKey(DsseEnvelope envelope, SigstoreTestBundle bundle)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelope);
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
|
||||
using var publicKey = ECDsa.Create();
|
||||
publicKey.ImportSubjectPublicKeyInfo(bundle.PublicKey, out _);
|
||||
|
||||
var pae = BuildPae(envelope.PayloadType, envelope.Payload.Span);
|
||||
|
||||
foreach (var sig in envelope.Signatures)
|
||||
{
|
||||
if (sig.KeyId != null && sig.KeyId != bundle.KeyId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var signatureBytes = Convert.FromBase64String(sig.Signature);
|
||||
if (publicKey.VerifyData(pae, signatureBytes, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Continue to next signature
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Payload creation helpers for tests
|
||||
|
||||
/// <summary>
|
||||
/// Creates a minimal in-toto statement payload for testing.
|
||||
/// </summary>
|
||||
public static byte[] CreateInTotoPayload(
|
||||
string predicateType = "https://slsa.dev/provenance/v1",
|
||||
string subjectName = "test-artifact",
|
||||
string subjectDigest = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
|
||||
{
|
||||
var statement = new
|
||||
{
|
||||
_type = "https://in-toto.io/Statement/v1",
|
||||
subject = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
name = subjectName,
|
||||
digest = new { sha256 = subjectDigest.Replace("sha256:", "") }
|
||||
}
|
||||
},
|
||||
predicateType,
|
||||
predicate = new { }
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(statement, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a deterministic test payload with specified content.
|
||||
/// </summary>
|
||||
public static byte[] CreateTestPayload(string content = "deterministic-test-payload")
|
||||
{
|
||||
return Encoding.UTF8.GetBytes(content);
|
||||
}
|
||||
|
||||
// Private helpers
|
||||
|
||||
private static byte[] BuildPae(string payloadType, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
// PAE(payloadType, payload) = "DSSEv1" + SP + len(payloadType) + SP + payloadType + SP + len(payload) + SP + payload
|
||||
// Where SP is ASCII space (0x20)
|
||||
const string preamble = "DSSEv1 ";
|
||||
|
||||
var payloadTypeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
var payloadTypeLenStr = payloadTypeBytes.Length.ToString();
|
||||
var payloadLenStr = payload.Length.ToString();
|
||||
|
||||
var totalLength = preamble.Length
|
||||
+ payloadTypeLenStr.Length + 1 + payloadTypeBytes.Length + 1
|
||||
+ payloadLenStr.Length + 1 + payload.Length;
|
||||
|
||||
var pae = new byte[totalLength];
|
||||
var offset = 0;
|
||||
|
||||
// "DSSEv1 "
|
||||
Encoding.UTF8.GetBytes(preamble, pae.AsSpan(offset));
|
||||
offset += preamble.Length;
|
||||
|
||||
// len(payloadType) + SP
|
||||
Encoding.UTF8.GetBytes(payloadTypeLenStr, pae.AsSpan(offset));
|
||||
offset += payloadTypeLenStr.Length;
|
||||
pae[offset++] = (byte)' ';
|
||||
|
||||
// payloadType + SP
|
||||
payloadTypeBytes.CopyTo(pae.AsSpan(offset));
|
||||
offset += payloadTypeBytes.Length;
|
||||
pae[offset++] = (byte)' ';
|
||||
|
||||
// len(payload) + SP
|
||||
Encoding.UTF8.GetBytes(payloadLenStr, pae.AsSpan(offset));
|
||||
offset += payloadLenStr.Length;
|
||||
pae[offset++] = (byte)' ';
|
||||
|
||||
// payload
|
||||
payload.CopyTo(pae.AsSpan(offset));
|
||||
|
||||
return pae;
|
||||
}
|
||||
|
||||
private SignatureVerificationResult VerifySingleSignature(DsseSignature sig, byte[] pae)
|
||||
{
|
||||
var keyMatches = sig.KeyId == null || sig.KeyId == _keyId;
|
||||
|
||||
if (!keyMatches)
|
||||
{
|
||||
return new SignatureVerificationResult(sig.KeyId, false, "Key ID mismatch");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var signatureBytes = Convert.FromBase64String(sig.Signature);
|
||||
var isValid = _signingKey.VerifyData(pae, signatureBytes, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
|
||||
return new SignatureVerificationResult(sig.KeyId, isValid, isValid ? null : "Signature verification failed");
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return new SignatureVerificationResult(sig.KeyId, false, "Invalid base64 signature format");
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
return new SignatureVerificationResult(sig.KeyId, false, $"Cryptographic error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_signingKey.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of DSSE envelope verification with detailed per-signature results.
|
||||
/// </summary>
|
||||
public sealed record DsseVerificationResult(
|
||||
bool IsValid,
|
||||
IReadOnlyList<SignatureVerificationResult> SignatureResults);
|
||||
|
||||
/// <summary>
|
||||
/// Result of verifying a single signature.
|
||||
/// </summary>
|
||||
public sealed record SignatureVerificationResult(
|
||||
string? KeyId,
|
||||
bool IsValid,
|
||||
string? FailureReason);
|
||||
|
||||
/// <summary>
|
||||
/// Minimal Sigstore-compatible bundle for testing DSSE round-trips.
|
||||
/// </summary>
|
||||
public sealed record SigstoreTestBundle(
|
||||
string MediaType,
|
||||
byte[] DsseEnvelope,
|
||||
byte[] PublicKey,
|
||||
string KeyId,
|
||||
string Algorithm)
|
||||
{
|
||||
/// <summary>
|
||||
/// Serializes the bundle to JSON bytes.
|
||||
/// </summary>
|
||||
public byte[] Serialize()
|
||||
{
|
||||
var bundle = new
|
||||
{
|
||||
mediaType = MediaType,
|
||||
dsseEnvelope = Convert.ToBase64String(DsseEnvelope),
|
||||
verificationMaterial = new
|
||||
{
|
||||
publicKey = new
|
||||
{
|
||||
hint = KeyId,
|
||||
rawBytes = Convert.ToBase64String(PublicKey)
|
||||
},
|
||||
algorithm = Algorithm
|
||||
}
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(bundle, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes a bundle from JSON bytes.
|
||||
/// </summary>
|
||||
public static SigstoreTestBundle Deserialize(ReadOnlySpan<byte> json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json.ToArray());
|
||||
var root = doc.RootElement;
|
||||
|
||||
var mediaType = root.GetProperty("mediaType").GetString()
|
||||
?? throw new JsonException("Missing mediaType");
|
||||
|
||||
var dsseEnvelopeBase64 = root.GetProperty("dsseEnvelope").GetString()
|
||||
?? throw new JsonException("Missing dsseEnvelope");
|
||||
|
||||
var verificationMaterial = root.GetProperty("verificationMaterial");
|
||||
var publicKeyElement = verificationMaterial.GetProperty("publicKey");
|
||||
|
||||
var keyId = publicKeyElement.GetProperty("hint").GetString()
|
||||
?? throw new JsonException("Missing hint (keyId)");
|
||||
|
||||
var publicKeyBase64 = publicKeyElement.GetProperty("rawBytes").GetString()
|
||||
?? throw new JsonException("Missing rawBytes");
|
||||
|
||||
var algorithm = verificationMaterial.GetProperty("algorithm").GetString()
|
||||
?? throw new JsonException("Missing algorithm");
|
||||
|
||||
return new SigstoreTestBundle(
|
||||
mediaType,
|
||||
Convert.FromBase64String(dsseEnvelopeBase64),
|
||||
Convert.FromBase64String(publicKeyBase64),
|
||||
keyId,
|
||||
algorithm);
|
||||
}
|
||||
}
|
||||
@@ -1,381 +0,0 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DsseRoundtripTests.cs
|
||||
// Sprint: SPRINT_8200_0001_0002_dsse_roundtrip_testing
|
||||
// Tasks: DSSE-8200-004, DSSE-8200-005, DSSE-8200-006, DSSE-8200-010, DSSE-8200-011, DSSE-8200-012
|
||||
// Description: DSSE round-trip verification tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Envelope.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for DSSE envelope round-trip verification.
|
||||
/// Validates sign → serialize → deserialize → verify cycles and determinism.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Trait("Category", "DsseRoundtrip")]
|
||||
public sealed class DsseRoundtripTests : IDisposable
|
||||
{
|
||||
private readonly DsseRoundtripTestFixture _fixture;
|
||||
|
||||
public DsseRoundtripTests()
|
||||
{
|
||||
_fixture = new DsseRoundtripTestFixture();
|
||||
}
|
||||
|
||||
// DSSE-8200-004: Basic sign → serialize → deserialize → verify
|
||||
|
||||
[Fact]
|
||||
public void SignSerializeDeserializeVerify_HappyPath_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
|
||||
|
||||
// Act - Sign
|
||||
var originalEnvelope = _fixture.Sign(payload);
|
||||
var originalVerified = _fixture.Verify(originalEnvelope);
|
||||
|
||||
// Act - Serialize
|
||||
var serializedBytes = DsseRoundtripTestFixture.SerializeToBytes(originalEnvelope);
|
||||
|
||||
// Act - Deserialize
|
||||
var deserializedEnvelope = DsseRoundtripTestFixture.DeserializeFromBytes(serializedBytes);
|
||||
|
||||
// Act - Verify deserialized
|
||||
var deserializedVerified = _fixture.Verify(deserializedEnvelope);
|
||||
|
||||
// Assert
|
||||
originalVerified.Should().BeTrue("original envelope should verify");
|
||||
deserializedVerified.Should().BeTrue("deserialized envelope should verify");
|
||||
|
||||
deserializedEnvelope.PayloadType.Should().Be(originalEnvelope.PayloadType);
|
||||
deserializedEnvelope.Payload.ToArray().Should().BeEquivalentTo(originalEnvelope.Payload.ToArray());
|
||||
deserializedEnvelope.Signatures.Should().HaveCount(originalEnvelope.Signatures.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignSerializeDeserializeVerify_WithJsonPayload_PreservesContent()
|
||||
{
|
||||
// Arrange
|
||||
var testData = new
|
||||
{
|
||||
_type = "https://in-toto.io/Statement/v1",
|
||||
subject = new[] { new { name = "test", digest = new { sha256 = "abc123" } } },
|
||||
predicateType = "https://slsa.dev/provenance/v1",
|
||||
predicate = new { buildType = "test" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var envelope = _fixture.SignJson(testData);
|
||||
var serialized = DsseRoundtripTestFixture.SerializeToBytes(envelope);
|
||||
var deserialized = DsseRoundtripTestFixture.DeserializeFromBytes(serialized);
|
||||
|
||||
// Assert
|
||||
_fixture.Verify(deserialized).Should().BeTrue();
|
||||
|
||||
var originalPayload = Encoding.UTF8.GetString(envelope.Payload.Span);
|
||||
var deserializedPayload = Encoding.UTF8.GetString(deserialized.Payload.Span);
|
||||
deserializedPayload.Should().Be(originalPayload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignSerializeDeserializeVerify_ThroughFile_PreservesIntegrity()
|
||||
{
|
||||
// Arrange
|
||||
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
|
||||
var envelope = _fixture.Sign(payload);
|
||||
|
||||
// Act - Full round-trip through file system
|
||||
var roundtrippedEnvelope = await DsseRoundtripTestFixture.RoundtripThroughFileAsync(envelope);
|
||||
|
||||
// Assert
|
||||
_fixture.Verify(roundtrippedEnvelope).Should().BeTrue();
|
||||
roundtrippedEnvelope.Payload.ToArray().Should().BeEquivalentTo(envelope.Payload.ToArray());
|
||||
}
|
||||
|
||||
// DSSE-8200-005: Tamper detection - modified payload
|
||||
|
||||
[Fact]
|
||||
public void Verify_WithModifiedPayload_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
|
||||
var envelope = _fixture.Sign(payload);
|
||||
_fixture.Verify(envelope).Should().BeTrue("unmodified envelope should verify");
|
||||
|
||||
// Act - Tamper with payload
|
||||
var serialized = DsseRoundtripTestFixture.SerializeToBytes(envelope);
|
||||
var tamperedJson = TamperWithPayload(serialized);
|
||||
var tamperedEnvelope = DsseRoundtripTestFixture.DeserializeFromBytes(tamperedJson);
|
||||
|
||||
// Assert
|
||||
_fixture.Verify(tamperedEnvelope).Should().BeFalse("tampered payload should not verify");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_WithSingleBytePayloadChange_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var payload = DsseRoundtripTestFixture.CreateTestPayload("original-content-here");
|
||||
var envelope = _fixture.Sign(payload);
|
||||
|
||||
// Act - Modify a single byte in payload
|
||||
var modifiedPayload = payload.ToArray();
|
||||
modifiedPayload[10] ^= 0x01; // Flip one bit in the middle
|
||||
|
||||
var tamperedEnvelope = new DsseEnvelope(
|
||||
envelope.PayloadType,
|
||||
modifiedPayload,
|
||||
envelope.Signatures);
|
||||
|
||||
// Assert
|
||||
_fixture.Verify(tamperedEnvelope).Should().BeFalse("single bit change should invalidate signature");
|
||||
}
|
||||
|
||||
// DSSE-8200-006: Tamper detection - modified signature
|
||||
|
||||
[Fact]
|
||||
public void Verify_WithModifiedSignature_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
|
||||
var envelope = _fixture.Sign(payload);
|
||||
_fixture.Verify(envelope).Should().BeTrue("unmodified envelope should verify");
|
||||
|
||||
// Act - Tamper with signature
|
||||
var originalSig = envelope.Signatures[0];
|
||||
var tamperedSigBytes = Convert.FromBase64String(originalSig.Signature);
|
||||
tamperedSigBytes[0] ^= 0xFF; // Corrupt first byte
|
||||
|
||||
var tamperedSig = new DsseSignature(Convert.ToBase64String(tamperedSigBytes), originalSig.KeyId);
|
||||
var tamperedEnvelope = new DsseEnvelope(
|
||||
envelope.PayloadType,
|
||||
envelope.Payload,
|
||||
[tamperedSig]);
|
||||
|
||||
// Assert
|
||||
_fixture.Verify(tamperedEnvelope).Should().BeFalse("tampered signature should not verify");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_WithTruncatedSignature_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
|
||||
var envelope = _fixture.Sign(payload);
|
||||
|
||||
// Act - Truncate signature
|
||||
var originalSig = envelope.Signatures[0];
|
||||
var truncatedSigBytes = Convert.FromBase64String(originalSig.Signature).AsSpan(0, 10).ToArray();
|
||||
|
||||
var truncatedSig = new DsseSignature(Convert.ToBase64String(truncatedSigBytes), originalSig.KeyId);
|
||||
var tamperedEnvelope = new DsseEnvelope(
|
||||
envelope.PayloadType,
|
||||
envelope.Payload,
|
||||
[truncatedSig]);
|
||||
|
||||
// Assert
|
||||
_fixture.Verify(tamperedEnvelope).Should().BeFalse("truncated signature should not verify");
|
||||
}
|
||||
|
||||
// DSSE-8200-010: Determinism - same payload signed twice produces identical envelope bytes
|
||||
|
||||
[Fact]
|
||||
public void Sign_SamePayloadTwice_WithSameKey_ProducesConsistentPayloadAndSignatureFormat()
|
||||
{
|
||||
// Arrange - Use the same key instance to sign twice
|
||||
var payload = DsseRoundtripTestFixture.CreateTestPayload("deterministic-payload");
|
||||
|
||||
// Act - Sign the same payload twice with the same key
|
||||
var envelope1 = _fixture.Sign(payload);
|
||||
var envelope2 = _fixture.Sign(payload);
|
||||
|
||||
// Assert - Payloads should be identical
|
||||
envelope1.Payload.ToArray().Should().BeEquivalentTo(envelope2.Payload.ToArray());
|
||||
envelope1.PayloadType.Should().Be(envelope2.PayloadType);
|
||||
|
||||
// Key ID should be the same
|
||||
envelope1.Signatures[0].KeyId.Should().Be(envelope2.Signatures[0].KeyId);
|
||||
|
||||
// Note: ECDSA signatures may differ due to random k value, but they should both verify
|
||||
_fixture.Verify(envelope1).Should().BeTrue();
|
||||
_fixture.Verify(envelope2).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sign_DifferentPayloads_ProducesDifferentSignatures()
|
||||
{
|
||||
// Arrange
|
||||
var payload1 = DsseRoundtripTestFixture.CreateTestPayload("payload-1");
|
||||
var payload2 = DsseRoundtripTestFixture.CreateTestPayload("payload-2");
|
||||
|
||||
// Act
|
||||
var envelope1 = _fixture.Sign(payload1);
|
||||
var envelope2 = _fixture.Sign(payload2);
|
||||
|
||||
// Assert
|
||||
envelope1.Signatures[0].Signature.Should().NotBe(envelope2.Signatures[0].Signature);
|
||||
}
|
||||
|
||||
// DSSE-8200-011: Serialization is canonical (key order, no whitespace variance)
|
||||
|
||||
[Fact]
|
||||
public void Serialize_ProducesCanonicalJson_NoWhitespaceVariance()
|
||||
{
|
||||
// Arrange
|
||||
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
|
||||
var envelope = _fixture.Sign(payload);
|
||||
|
||||
// Act - Serialize multiple times
|
||||
var bytes1 = DsseRoundtripTestFixture.SerializeToBytes(envelope);
|
||||
var bytes2 = DsseRoundtripTestFixture.SerializeToBytes(envelope);
|
||||
var bytes3 = DsseRoundtripTestFixture.SerializeToBytes(envelope);
|
||||
|
||||
// Assert - All serializations should be byte-for-byte identical
|
||||
bytes2.Should().BeEquivalentTo(bytes1);
|
||||
bytes3.Should().BeEquivalentTo(bytes1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_OrdersKeysConsistently()
|
||||
{
|
||||
// Arrange
|
||||
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
|
||||
var envelope = _fixture.Sign(payload);
|
||||
|
||||
// Act
|
||||
var serialized = DsseRoundtripTestFixture.SerializeToBytes(envelope);
|
||||
var json = Encoding.UTF8.GetString(serialized);
|
||||
|
||||
// Assert - Verify key order in JSON
|
||||
var payloadTypeIndex = json.IndexOf("\"payloadType\"");
|
||||
var payloadIndex = json.IndexOf("\"payload\"");
|
||||
var signaturesIndex = json.IndexOf("\"signatures\"");
|
||||
|
||||
payloadTypeIndex.Should().BeLessThan(payloadIndex, "payloadType should come before payload");
|
||||
payloadIndex.Should().BeLessThan(signaturesIndex, "payload should come before signatures");
|
||||
}
|
||||
|
||||
// DSSE-8200-012: Property test - serialize → deserialize → serialize produces identical bytes
|
||||
|
||||
[Theory]
|
||||
[InlineData("simple-text-payload")]
|
||||
[InlineData("")]
|
||||
[InlineData("unicode: 你好世界 🔐")]
|
||||
[InlineData("{\"key\":\"value\",\"nested\":{\"array\":[1,2,3]}}")]
|
||||
public void SerializeDeserializeSerialize_ProducesIdenticalBytes(string payloadContent)
|
||||
{
|
||||
// Arrange
|
||||
var payload = Encoding.UTF8.GetBytes(payloadContent);
|
||||
if (payload.Length == 0)
|
||||
{
|
||||
// Empty payload needs at least one byte for valid DSSE
|
||||
payload = Encoding.UTF8.GetBytes("{}");
|
||||
}
|
||||
|
||||
var envelope = _fixture.Sign(payload);
|
||||
|
||||
// Act - Triple round-trip
|
||||
var bytes1 = DsseRoundtripTestFixture.SerializeToBytes(envelope);
|
||||
var deserialized1 = DsseRoundtripTestFixture.DeserializeFromBytes(bytes1);
|
||||
var bytes2 = DsseRoundtripTestFixture.SerializeToBytes(deserialized1);
|
||||
var deserialized2 = DsseRoundtripTestFixture.DeserializeFromBytes(bytes2);
|
||||
var bytes3 = DsseRoundtripTestFixture.SerializeToBytes(deserialized2);
|
||||
|
||||
// Assert - All serializations should be identical
|
||||
bytes2.Should().BeEquivalentTo(bytes1, "first round-trip should be stable");
|
||||
bytes3.Should().BeEquivalentTo(bytes1, "second round-trip should be stable");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeDeserializeSerialize_LargePayload_ProducesIdenticalBytes()
|
||||
{
|
||||
// Arrange - Create a large payload
|
||||
var largeContent = new string('X', 100_000);
|
||||
var payload = Encoding.UTF8.GetBytes($"{{\"large\":\"{largeContent}\"}}");
|
||||
var envelope = _fixture.Sign(payload);
|
||||
|
||||
// Act
|
||||
var bytes1 = DsseRoundtripTestFixture.SerializeToBytes(envelope);
|
||||
var deserialized = DsseRoundtripTestFixture.DeserializeFromBytes(bytes1);
|
||||
var bytes2 = DsseRoundtripTestFixture.SerializeToBytes(deserialized);
|
||||
|
||||
// Assert
|
||||
bytes2.Should().BeEquivalentTo(bytes1);
|
||||
_fixture.Verify(deserialized).Should().BeTrue();
|
||||
}
|
||||
|
||||
// Verification result tests
|
||||
|
||||
[Fact]
|
||||
public void VerifyDetailed_ValidEnvelope_ReturnsSuccessResult()
|
||||
{
|
||||
// Arrange
|
||||
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
|
||||
var envelope = _fixture.Sign(payload);
|
||||
|
||||
// Act
|
||||
var result = _fixture.VerifyDetailed(envelope);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.SignatureResults.Should().HaveCount(1);
|
||||
result.SignatureResults[0].IsValid.Should().BeTrue();
|
||||
result.SignatureResults[0].FailureReason.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyDetailed_InvalidSignature_ReturnsFailureReason()
|
||||
{
|
||||
// Arrange
|
||||
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
|
||||
var envelope = _fixture.Sign(payload);
|
||||
|
||||
// Tamper with payload
|
||||
var tamperedPayload = payload.ToArray();
|
||||
tamperedPayload[0] ^= 0xFF;
|
||||
var tamperedEnvelope = new DsseEnvelope(
|
||||
envelope.PayloadType,
|
||||
tamperedPayload,
|
||||
envelope.Signatures);
|
||||
|
||||
// Act
|
||||
var result = _fixture.VerifyDetailed(tamperedEnvelope);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.SignatureResults.Should().HaveCount(1);
|
||||
result.SignatureResults[0].IsValid.Should().BeFalse();
|
||||
result.SignatureResults[0].FailureReason.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private static byte[] TamperWithPayload(byte[] serializedEnvelope)
|
||||
{
|
||||
var json = Encoding.UTF8.GetString(serializedEnvelope);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
var payloadBase64 = doc.RootElement.GetProperty("payload").GetString()!;
|
||||
var payloadBytes = Convert.FromBase64String(payloadBase64);
|
||||
|
||||
// Modify payload content
|
||||
payloadBytes[0] ^= 0xFF;
|
||||
var tamperedPayloadBase64 = Convert.ToBase64String(payloadBytes);
|
||||
|
||||
// Reconstruct JSON with tampered payload
|
||||
json = json.Replace(payloadBase64, tamperedPayloadBase64);
|
||||
return Encoding.UTF8.GetBytes(json);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_fixture.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Cryptography;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Attestor.Envelope.Tests;
|
||||
|
||||
public sealed class EnvelopeSignatureServiceTests
|
||||
{
|
||||
private static readonly byte[] SamplePayload = Encoding.UTF8.GetBytes("stella-ops-deterministic");
|
||||
|
||||
private static readonly byte[] Ed25519Seed =
|
||||
Convert.FromHexString("9D61B19DEFFD5A60BA844AF492EC2CC4" +
|
||||
"4449C5697B326919703BAC031CAE7F60D75A980182B10AB7D54BFED3C964073A" +
|
||||
"0EE172F3DAA62325AF021A68F707511A");
|
||||
|
||||
private static readonly byte[] Ed25519Public =
|
||||
Convert.FromHexString("D75A980182B10AB7D54BFED3C964073A0EE172F3DAA62325AF021A68F707511A");
|
||||
|
||||
private readonly EnvelopeSignatureService service = new();
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SignAndVerify_Ed25519_Succeeds()
|
||||
{
|
||||
var signingKey = EnvelopeKey.CreateEd25519Signer(Ed25519Seed, Ed25519Public);
|
||||
var verifyKey = EnvelopeKey.CreateEd25519Verifier(Ed25519Public);
|
||||
|
||||
var signResult = service.Sign(SamplePayload, signingKey);
|
||||
|
||||
signResult.IsSuccess.Should().BeTrue();
|
||||
signResult.Value.AlgorithmId.Should().Be(SignatureAlgorithms.Ed25519);
|
||||
signResult.Value.KeyId.Should().Be(signingKey.KeyId);
|
||||
|
||||
var verifyResult = service.Verify(SamplePayload, signResult.Value, verifyKey);
|
||||
|
||||
verifyResult.IsSuccess.Should().BeTrue();
|
||||
verifyResult.Value.Should().BeTrue();
|
||||
|
||||
var expectedKeyId = ComputeExpectedEd25519KeyId(Ed25519Public);
|
||||
signingKey.KeyId.Should().Be(expectedKeyId);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_Ed25519_InvalidSignature_ReturnsError()
|
||||
{
|
||||
var signingKey = EnvelopeKey.CreateEd25519Signer(Ed25519Seed, Ed25519Public);
|
||||
var signResult = service.Sign(SamplePayload, signingKey);
|
||||
signResult.IsSuccess.Should().BeTrue();
|
||||
|
||||
var tamperedBytes = signResult.Value.Value.ToArray();
|
||||
tamperedBytes[0] ^= 0xFF;
|
||||
var tamperedSignature = new EnvelopeSignature(signResult.Value.KeyId, signResult.Value.AlgorithmId, tamperedBytes);
|
||||
var verifyKey = EnvelopeKey.CreateEd25519Verifier(Ed25519Public);
|
||||
|
||||
var verifyResult = service.Verify(SamplePayload, tamperedSignature, verifyKey);
|
||||
|
||||
verifyResult.IsSuccess.Should().BeFalse();
|
||||
verifyResult.Error.Code.Should().Be(EnvelopeSignatureErrorCode.SignatureInvalid);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SignAndVerify_EcdsaEs256_Succeeds()
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var privateParameters = ecdsa.ExportParameters(includePrivateParameters: true);
|
||||
var publicParameters = ecdsa.ExportParameters(includePrivateParameters: false);
|
||||
|
||||
var signingKey = EnvelopeKey.CreateEcdsaSigner(SignatureAlgorithms.Es256, in privateParameters);
|
||||
var verifyKey = EnvelopeKey.CreateEcdsaVerifier(SignatureAlgorithms.Es256, in publicParameters);
|
||||
|
||||
var signResult = service.Sign(SamplePayload, signingKey);
|
||||
signResult.IsSuccess.Should().BeTrue();
|
||||
|
||||
var verifyResult = service.Verify(SamplePayload, signResult.Value, verifyKey);
|
||||
verifyResult.IsSuccess.Should().BeTrue();
|
||||
verifyResult.Value.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Sign_WithVerificationOnlyKey_ReturnsMissingPrivateKey()
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var publicParameters = ecdsa.ExportParameters(includePrivateParameters: false);
|
||||
var verifyOnlyKey = EnvelopeKey.CreateEcdsaVerifier(SignatureAlgorithms.Es256, in publicParameters);
|
||||
|
||||
var signResult = service.Sign(SamplePayload, verifyOnlyKey);
|
||||
|
||||
signResult.IsSuccess.Should().BeFalse();
|
||||
signResult.Error.Code.Should().Be(EnvelopeSignatureErrorCode.MissingPrivateKey);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_WithMismatchedKeyId_ReturnsError()
|
||||
{
|
||||
var signingKey = EnvelopeKey.CreateEd25519Signer(Ed25519Seed, Ed25519Public);
|
||||
var signResult = service.Sign(SamplePayload, signingKey);
|
||||
signResult.IsSuccess.Should().BeTrue();
|
||||
|
||||
var alternateKey = EnvelopeKey.CreateEd25519Verifier(Ed25519Public, "sha256:alternate");
|
||||
var verifyResult = service.Verify(SamplePayload, signResult.Value, alternateKey);
|
||||
|
||||
verifyResult.IsSuccess.Should().BeFalse();
|
||||
verifyResult.Error.Code.Should().Be(EnvelopeSignatureErrorCode.KeyIdMismatch);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_WithInvalidSignatureLength_ReturnsFormatError()
|
||||
{
|
||||
var verifyKey = EnvelopeKey.CreateEd25519Verifier(Ed25519Public);
|
||||
var invalidSignature = new EnvelopeSignature(verifyKey.KeyId, verifyKey.AlgorithmId, new byte[16]);
|
||||
|
||||
var verifyResult = service.Verify(SamplePayload, invalidSignature, verifyKey);
|
||||
|
||||
verifyResult.IsSuccess.Should().BeFalse();
|
||||
verifyResult.Error.Code.Should().Be(EnvelopeSignatureErrorCode.InvalidSignatureFormat);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_WithAlgorithmMismatch_ReturnsError()
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var privateParameters = ecdsa.ExportParameters(includePrivateParameters: true);
|
||||
var publicParameters = ecdsa.ExportParameters(includePrivateParameters: false);
|
||||
var signingKey = EnvelopeKey.CreateEcdsaSigner(SignatureAlgorithms.Es256, in privateParameters);
|
||||
var signResult = service.Sign(SamplePayload, signingKey);
|
||||
signResult.IsSuccess.Should().BeTrue();
|
||||
|
||||
var mismatchKey = EnvelopeKey.CreateEcdsaVerifier(SignatureAlgorithms.Es384, in publicParameters, signResult.Value.KeyId);
|
||||
var verifyResult = service.Verify(SamplePayload, signResult.Value, mismatchKey);
|
||||
|
||||
verifyResult.IsSuccess.Should().BeFalse();
|
||||
verifyResult.Error.Code.Should().Be(EnvelopeSignatureErrorCode.AlgorithmMismatch);
|
||||
}
|
||||
|
||||
private static string ComputeExpectedEd25519KeyId(byte[] publicKey)
|
||||
{
|
||||
var jwk = $"{{\"crv\":\"Ed25519\",\"kty\":\"OKP\",\"x\":\"{ToBase64Url(publicKey)}\"}}";
|
||||
using var sha = SHA256.Create();
|
||||
using StellaOps.TestKit;
|
||||
var digest = sha.ComputeHash(Encoding.UTF8.GetBytes(jwk));
|
||||
return $"sha256:{ToBase64Url(digest)}";
|
||||
}
|
||||
|
||||
private static string ToBase64Url(byte[] bytes)
|
||||
=> Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<WarningsNotAsErrors>NU1504</WarningsNotAsErrors>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -9,7 +9,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="BouncyCastle.Cryptography" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
@@ -114,7 +114,6 @@ public sealed class DsseEnvelopeSerializerTests
|
||||
Assert.NotNull(result.ExpandedJson);
|
||||
|
||||
using var expanded = JsonDocument.Parse(result.ExpandedJson!);
|
||||
using StellaOps.TestKit;
|
||||
var detached = expanded.RootElement.GetProperty("detachedPayload");
|
||||
|
||||
Assert.Equal(reference.Uri, detached.GetProperty("uri").GetString());
|
||||
|
||||
@@ -7,16 +7,19 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<WarningsNotAsErrors>NU1504</WarningsNotAsErrors>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="FsCheck.Xunit" Version="3.3.1" />
|
||||
<PackageReference Include="FsCheck" Version="3.3.1" />
|
||||
<PackageReference Include="FsCheck.Xunit" />
|
||||
<PackageReference Include="FsCheck" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" >
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" >
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -27,5 +30,4 @@
|
||||
<ProjectReference Include="..\\..\\StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="../../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -1,197 +1,724 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor", "StellaOps.Attestor", "{78C966F5-2242-D8EC-ADCA-A1A9C7F723A6}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Core", "StellaOps.Attestor\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj", "{D44872A3-772A-43D7-B340-61253543F02B}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Infrastructure", "StellaOps.Attestor\StellaOps.Attestor.Infrastructure\StellaOps.Attestor.Infrastructure.csproj", "{BFADAB55-9D9D-456F-987B-A4536027BA77}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Tests", "StellaOps.Attestor\StellaOps.Attestor.Tests\StellaOps.Attestor.Tests.csproj", "{E2546302-F0CD-43E6-9CD6-D4B5E711454C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.WebService", "StellaOps.Attestor\StellaOps.Attestor.WebService\StellaOps.Attestor.WebService.csproj", "{39CCDD3E-5802-4E72-BE0F-25F7172C74E6}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Configuration", "..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj", "{0792B7D7-E298-4639-B3DC-AFAF427810E9}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugins.Abstractions", "..\Authority\StellaOps.Authority\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj", "{E93D1212-2745-4AD7-AD42-7666952A60C5}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{9AE76C3A-0712-4DDA-A751-D0E8D59BD7A1}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Abstractions", "..\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj", "{ED2AB277-AA70-4593-869A-BB13DA55FD12}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{6E844D37-2714-496B-8557-8FA2BF1744E8}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Client", "..\Authority\StellaOps.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj", "{44EB6890-FB96-405B-8CEC-A1EEB38474CE}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.ServerIntegration", "..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj", "{36FBCE51-0429-4F2B-87FD-95B37941001D}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Core.Tests", "StellaOps.Attestor\StellaOps.Attestor.Core.Tests\StellaOps.Attestor.Core.Tests.csproj", "{B45076F7-DDD2-41A9-A853-30905ED62BFC}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{D44872A3-772A-43D7-B340-61253543F02B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D44872A3-772A-43D7-B340-61253543F02B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D44872A3-772A-43D7-B340-61253543F02B}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{D44872A3-772A-43D7-B340-61253543F02B}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{D44872A3-772A-43D7-B340-61253543F02B}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{D44872A3-772A-43D7-B340-61253543F02B}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{D44872A3-772A-43D7-B340-61253543F02B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D44872A3-772A-43D7-B340-61253543F02B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D44872A3-772A-43D7-B340-61253543F02B}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{D44872A3-772A-43D7-B340-61253543F02B}.Release|x64.Build.0 = Release|Any CPU
|
||||
{D44872A3-772A-43D7-B340-61253543F02B}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{D44872A3-772A-43D7-B340-61253543F02B}.Release|x86.Build.0 = Release|Any CPU
|
||||
{BFADAB55-9D9D-456F-987B-A4536027BA77}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BFADAB55-9D9D-456F-987B-A4536027BA77}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BFADAB55-9D9D-456F-987B-A4536027BA77}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{BFADAB55-9D9D-456F-987B-A4536027BA77}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{BFADAB55-9D9D-456F-987B-A4536027BA77}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{BFADAB55-9D9D-456F-987B-A4536027BA77}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{BFADAB55-9D9D-456F-987B-A4536027BA77}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BFADAB55-9D9D-456F-987B-A4536027BA77}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{BFADAB55-9D9D-456F-987B-A4536027BA77}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{BFADAB55-9D9D-456F-987B-A4536027BA77}.Release|x64.Build.0 = Release|Any CPU
|
||||
{BFADAB55-9D9D-456F-987B-A4536027BA77}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{BFADAB55-9D9D-456F-987B-A4536027BA77}.Release|x86.Build.0 = Release|Any CPU
|
||||
{E2546302-F0CD-43E6-9CD6-D4B5E711454C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E2546302-F0CD-43E6-9CD6-D4B5E711454C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E2546302-F0CD-43E6-9CD6-D4B5E711454C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{E2546302-F0CD-43E6-9CD6-D4B5E711454C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{E2546302-F0CD-43E6-9CD6-D4B5E711454C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{E2546302-F0CD-43E6-9CD6-D4B5E711454C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{E2546302-F0CD-43E6-9CD6-D4B5E711454C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E2546302-F0CD-43E6-9CD6-D4B5E711454C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E2546302-F0CD-43E6-9CD6-D4B5E711454C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{E2546302-F0CD-43E6-9CD6-D4B5E711454C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E2546302-F0CD-43E6-9CD6-D4B5E711454C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E2546302-F0CD-43E6-9CD6-D4B5E711454C}.Release|x86.Build.0 = Release|Any CPU
|
||||
{39CCDD3E-5802-4E72-BE0F-25F7172C74E6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{39CCDD3E-5802-4E72-BE0F-25F7172C74E6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{39CCDD3E-5802-4E72-BE0F-25F7172C74E6}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{39CCDD3E-5802-4E72-BE0F-25F7172C74E6}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{39CCDD3E-5802-4E72-BE0F-25F7172C74E6}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{39CCDD3E-5802-4E72-BE0F-25F7172C74E6}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{39CCDD3E-5802-4E72-BE0F-25F7172C74E6}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{39CCDD3E-5802-4E72-BE0F-25F7172C74E6}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{39CCDD3E-5802-4E72-BE0F-25F7172C74E6}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{39CCDD3E-5802-4E72-BE0F-25F7172C74E6}.Release|x64.Build.0 = Release|Any CPU
|
||||
{39CCDD3E-5802-4E72-BE0F-25F7172C74E6}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{39CCDD3E-5802-4E72-BE0F-25F7172C74E6}.Release|x86.Build.0 = Release|Any CPU
|
||||
{0792B7D7-E298-4639-B3DC-AFAF427810E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{0792B7D7-E298-4639-B3DC-AFAF427810E9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{0792B7D7-E298-4639-B3DC-AFAF427810E9}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{0792B7D7-E298-4639-B3DC-AFAF427810E9}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{0792B7D7-E298-4639-B3DC-AFAF427810E9}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{0792B7D7-E298-4639-B3DC-AFAF427810E9}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{0792B7D7-E298-4639-B3DC-AFAF427810E9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{0792B7D7-E298-4639-B3DC-AFAF427810E9}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{0792B7D7-E298-4639-B3DC-AFAF427810E9}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{0792B7D7-E298-4639-B3DC-AFAF427810E9}.Release|x64.Build.0 = Release|Any CPU
|
||||
{0792B7D7-E298-4639-B3DC-AFAF427810E9}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{0792B7D7-E298-4639-B3DC-AFAF427810E9}.Release|x86.Build.0 = Release|Any CPU
|
||||
{E93D1212-2745-4AD7-AD42-7666952A60C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E93D1212-2745-4AD7-AD42-7666952A60C5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E93D1212-2745-4AD7-AD42-7666952A60C5}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{E93D1212-2745-4AD7-AD42-7666952A60C5}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{E93D1212-2745-4AD7-AD42-7666952A60C5}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{E93D1212-2745-4AD7-AD42-7666952A60C5}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{E93D1212-2745-4AD7-AD42-7666952A60C5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E93D1212-2745-4AD7-AD42-7666952A60C5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E93D1212-2745-4AD7-AD42-7666952A60C5}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{E93D1212-2745-4AD7-AD42-7666952A60C5}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E93D1212-2745-4AD7-AD42-7666952A60C5}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E93D1212-2745-4AD7-AD42-7666952A60C5}.Release|x86.Build.0 = Release|Any CPU
|
||||
{9AE76C3A-0712-4DDA-A751-D0E8D59BD7A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{9AE76C3A-0712-4DDA-A751-D0E8D59BD7A1}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{9AE76C3A-0712-4DDA-A751-D0E8D59BD7A1}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{9AE76C3A-0712-4DDA-A751-D0E8D59BD7A1}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{9AE76C3A-0712-4DDA-A751-D0E8D59BD7A1}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{9AE76C3A-0712-4DDA-A751-D0E8D59BD7A1}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{9AE76C3A-0712-4DDA-A751-D0E8D59BD7A1}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{9AE76C3A-0712-4DDA-A751-D0E8D59BD7A1}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{9AE76C3A-0712-4DDA-A751-D0E8D59BD7A1}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{9AE76C3A-0712-4DDA-A751-D0E8D59BD7A1}.Release|x64.Build.0 = Release|Any CPU
|
||||
{9AE76C3A-0712-4DDA-A751-D0E8D59BD7A1}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{9AE76C3A-0712-4DDA-A751-D0E8D59BD7A1}.Release|x86.Build.0 = Release|Any CPU
|
||||
{ED2AB277-AA70-4593-869A-BB13DA55FD12}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{ED2AB277-AA70-4593-869A-BB13DA55FD12}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{ED2AB277-AA70-4593-869A-BB13DA55FD12}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{ED2AB277-AA70-4593-869A-BB13DA55FD12}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{ED2AB277-AA70-4593-869A-BB13DA55FD12}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{ED2AB277-AA70-4593-869A-BB13DA55FD12}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{ED2AB277-AA70-4593-869A-BB13DA55FD12}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{ED2AB277-AA70-4593-869A-BB13DA55FD12}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{ED2AB277-AA70-4593-869A-BB13DA55FD12}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{ED2AB277-AA70-4593-869A-BB13DA55FD12}.Release|x64.Build.0 = Release|Any CPU
|
||||
{ED2AB277-AA70-4593-869A-BB13DA55FD12}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{ED2AB277-AA70-4593-869A-BB13DA55FD12}.Release|x86.Build.0 = Release|Any CPU
|
||||
{6E844D37-2714-496B-8557-8FA2BF1744E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{6E844D37-2714-496B-8557-8FA2BF1744E8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6E844D37-2714-496B-8557-8FA2BF1744E8}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{6E844D37-2714-496B-8557-8FA2BF1744E8}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{6E844D37-2714-496B-8557-8FA2BF1744E8}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{6E844D37-2714-496B-8557-8FA2BF1744E8}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{6E844D37-2714-496B-8557-8FA2BF1744E8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6E844D37-2714-496B-8557-8FA2BF1744E8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{6E844D37-2714-496B-8557-8FA2BF1744E8}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{6E844D37-2714-496B-8557-8FA2BF1744E8}.Release|x64.Build.0 = Release|Any CPU
|
||||
{6E844D37-2714-496B-8557-8FA2BF1744E8}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{6E844D37-2714-496B-8557-8FA2BF1744E8}.Release|x86.Build.0 = Release|Any CPU
|
||||
{44EB6890-FB96-405B-8CEC-A1EEB38474CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{44EB6890-FB96-405B-8CEC-A1EEB38474CE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{44EB6890-FB96-405B-8CEC-A1EEB38474CE}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{44EB6890-FB96-405B-8CEC-A1EEB38474CE}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{44EB6890-FB96-405B-8CEC-A1EEB38474CE}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{44EB6890-FB96-405B-8CEC-A1EEB38474CE}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{44EB6890-FB96-405B-8CEC-A1EEB38474CE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{44EB6890-FB96-405B-8CEC-A1EEB38474CE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{44EB6890-FB96-405B-8CEC-A1EEB38474CE}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{44EB6890-FB96-405B-8CEC-A1EEB38474CE}.Release|x64.Build.0 = Release|Any CPU
|
||||
{44EB6890-FB96-405B-8CEC-A1EEB38474CE}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{44EB6890-FB96-405B-8CEC-A1EEB38474CE}.Release|x86.Build.0 = Release|Any CPU
|
||||
{36FBCE51-0429-4F2B-87FD-95B37941001D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{36FBCE51-0429-4F2B-87FD-95B37941001D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{36FBCE51-0429-4F2B-87FD-95B37941001D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{36FBCE51-0429-4F2B-87FD-95B37941001D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{36FBCE51-0429-4F2B-87FD-95B37941001D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{36FBCE51-0429-4F2B-87FD-95B37941001D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{36FBCE51-0429-4F2B-87FD-95B37941001D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{36FBCE51-0429-4F2B-87FD-95B37941001D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{36FBCE51-0429-4F2B-87FD-95B37941001D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{36FBCE51-0429-4F2B-87FD-95B37941001D}.Release|x64.Build.0 = Release|Any CPU
|
||||
{36FBCE51-0429-4F2B-87FD-95B37941001D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{36FBCE51-0429-4F2B-87FD-95B37941001D}.Release|x86.Build.0 = Release|Any CPU
|
||||
{B45076F7-DDD2-41A9-A853-30905ED62BFC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B45076F7-DDD2-41A9-A853-30905ED62BFC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B45076F7-DDD2-41A9-A853-30905ED62BFC}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{B45076F7-DDD2-41A9-A853-30905ED62BFC}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{B45076F7-DDD2-41A9-A853-30905ED62BFC}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{B45076F7-DDD2-41A9-A853-30905ED62BFC}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{B45076F7-DDD2-41A9-A853-30905ED62BFC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B45076F7-DDD2-41A9-A853-30905ED62BFC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B45076F7-DDD2-41A9-A853-30905ED62BFC}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{B45076F7-DDD2-41A9-A853-30905ED62BFC}.Release|x64.Build.0 = Release|Any CPU
|
||||
{B45076F7-DDD2-41A9-A853-30905ED62BFC}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{B45076F7-DDD2-41A9-A853-30905ED62BFC}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{D44872A3-772A-43D7-B340-61253543F02B} = {78C966F5-2242-D8EC-ADCA-A1A9C7F723A6}
|
||||
{BFADAB55-9D9D-456F-987B-A4536027BA77} = {78C966F5-2242-D8EC-ADCA-A1A9C7F723A6}
|
||||
{E2546302-F0CD-43E6-9CD6-D4B5E711454C} = {78C966F5-2242-D8EC-ADCA-A1A9C7F723A6}
|
||||
{39CCDD3E-5802-4E72-BE0F-25F7172C74E6} = {78C966F5-2242-D8EC-ADCA-A1A9C7F723A6}
|
||||
{B45076F7-DDD2-41A9-A853-30905ED62BFC} = {78C966F5-2242-D8EC-ADCA-A1A9C7F723A6}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestation", "StellaOps.Attestation", "{90CF3381-CBAE-2B8D-0537-AD64B791BAF6}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestation.Tests", "StellaOps.Attestation.Tests", "{16FDFA1F-498B-102B-17E1-FC00C09D4EBC}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor", "StellaOps.Attestor", "{71E0B869-A3E8-5C22-3F16-2FAC19BA5CF4}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.Envelope", "StellaOps.Attestor.Envelope", "{EEC3E9C8-801E-B985-7464-0E951734E27B}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{24E31B89-9882-D59D-8E14-703E07846191}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.Envelope.Tests", "StellaOps.Attestor.Envelope.Tests", "{74462AC2-A462-A614-2624-C42ED04D63E5}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.Types", "StellaOps.Attestor.Types", "{36EEFF85-DF86-D5D9-D65E-25B430F8062A}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{03B758AA-030D-70A3-63D4-D4D0C55B0FB0}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.Types.Generator", "StellaOps.Attestor.Types.Generator", "{BCA2B7CD-4712-2E23-CAD5-08A6E0E5AF9E}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.Verify", "StellaOps.Attestor.Verify", "{E5BCCC93-A8F0-B1E2-70BA-BB357163D73D}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.Core", "StellaOps.Attestor.Core", "{82949389-F04A-4A86-CFCD-F0904037BE59}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.Core.Tests", "StellaOps.Attestor.Core.Tests", "{1D6ACC15-2455-55AE-0163-443FE1D2E886}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.Infrastructure", "StellaOps.Attestor.Infrastructure", "{6B8640E3-A642-EA63-30CD-9F2534021598}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.Tests", "StellaOps.Attestor.Tests", "{CE9F45C3-E45F-BA47-C46D-90BAF329332F}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.WebService", "StellaOps.Attestor.WebService", "{0EEF1F44-5047-7B89-B833-CBA24BD4D1D0}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__External", "__External", "{5B52EF8A-3661-DCFF-797D-BC4D6AC60BDA}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AirGap", "AirGap", "{F310596E-88BB-9E54-885E-21C61971917E}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.AirGap.Policy", "StellaOps.AirGap.Policy", "{D9492ED1-A812-924B-65E4-F518592B49BB}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.AirGap.Policy", "StellaOps.AirGap.Policy", "{3823DE1E-2ACE-C956-99E1-00DB786D9E1D}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Authority", "Authority", "{C1DCEFBD-12A5-EAAE-632E-8EEB9BE491B6}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Authority", "StellaOps.Authority", "{A6928CBA-4D4D-AB2B-CA19-FEE6E73ECE70}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Auth.Abstractions", "StellaOps.Auth.Abstractions", "{F2E6CB0E-DF77-1FAA-582B-62B040DF3848}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Auth.Client", "StellaOps.Auth.Client", "{C494ECBE-DEA5-3576-D2AF-200FF12BC144}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Auth.ServerIntegration", "StellaOps.Auth.ServerIntegration", "{7E890DF9-B715-B6DF-2498-FD74DDA87D71}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Authority.Plugins.Abstractions", "StellaOps.Authority.Plugins.Abstractions", "{64689413-46D7-8499-68A6-B6367ACBC597}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Concelier", "Concelier", "{157C3671-CA0B-69FA-A7C9-74A1FDA97B99}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{F39E09D6-BF93-B64A-CFE7-2BA92815C0FE}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.SourceIntel", "StellaOps.Concelier.SourceIntel", "{F2B58F4E-6F28-A25F-5BFB-CDEBAD6B9A3E}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Feedser", "Feedser", "{C4A90603-BE42-0044-CAB4-3EB910AD51A5}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Feedser.BinaryAnalysis", "StellaOps.Feedser.BinaryAnalysis", "{054761F9-16D3-B2F8-6F4D-EFC2248805CD}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Feedser.Core", "StellaOps.Feedser.Core", "{B54CE64C-4167-1DD1-B7D6-2FD7A5AEF715}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Provenance", "Provenance", "{316BBD0A-04D2-85C9-52EA-7993CC6C8930}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Provenance.Attestation", "StellaOps.Provenance.Attestation", "{9D6AB85A-85EA-D85A-5566-A121D34016E6}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Router", "Router", "{FC018E5B-1E2F-DE19-1E97-0C845058C469}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{1BE5B76C-B486-560B-6CB2-44C6537249AA}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Messaging", "StellaOps.Messaging", "{F4F1CBE2-1CDD-CAA4-41F0-266DB4677C05}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Microservice", "StellaOps.Microservice", "{3DE1DCDC-C845-4AC7-7B66-34B0A9E8626B}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Microservice.AspNetCore", "StellaOps.Microservice.AspNetCore", "{6FA01E92-606B-0CB8-8583-6F693A903CFC}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Router.AspNet", "StellaOps.Router.AspNet", "{A5994E92-7E0E-89FE-5628-DE1A0176B8BA}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Router.Common", "StellaOps.Router.Common", "{54C11B29-4C54-7255-AB44-BEB63AF9BD1F}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Signer", "Signer", "{3247EE0D-B3E9-9C11-B0AE-FE719410390B}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Signer", "StellaOps.Signer", "{CD7C09DA-FEC8-2CC5-D00C-E525638DFF4A}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Signer.Core", "StellaOps.Signer.Core", "{79B10804-91E9-972E-1913-EE0F0B11663E}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{1345DD29-BB3A-FB5F-4B3D-E29F6045A27A}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Canonical.Json", "StellaOps.Canonical.Json", "{79E122F4-2325-3E92-438E-5825A307B594}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Configuration", "StellaOps.Configuration", "{538E2D98-5325-3F54-BE74-EFE5FC1ECBD8}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography", "StellaOps.Cryptography", "{66557252-B5C4-664B-D807-07018C627474}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.DependencyInjection", "StellaOps.Cryptography.DependencyInjection", "{7203223D-FF02-7BEB-2798-D1639ACC01C4}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Kms", "StellaOps.Cryptography.Kms", "{5AC9EE40-1881-5F8A-46A2-2C303950D3C8}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.BouncyCastle", "StellaOps.Cryptography.Plugin.BouncyCastle", "{927E3CD3-4C20-4DE5-A395-D0977152A8D3}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.CryptoPro", "StellaOps.Cryptography.Plugin.CryptoPro", "{3C69853C-90E3-D889-1960-3B9229882590}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.OpenSslGost", "StellaOps.Cryptography.Plugin.OpenSslGost", "{643E4D4C-BC96-A37F-E0EC-488127F0B127}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.Pkcs11Gost", "StellaOps.Cryptography.Plugin.Pkcs11Gost", "{6F2CA7F5-3E7C-C61B-94E6-E7DD1227B5B1}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.PqSoft", "StellaOps.Cryptography.Plugin.PqSoft", "{F04B7DBB-77A5-C978-B2DE-8C189A32AA72}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.SimRemote", "StellaOps.Cryptography.Plugin.SimRemote", "{7C72F22A-20FF-DF5B-9191-6DFD0D497DB2}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.SmRemote", "StellaOps.Cryptography.Plugin.SmRemote", "{C896CC0A-F5E6-9AA4-C582-E691441F8D32}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.SmSoft", "StellaOps.Cryptography.Plugin.SmSoft", "{0AA3A418-AB45-CCA4-46D4-EEBFE011FECA}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.WineCsp", "StellaOps.Cryptography.Plugin.WineCsp", "{225D9926-4AE8-E539-70AD-8698E688F271}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.PluginLoader", "StellaOps.Cryptography.PluginLoader", "{D6E8E69C-F721-BBCB-8C39-9716D53D72AD}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.DependencyInjection", "StellaOps.DependencyInjection", "{589A43FD-8213-E9E3-6CFF-9CBA72D53E98}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Evidence.Bundle", "StellaOps.Evidence.Bundle", "{2BACF7E3-1278-FE99-8343-8221E6FBA9DE}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Evidence.Core", "StellaOps.Evidence.Core", "{75E47125-E4D7-8482-F1A4-726564970864}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Plugin", "StellaOps.Plugin", "{772B02B5-6280-E1D4-3E2E-248D0455C2FB}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.TestKit", "StellaOps.TestKit", "{8380A20C-A5B8-EE91-1A58-270323688CB9}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{A5C98087-E847-D2C4-2143-20869479839D}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.Bundle", "StellaOps.Attestor.Bundle", "{8B253AA0-6EEA-0F51-F0A8-EEA915D44F48}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.Bundling", "StellaOps.Attestor.Bundling", "{0CF93E6B-0F6A-EBF0-2E8A-556F2C6D72A9}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.GraphRoot", "StellaOps.Attestor.GraphRoot", "{72934DAE-92BF-2934-E9DC-04C2AB02B516}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.Oci", "StellaOps.Attestor.Oci", "{0B7675BE-31C7-F03F-62C0-255CD8BE54BB}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.Offline", "StellaOps.Attestor.Offline", "{DF4A5FA5-C292-27B3-A767-FB4996A8A902}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.Persistence", "StellaOps.Attestor.Persistence", "{90FB6C61-A2D9-5036-9B21-C68557ABA436}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.ProofChain", "StellaOps.Attestor.ProofChain", "{65801826-F5F7-41BA-CB10-5789ED3F3CF6}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.StandardPredicates", "StellaOps.Attestor.StandardPredicates", "{5655485E-13E7-6E41-7969-92595929FC6F}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.TrustVerdict", "StellaOps.Attestor.TrustVerdict", "{6BFEF2CB-6F79-173F-9855-B3559FA8E68E}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.TrustVerdict.Tests", "StellaOps.Attestor.TrustVerdict.Tests", "{6982097F-AD93-D38F-56A6-33B35C576E0E}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{AB891B76-C0E8-53F9-5C21-062253F7FAD4}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.GraphRoot.Tests", "StellaOps.Attestor.GraphRoot.Tests", "{A3E99180-EC19-5022-73BA-ED9734816449}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{BB76B5A5-14BA-E317-828D-110B711D71F5}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.Bundle.Tests", "StellaOps.Attestor.Bundle.Tests", "{E379EF24-F47D-E927-DBEB-25A54D222C11}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.Bundling.Tests", "StellaOps.Attestor.Bundling.Tests", "{57D43274-FC41-0C54-51B1-C97F1DF9AFFF}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.Oci.Tests", "StellaOps.Attestor.Oci.Tests", "{A488002F-3672-6BFD-80E8-32403AE4E7B0}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.Offline.Tests", "StellaOps.Attestor.Offline.Tests", "{D5F3ECBE-5065-3719-6C41-E48C50813B54}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.Persistence.Tests", "StellaOps.Attestor.Persistence.Tests", "{D93629D2-E9AB-12A7-6862-28AEA680E7EC}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.ProofChain.Tests", "StellaOps.Attestor.ProofChain.Tests", "{434E4734-E228-6879-9792-4FCC89EAE78B}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.StandardPredicates.Tests", "StellaOps.Attestor.StandardPredicates.Tests", "{E2B3CA1A-646E-50B4-E4F4-7BA26C76FA89}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.Types.Tests", "StellaOps.Attestor.Types.Tests", "{6918C548-099F-0CB2-5D3E-A4328B2D2A03}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AirGap.Policy", "E:\dev\git.stella-ops.org\src\AirGap\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.csproj", "{AD31623A-BC43-52C2-D906-AC1D8784A541}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestation", "StellaOps.Attestation\StellaOps.Attestation.csproj", "{E106BC8E-B20D-C1B5-130C-DAC28922112A}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestation.Tests", "StellaOps.Attestation.Tests\StellaOps.Attestation.Tests.csproj", "{15B19EA6-64A2-9F72-253E-8C25498642A4}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Bundle", "__Libraries\StellaOps.Attestor.Bundle\StellaOps.Attestor.Bundle.csproj", "{A819B4D8-A6E5-E657-D273-B1C8600B995E}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Bundle.Tests", "__Tests\StellaOps.Attestor.Bundle.Tests\StellaOps.Attestor.Bundle.Tests.csproj", "{FB0A6817-E520-2A7D-05B2-DEE5068F40EF}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Bundling", "__Libraries\StellaOps.Attestor.Bundling\StellaOps.Attestor.Bundling.csproj", "{E801E8A7-6CE4-8230-C955-5484545215FB}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Bundling.Tests", "__Tests\StellaOps.Attestor.Bundling.Tests\StellaOps.Attestor.Bundling.Tests.csproj", "{40C1DF68-8489-553B-2C64-55DA7380ED35}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Core", "StellaOps.Attestor\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj", "{5B4DF41E-C8CC-2606-FA2D-967118BD3C59}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Core.Tests", "StellaOps.Attestor\StellaOps.Attestor.Core.Tests\StellaOps.Attestor.Core.Tests.csproj", "{06135530-D68F-1A03-22D7-BC84EFD2E11F}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Envelope", "StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj", "{3D8C5A6C-462D-7487-5BD0-A3EF6B657EB6}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Envelope.Tests", "StellaOps.Attestor.Envelope\__Tests\StellaOps.Attestor.Envelope.Tests\StellaOps.Attestor.Envelope.Tests.csproj", "{A32129FA-4E92-7D7F-A61F-BEB52EFBF48B}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.GraphRoot", "__Libraries\StellaOps.Attestor.GraphRoot\StellaOps.Attestor.GraphRoot.csproj", "{2609BC1A-6765-29BE-78CC-C0F1D2814F10}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.GraphRoot.Tests", "__Libraries\__Tests\StellaOps.Attestor.GraphRoot.Tests\StellaOps.Attestor.GraphRoot.Tests.csproj", "{69E0EC1F-5029-947D-1413-EF882927E2B0}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Infrastructure", "StellaOps.Attestor\StellaOps.Attestor.Infrastructure\StellaOps.Attestor.Infrastructure.csproj", "{3FEDE6CF-5A30-3B6A-DC12-F8980A151FA3}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Oci", "__Libraries\StellaOps.Attestor.Oci\StellaOps.Attestor.Oci.csproj", "{1518529E-F254-A7FE-8370-AB3BE062EFF1}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Oci.Tests", "__Tests\StellaOps.Attestor.Oci.Tests\StellaOps.Attestor.Oci.Tests.csproj", "{F9C8D029-819C-9990-4B9E-654852DAC9FA}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Offline", "__Libraries\StellaOps.Attestor.Offline\StellaOps.Attestor.Offline.csproj", "{DFCE287C-0F71-9928-52EE-853D4F577AC2}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Offline.Tests", "__Tests\StellaOps.Attestor.Offline.Tests\StellaOps.Attestor.Offline.Tests.csproj", "{A8ADAD4F-416B-FC6C-B277-6B30175923D7}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Persistence", "__Libraries\StellaOps.Attestor.Persistence\StellaOps.Attestor.Persistence.csproj", "{C938EE4E-05F3-D70F-D4CE-5DD3BD30A9BE}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Persistence.Tests", "__Tests\StellaOps.Attestor.Persistence.Tests\StellaOps.Attestor.Persistence.Tests.csproj", "{30E49A0B-9AF7-BD40-2F67-E1649E0C01D3}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.ProofChain", "__Libraries\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj", "{C6822231-A4F4-9E69-6CE2-4FDB3E81C728}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.ProofChain.Tests", "__Tests\StellaOps.Attestor.ProofChain.Tests\StellaOps.Attestor.ProofChain.Tests.csproj", "{3DCC5B0B-61F6-D9FE-1ADA-00275F8EC014}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.StandardPredicates", "__Libraries\StellaOps.Attestor.StandardPredicates\StellaOps.Attestor.StandardPredicates.csproj", "{5405F1C4-B6AA-5A57-5C5E-BA054C886E0A}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.StandardPredicates.Tests", "__Tests\StellaOps.Attestor.StandardPredicates.Tests\StellaOps.Attestor.StandardPredicates.Tests.csproj", "{606D5F2B-4DC3-EF27-D1EA-E34079906290}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Tests", "StellaOps.Attestor\StellaOps.Attestor.Tests\StellaOps.Attestor.Tests.csproj", "{E07533EC-A1A3-1C88-56B4-2D0F6AF2C108}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.TrustVerdict", "__Libraries\StellaOps.Attestor.TrustVerdict\StellaOps.Attestor.TrustVerdict.csproj", "{3764DF9D-85DB-0693-2652-27F255BEF707}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.TrustVerdict.Tests", "__Libraries\StellaOps.Attestor.TrustVerdict.Tests\StellaOps.Attestor.TrustVerdict.Tests.csproj", "{28173802-4E31-989B-3EC8-EFA2F3E303FE}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Types.Generator", "StellaOps.Attestor.Types\Tools\StellaOps.Attestor.Types.Generator\StellaOps.Attestor.Types.Generator.csproj", "{A4BE8496-7AAD-5ABC-AC6A-F6F616337621}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Types.Tests", "__Tests\StellaOps.Attestor.Types.Tests\StellaOps.Attestor.Types.Tests.csproj", "{389AA121-1A46-F197-B5CE-E38A70E7B8E0}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Verify", "StellaOps.Attestor.Verify\StellaOps.Attestor.Verify.csproj", "{8AEE7695-A038-2706-8977-DBA192AD1B19}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.WebService", "StellaOps.Attestor\StellaOps.Attestor.WebService\StellaOps.Attestor.WebService.csproj", "{41556833-B688-61CF-8C6C-4F5CA610CA17}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Abstractions", "E:\dev\git.stella-ops.org\src\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj", "{55D9B653-FB76-FCE8-1A3C-67B1BEDEC214}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Client", "E:\dev\git.stella-ops.org\src\Authority\StellaOps.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj", "{DE5BF139-1E5C-D6EA-4FAA-661EF353A194}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.ServerIntegration", "E:\dev\git.stella-ops.org\src\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj", "{ECA25786-A3A8-92C4-4AA3-D4A73C69FDCA}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugins.Abstractions", "E:\dev\git.stella-ops.org\src\Authority\StellaOps.Authority\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj", "{97F94029-5419-6187-5A63-5C8FD9232FAE}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Canonical.Json", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj", "{AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.SourceIntel", "E:\dev\git.stella-ops.org\src\Concelier\__Libraries\StellaOps.Concelier.SourceIntel\StellaOps.Concelier.SourceIntel.csproj", "{EB093C48-CDAC-106B-1196-AE34809B34C0}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Configuration", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj", "{92C62F7B-8028-6EE1-B71B-F45F459B8E97}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{F664A948-E352-5808-E780-77A03F19E93E}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.DependencyInjection", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj", "{FA83F778-5252-0B80-5555-E69F790322EA}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Kms", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Kms\StellaOps.Cryptography.Kms.csproj", "{F3A27846-6DE0-3448-222C-25A273E86B2E}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.BouncyCastle", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.BouncyCastle\StellaOps.Cryptography.Plugin.BouncyCastle.csproj", "{166F4DEC-9886-92D5-6496-085664E9F08F}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.CryptoPro", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.CryptoPro\StellaOps.Cryptography.Plugin.CryptoPro.csproj", "{C53E0895-879A-D9E6-0A43-24AD17A2F270}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.OpenSslGost", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.OpenSslGost\StellaOps.Cryptography.Plugin.OpenSslGost.csproj", "{0AED303F-69E6-238F-EF80-81985080EDB7}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.Pkcs11Gost", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.Pkcs11Gost\StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj", "{2904D288-CE64-A565-2C46-C2E85A96A1EE}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.PqSoft", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.PqSoft\StellaOps.Cryptography.Plugin.PqSoft.csproj", "{A6667CC3-B77F-023E-3A67-05F99E9FF46A}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.SimRemote", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.SimRemote\StellaOps.Cryptography.Plugin.SimRemote.csproj", "{A26E2816-F787-F76B-1D6C-E086DD3E19CE}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.SmRemote", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.SmRemote\StellaOps.Cryptography.Plugin.SmRemote.csproj", "{B3DEC619-67AC-1B5A-4F3E-A1F24C3F6877}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.SmSoft", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.SmSoft\StellaOps.Cryptography.Plugin.SmSoft.csproj", "{90DB65B4-8F6E-FB8E-0281-505AD8BC6BA6}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.WineCsp", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.WineCsp\StellaOps.Cryptography.Plugin.WineCsp.csproj", "{059FBB86-DEE6-8207-3F23-2A1A3EC00DEA}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.PluginLoader", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.PluginLoader\StellaOps.Cryptography.PluginLoader.csproj", "{8BBA3159-C4CC-F685-A28C-7FE6CBD3D2A1}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{632A1F0D-1BA5-C84B-B716-2BE638A92780}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Evidence.Bundle", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Evidence.Bundle\StellaOps.Evidence.Bundle.csproj", "{9DE7852B-7E2D-257E-B0F1-45D2687854ED}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Evidence.Core", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Evidence.Core\StellaOps.Evidence.Core.csproj", "{DC2AFC89-C3C8-4E9B-13A7-027EB6386EFA}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.BinaryAnalysis", "E:\dev\git.stella-ops.org\src\Feedser\StellaOps.Feedser.BinaryAnalysis\StellaOps.Feedser.BinaryAnalysis.csproj", "{CB296A20-2732-77C1-7F23-27D5BAEDD0C7}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Core", "E:\dev\git.stella-ops.org\src\Feedser\StellaOps.Feedser.Core\StellaOps.Feedser.Core.csproj", "{0DBEC9BA-FE1D-3898-B2C6-E4357DC23E0F}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Messaging", "E:\dev\git.stella-ops.org\src\Router\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj", "{97998C88-E6E1-D5E2-B632-537B58E00CBF}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Microservice", "E:\dev\git.stella-ops.org\src\Router\__Libraries\StellaOps.Microservice\StellaOps.Microservice.csproj", "{BAD08D96-A80A-D27F-5D9C-656AEEB3D568}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Microservice.AspNetCore", "E:\dev\git.stella-ops.org\src\Router\__Libraries\StellaOps.Microservice.AspNetCore\StellaOps.Microservice.AspNetCore.csproj", "{F63694F1-B56D-6E72-3F5D-5D38B1541F0F}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Plugin", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj", "{38A9EE9B-6FC8-93BC-0D43-2A906E678D66}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Provenance.Attestation", "E:\dev\git.stella-ops.org\src\Provenance\StellaOps.Provenance.Attestation\StellaOps.Provenance.Attestation.csproj", "{A78EBC0F-C62C-8F56-95C0-330E376242A2}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.AspNet", "E:\dev\git.stella-ops.org\src\Router\__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj", "{79104479-B087-E5D0-5523-F1803282A246}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Router.Common", "E:\dev\git.stella-ops.org\src\Router\__Libraries\StellaOps.Router.Common\StellaOps.Router.Common.csproj", "{F17A6F0B-3120-2BA9-84D8-5F8BA0B9705D}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Signer.Core", "E:\dev\git.stella-ops.org\src\Signer\StellaOps.Signer\StellaOps.Signer.Core\StellaOps.Signer.Core.csproj", "{0AF13355-173C-3128-5AFC-D32E540DA3EF}"
|
||||
|
||||
EndProject
|
||||
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TestKit", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj", "{AF043113-CCE3-59C1-DF71-9804155F26A8}"
|
||||
|
||||
EndProject
|
||||
|
||||
Global
|
||||
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
|
||||
Release|Any CPU = Release|Any CPU
|
||||
|
||||
EndGlobalSection
|
||||
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
|
||||
{AD31623A-BC43-52C2-D906-AC1D8784A541}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
|
||||
{AD31623A-BC43-52C2-D906-AC1D8784A541}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
|
||||
{AD31623A-BC43-52C2-D906-AC1D8784A541}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
|
||||
{AD31623A-BC43-52C2-D906-AC1D8784A541}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
|
||||
{E106BC8E-B20D-C1B5-130C-DAC28922112A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
|
||||
{E106BC8E-B20D-C1B5-130C-DAC28922112A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
|
||||
{E106BC8E-B20D-C1B5-130C-DAC28922112A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
|
||||
{E106BC8E-B20D-C1B5-130C-DAC28922112A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
|
||||
{15B19EA6-64A2-9F72-253E-8C25498642A4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
|
||||
{15B19EA6-64A2-9F72-253E-8C25498642A4}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
|
||||
{15B19EA6-64A2-9F72-253E-8C25498642A4}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
|
||||
{15B19EA6-64A2-9F72-253E-8C25498642A4}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
|
||||
{A819B4D8-A6E5-E657-D273-B1C8600B995E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
|
||||
{A819B4D8-A6E5-E657-D273-B1C8600B995E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
|
||||
{A819B4D8-A6E5-E657-D273-B1C8600B995E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
|
||||
{A819B4D8-A6E5-E657-D273-B1C8600B995E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
|
||||
{FB0A6817-E520-2A7D-05B2-DEE5068F40EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
|
||||
{FB0A6817-E520-2A7D-05B2-DEE5068F40EF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
|
||||
{FB0A6817-E520-2A7D-05B2-DEE5068F40EF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
|
||||
{FB0A6817-E520-2A7D-05B2-DEE5068F40EF}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
|
||||
{E801E8A7-6CE4-8230-C955-5484545215FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
|
||||
{E801E8A7-6CE4-8230-C955-5484545215FB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
|
||||
{E801E8A7-6CE4-8230-C955-5484545215FB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
|
||||
{E801E8A7-6CE4-8230-C955-5484545215FB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
|
||||
{40C1DF68-8489-553B-2C64-55DA7380ED35}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
|
||||
{40C1DF68-8489-553B-2C64-55DA7380ED35}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
|
||||
{40C1DF68-8489-553B-2C64-55DA7380ED35}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
|
||||
{40C1DF68-8489-553B-2C64-55DA7380ED35}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
|
||||
{5B4DF41E-C8CC-2606-FA2D-967118BD3C59}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
|
||||
{5B4DF41E-C8CC-2606-FA2D-967118BD3C59}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
|
||||
{5B4DF41E-C8CC-2606-FA2D-967118BD3C59}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
|
||||
{5B4DF41E-C8CC-2606-FA2D-967118BD3C59}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
|
||||
{06135530-D68F-1A03-22D7-BC84EFD2E11F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
|
||||
{06135530-D68F-1A03-22D7-BC84EFD2E11F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
|
||||
{06135530-D68F-1A03-22D7-BC84EFD2E11F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
|
||||
{06135530-D68F-1A03-22D7-BC84EFD2E11F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
|
||||
{3D8C5A6C-462D-7487-5BD0-A3EF6B657EB6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
|
||||
{3D8C5A6C-462D-7487-5BD0-A3EF6B657EB6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
|
||||
{3D8C5A6C-462D-7487-5BD0-A3EF6B657EB6}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
|
||||
{3D8C5A6C-462D-7487-5BD0-A3EF6B657EB6}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
|
||||
{A32129FA-4E92-7D7F-A61F-BEB52EFBF48B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
|
||||
{A32129FA-4E92-7D7F-A61F-BEB52EFBF48B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
|
||||
{A32129FA-4E92-7D7F-A61F-BEB52EFBF48B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
|
||||
{A32129FA-4E92-7D7F-A61F-BEB52EFBF48B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
|
||||
{2609BC1A-6765-29BE-78CC-C0F1D2814F10}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
|
||||
{2609BC1A-6765-29BE-78CC-C0F1D2814F10}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
|
||||
{2609BC1A-6765-29BE-78CC-C0F1D2814F10}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
|
||||
{2609BC1A-6765-29BE-78CC-C0F1D2814F10}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
|
||||
{69E0EC1F-5029-947D-1413-EF882927E2B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
|
||||
{69E0EC1F-5029-947D-1413-EF882927E2B0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
|
||||
{69E0EC1F-5029-947D-1413-EF882927E2B0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
|
||||
{69E0EC1F-5029-947D-1413-EF882927E2B0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
|
||||
@@ -8,25 +8,23 @@
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<PackageReference Include="BouncyCastle.Cryptography" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" >
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" >
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -0,0 +1,325 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DeltaAttestationService.cs
|
||||
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-025)
|
||||
// Task: Implement IDeltaVerdictAttestationService
|
||||
// Description: Creates DSSE-signed in-toto statements for lineage delta changes.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Core.Signing;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Signer.Core;
|
||||
using StellaOps.Signer.Core.Predicates;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Delta;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of <see cref="IDeltaAttestationService"/> that creates DSSE-signed
|
||||
/// in-toto statements for lineage delta changes.
|
||||
/// </summary>
|
||||
public sealed class DeltaAttestationService : IDeltaAttestationService
|
||||
{
|
||||
private static readonly ActivitySource ActivitySource = new("StellaOps.Attestor.Delta");
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly IAttestationSigningService _signingService;
|
||||
private readonly ILogger<DeltaAttestationService> _logger;
|
||||
private readonly DeltaAttestationOptions _options;
|
||||
|
||||
public DeltaAttestationService(
|
||||
IAttestationSigningService signingService,
|
||||
IOptions<DeltaAttestationOptions> options,
|
||||
ILogger<DeltaAttestationService> logger)
|
||||
{
|
||||
_signingService = signingService ?? throw new ArgumentNullException(nameof(signingService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? new DeltaAttestationOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DeltaAttestationResult> CreateVexDeltaAttestationAsync(
|
||||
VexDeltaAttestationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var activity = ActivitySource.StartActivity("CreateVexDeltaAttestation");
|
||||
activity?.SetTag("from_digest", request.FromDigest);
|
||||
activity?.SetTag("to_digest", request.ToDigest);
|
||||
activity?.SetTag("tenant_id", request.TenantId);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Creating VEX delta attestation from {FromDigest} to {ToDigest}",
|
||||
request.FromDigest, request.ToDigest);
|
||||
|
||||
return await CreateDeltaAttestationAsync(
|
||||
request,
|
||||
request.Delta,
|
||||
PredicateTypes.StellaOpsVexDelta,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DeltaAttestationResult> CreateSbomDeltaAttestationAsync(
|
||||
SbomDeltaAttestationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var activity = ActivitySource.StartActivity("CreateSbomDeltaAttestation");
|
||||
activity?.SetTag("from_digest", request.FromDigest);
|
||||
activity?.SetTag("to_digest", request.ToDigest);
|
||||
activity?.SetTag("tenant_id", request.TenantId);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Creating SBOM delta attestation from {FromDigest} to {ToDigest}",
|
||||
request.FromDigest, request.ToDigest);
|
||||
|
||||
return await CreateDeltaAttestationAsync(
|
||||
request,
|
||||
request.Delta,
|
||||
PredicateTypes.StellaOpsSbomDelta,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DeltaAttestationResult> CreateVerdictDeltaAttestationAsync(
|
||||
VerdictDeltaAttestationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var activity = ActivitySource.StartActivity("CreateVerdictDeltaAttestation");
|
||||
activity?.SetTag("from_digest", request.FromDigest);
|
||||
activity?.SetTag("to_digest", request.ToDigest);
|
||||
activity?.SetTag("tenant_id", request.TenantId);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Creating verdict delta attestation from {FromDigest} to {ToDigest}",
|
||||
request.FromDigest, request.ToDigest);
|
||||
|
||||
return await CreateDeltaAttestationAsync(
|
||||
request,
|
||||
request.Delta,
|
||||
PredicateTypes.StellaOpsVerdictDelta,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DeltaAttestationResult> CreateReachabilityDeltaAttestationAsync(
|
||||
ReachabilityDeltaAttestationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var activity = ActivitySource.StartActivity("CreateReachabilityDeltaAttestation");
|
||||
activity?.SetTag("from_digest", request.FromDigest);
|
||||
activity?.SetTag("to_digest", request.ToDigest);
|
||||
activity?.SetTag("tenant_id", request.TenantId);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Creating reachability delta attestation from {FromDigest} to {ToDigest}",
|
||||
request.FromDigest, request.ToDigest);
|
||||
|
||||
return await CreateDeltaAttestationAsync(
|
||||
request,
|
||||
request.Delta,
|
||||
PredicateTypes.StellaOpsReachabilityDelta,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<DeltaAttestationResult> CreateDeltaAttestationAsync<TPredicate>(
|
||||
DeltaAttestationRequestBase request,
|
||||
TPredicate predicate,
|
||||
string predicateType,
|
||||
CancellationToken cancellationToken)
|
||||
where TPredicate : class
|
||||
{
|
||||
try
|
||||
{
|
||||
// Build in-toto statement
|
||||
var statement = BuildInTotoStatement(request, predicate, predicateType);
|
||||
var statementJson = JsonSerializer.Serialize(statement, JsonOptions);
|
||||
var payloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(statementJson));
|
||||
|
||||
// Compute envelope digest
|
||||
var envelopeDigest = ComputeSha256(statementJson);
|
||||
|
||||
// Build signing request
|
||||
var signRequest = new AttestationSignRequest
|
||||
{
|
||||
KeyId = request.KeyId ?? _options.DefaultKeyId ?? string.Empty,
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
PayloadBase64 = payloadBase64,
|
||||
Mode = request.UseKeyless ? "keyless" : "local",
|
||||
LogPreference = request.UseTransparencyLog ? "primary" : "none",
|
||||
Archive = true,
|
||||
Artifact = new AttestorSubmissionRequest.ArtifactInfo
|
||||
{
|
||||
Sha256 = envelopeDigest,
|
||||
Kind = "delta-attestation",
|
||||
SubjectUri = $"urn:stellaops:lineage:{request.FromDigest}..{request.ToDigest}"
|
||||
}
|
||||
};
|
||||
|
||||
// Create submission context
|
||||
var context = new SubmissionContext
|
||||
{
|
||||
CallerSubject = $"tenant:{request.TenantId}",
|
||||
CallerAudience = "stellaops-attestor",
|
||||
CallerClientId = "delta-attestation-service",
|
||||
CallerTenant = request.TenantId
|
||||
};
|
||||
|
||||
// Sign the attestation
|
||||
var signResult = await _signingService.SignAsync(signRequest, context, cancellationToken);
|
||||
|
||||
// Extract transparency log index if present
|
||||
long? logIndex = null;
|
||||
if (signResult.Bundle?.Dsse?.Signatures?.Count > 0)
|
||||
{
|
||||
// Log index would typically be returned in a separate field after Rekor submission
|
||||
_logger.LogDebug("Attestation signed with mode {Mode}", signResult.Mode);
|
||||
}
|
||||
|
||||
// Build envelope base64 from signed result
|
||||
var envelopeBase64 = signResult.Bundle?.Dsse != null
|
||||
? Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(signResult.Bundle.Dsse, JsonOptions)))
|
||||
: null;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created delta attestation with digest {Digest} for predicate type {PredicateType}",
|
||||
envelopeDigest, predicateType);
|
||||
|
||||
return new DeltaAttestationResult
|
||||
{
|
||||
Success = true,
|
||||
AttestationDigest = envelopeDigest,
|
||||
EnvelopeBase64 = envelopeBase64,
|
||||
TransparencyLogIndex = logIndex,
|
||||
PredicateType = predicateType,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create delta attestation from {FromDigest} to {ToDigest}",
|
||||
request.FromDigest, request.ToDigest);
|
||||
|
||||
return new DeltaAttestationResult
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message,
|
||||
PredicateType = predicateType,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private InTotoStatement<TPredicate> BuildInTotoStatement<TPredicate>(
|
||||
DeltaAttestationRequestBase request,
|
||||
TPredicate predicate,
|
||||
string predicateType)
|
||||
where TPredicate : class
|
||||
{
|
||||
var subjects = new List<InTotoSubject>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = $"lineage:{request.FromDigest}..{request.ToDigest}",
|
||||
Digest = new Dictionary<string, string>
|
||||
{
|
||||
["sha256_from"] = ExtractHash(request.FromDigest),
|
||||
["sha256_to"] = ExtractHash(request.ToDigest)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add annotations if provided
|
||||
if (request.Annotations?.Count > 0)
|
||||
{
|
||||
foreach (var (key, value) in request.Annotations)
|
||||
{
|
||||
subjects[0].Digest[$"annotation:{key}"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return new InTotoStatement<TPredicate>
|
||||
{
|
||||
Type = "https://in-toto.io/Statement/v1",
|
||||
Subject = subjects,
|
||||
PredicateType = predicateType,
|
||||
Predicate = predicate
|
||||
};
|
||||
}
|
||||
|
||||
private static string ExtractHash(string digest)
|
||||
{
|
||||
// Remove algorithm prefix if present (e.g., "sha256:abc123" -> "abc123")
|
||||
var colonIndex = digest.IndexOf(':');
|
||||
return colonIndex >= 0 ? digest[(colonIndex + 1)..] : digest;
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string content)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement structure.
|
||||
/// </summary>
|
||||
public sealed class InTotoStatement<TPredicate>
|
||||
where TPredicate : class
|
||||
{
|
||||
[JsonPropertyName("_type")]
|
||||
public string Type { get; set; } = "https://in-toto.io/Statement/v1";
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public IList<InTotoSubject> Subject { get; set; } = new List<InTotoSubject>();
|
||||
|
||||
[JsonPropertyName("predicateType")]
|
||||
public string PredicateType { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("predicate")]
|
||||
public TPredicate? Predicate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject entry for in-toto statements.
|
||||
/// </summary>
|
||||
public sealed class InTotoSubject
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public IDictionary<string, string> Digest { get; set; } = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for delta attestation service.
|
||||
/// </summary>
|
||||
public sealed class DeltaAttestationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Default key ID to use for signing when not specified in request.
|
||||
/// </summary>
|
||||
public string? DefaultKeyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to use keyless signing by default.
|
||||
/// </summary>
|
||||
public bool DefaultUseKeyless { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to publish to transparency log by default.
|
||||
/// </summary>
|
||||
public bool DefaultUseTransparencyLog { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IDeltaAttestationService.cs
|
||||
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-025)
|
||||
// Task: Implement IDeltaVerdictAttestationService
|
||||
// Description: Service interface for creating delta attestations for lineage changes.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Attestor.Core.Signing;
|
||||
using StellaOps.Signer.Core.Predicates;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Delta;
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating DSSE-signed attestations for delta changes between lineage versions.
|
||||
/// Generates in-toto statements with delta predicates and signs them using the configured signer.
|
||||
/// </summary>
|
||||
public interface IDeltaAttestationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a VEX delta attestation.
|
||||
/// </summary>
|
||||
/// <param name="request">The delta attestation request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The signed attestation result.</returns>
|
||||
Task<DeltaAttestationResult> CreateVexDeltaAttestationAsync(
|
||||
VexDeltaAttestationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an SBOM delta attestation.
|
||||
/// </summary>
|
||||
/// <param name="request">The delta attestation request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The signed attestation result.</returns>
|
||||
Task<DeltaAttestationResult> CreateSbomDeltaAttestationAsync(
|
||||
SbomDeltaAttestationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a verdict delta attestation.
|
||||
/// </summary>
|
||||
/// <param name="request">The delta attestation request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The signed attestation result.</returns>
|
||||
Task<DeltaAttestationResult> CreateVerdictDeltaAttestationAsync(
|
||||
VerdictDeltaAttestationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a reachability delta attestation.
|
||||
/// </summary>
|
||||
/// <param name="request">The delta attestation request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The signed attestation result.</returns>
|
||||
Task<DeltaAttestationResult> CreateReachabilityDeltaAttestationAsync(
|
||||
ReachabilityDeltaAttestationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base request for delta attestations.
|
||||
/// </summary>
|
||||
public abstract record DeltaAttestationRequestBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Digest of the source artifact.
|
||||
/// </summary>
|
||||
public required string FromDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the target artifact.
|
||||
/// </summary>
|
||||
public required string ToDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID for signing. If null, uses default.
|
||||
/// </summary>
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to use keyless signing (Sigstore Fulcio).
|
||||
/// </summary>
|
||||
public bool UseKeyless { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to record in transparency log (Rekor).
|
||||
/// </summary>
|
||||
public bool UseTransparencyLog { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Additional annotations to include.
|
||||
/// </summary>
|
||||
public IDictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a VEX delta attestation.
|
||||
/// </summary>
|
||||
public sealed record VexDeltaAttestationRequest : DeltaAttestationRequestBase
|
||||
{
|
||||
/// <summary>
|
||||
/// The VEX delta predicate to attest.
|
||||
/// </summary>
|
||||
public required VexDeltaPredicate Delta { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create an SBOM delta attestation.
|
||||
/// </summary>
|
||||
public sealed record SbomDeltaAttestationRequest : DeltaAttestationRequestBase
|
||||
{
|
||||
/// <summary>
|
||||
/// The SBOM delta predicate to attest.
|
||||
/// </summary>
|
||||
public required SbomDeltaPredicate Delta { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a verdict delta attestation.
|
||||
/// </summary>
|
||||
public sealed record VerdictDeltaAttestationRequest : DeltaAttestationRequestBase
|
||||
{
|
||||
/// <summary>
|
||||
/// The verdict delta predicate to attest.
|
||||
/// </summary>
|
||||
public required VerdictDeltaPredicate Delta { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a reachability delta attestation.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityDeltaAttestationRequest : DeltaAttestationRequestBase
|
||||
{
|
||||
/// <summary>
|
||||
/// The reachability delta predicate to attest.
|
||||
/// </summary>
|
||||
public required ReachabilityDeltaPredicate Delta { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of creating a delta attestation.
|
||||
/// </summary>
|
||||
public sealed record DeltaAttestationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the attestation was successfully created and signed.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the signed attestation envelope.
|
||||
/// </summary>
|
||||
public string? AttestationDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded DSSE envelope.
|
||||
/// </summary>
|
||||
public string? EnvelopeBase64 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Transparency log entry index (if published to Rekor).
|
||||
/// </summary>
|
||||
public long? TransparencyLogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Predicate type used.
|
||||
/// </summary>
|
||||
public string? PredicateType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp of attestation creation.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "stella.ops/sbom-delta@v1",
|
||||
"title": "SBOM Delta Predicate",
|
||||
"description": "Schema for SBOM delta attestations between artifact versions",
|
||||
"type": "object",
|
||||
"required": ["fromDigest", "toDigest", "fromSbomDigest", "toSbomDigest", "tenantId", "summary", "comparedAt"],
|
||||
"properties": {
|
||||
"fromDigest": {
|
||||
"type": "string",
|
||||
"description": "Digest of the source (baseline) artifact",
|
||||
"pattern": "^(sha256|sha384|sha512|blake3):[a-fA-F0-9]+$"
|
||||
},
|
||||
"toDigest": {
|
||||
"type": "string",
|
||||
"description": "Digest of the target (current) artifact",
|
||||
"pattern": "^(sha256|sha384|sha512|blake3):[a-fA-F0-9]+$"
|
||||
},
|
||||
"fromSbomDigest": {
|
||||
"type": "string",
|
||||
"description": "Digest of the source SBOM"
|
||||
},
|
||||
"toSbomDigest": {
|
||||
"type": "string",
|
||||
"description": "Digest of the target SBOM"
|
||||
},
|
||||
"tenantId": {
|
||||
"type": "string",
|
||||
"description": "Tenant identifier"
|
||||
},
|
||||
"added": {
|
||||
"type": "array",
|
||||
"description": "Components added in the target SBOM",
|
||||
"items": { "$ref": "#/$defs/component" }
|
||||
},
|
||||
"removed": {
|
||||
"type": "array",
|
||||
"description": "Components removed from the baseline SBOM",
|
||||
"items": { "$ref": "#/$defs/component" }
|
||||
},
|
||||
"versionChanged": {
|
||||
"type": "array",
|
||||
"description": "Components that changed version between SBOMs",
|
||||
"items": { "$ref": "#/$defs/versionChange" }
|
||||
},
|
||||
"summary": {
|
||||
"$ref": "#/$defs/summary"
|
||||
},
|
||||
"comparedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "When the comparison was performed"
|
||||
},
|
||||
"algorithmVersion": {
|
||||
"type": "string",
|
||||
"description": "Version of the delta computation algorithm"
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"component": {
|
||||
"type": "object",
|
||||
"required": ["purl", "name", "version"],
|
||||
"properties": {
|
||||
"purl": { "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"version": { "type": "string" },
|
||||
"type": { "type": "string" },
|
||||
"ecosystem": { "type": "string" },
|
||||
"knownVulnerabilities": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"licenses": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"versionChange": {
|
||||
"type": "object",
|
||||
"required": ["purl", "name", "previousVersion", "currentVersion", "changeType"],
|
||||
"properties": {
|
||||
"purl": { "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"previousVersion": { "type": "string" },
|
||||
"currentVersion": { "type": "string" },
|
||||
"changeType": {
|
||||
"type": "string",
|
||||
"enum": ["major", "minor", "patch", "unknown"]
|
||||
},
|
||||
"vulnerabilitiesFixed": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"vulnerabilitiesIntroduced": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
"type": "object",
|
||||
"required": ["addedCount", "removedCount", "versionChangedCount", "unchangedCount", "fromTotalCount", "toTotalCount", "vulnerabilitiesFixedCount", "vulnerabilitiesIntroducedCount"],
|
||||
"properties": {
|
||||
"addedCount": { "type": "integer", "minimum": 0 },
|
||||
"removedCount": { "type": "integer", "minimum": 0 },
|
||||
"versionChangedCount": { "type": "integer", "minimum": 0 },
|
||||
"unchangedCount": { "type": "integer", "minimum": 0 },
|
||||
"fromTotalCount": { "type": "integer", "minimum": 0 },
|
||||
"toTotalCount": { "type": "integer", "minimum": 0 },
|
||||
"vulnerabilitiesFixedCount": { "type": "integer", "minimum": 0 },
|
||||
"vulnerabilitiesIntroducedCount": { "type": "integer", "minimum": 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "stella.ops/verdict-delta@v1",
|
||||
"title": "Verdict Delta Predicate",
|
||||
"description": "Schema for policy verdict delta attestations between artifact versions",
|
||||
"type": "object",
|
||||
"required": ["fromDigest", "toDigest", "tenantId", "fromPolicyVersion", "toPolicyVersion", "fromVerdict", "toVerdict", "summary", "comparedAt"],
|
||||
"properties": {
|
||||
"fromDigest": {
|
||||
"type": "string",
|
||||
"description": "Digest of the source (baseline) artifact",
|
||||
"pattern": "^(sha256|sha384|sha512|blake3):[a-fA-F0-9]+$"
|
||||
},
|
||||
"toDigest": {
|
||||
"type": "string",
|
||||
"description": "Digest of the target (current) artifact",
|
||||
"pattern": "^(sha256|sha384|sha512|blake3):[a-fA-F0-9]+$"
|
||||
},
|
||||
"tenantId": {
|
||||
"type": "string",
|
||||
"description": "Tenant identifier"
|
||||
},
|
||||
"fromPolicyVersion": {
|
||||
"type": "string",
|
||||
"description": "Policy pack version used for baseline evaluation"
|
||||
},
|
||||
"toPolicyVersion": {
|
||||
"type": "string",
|
||||
"description": "Policy pack version used for target evaluation"
|
||||
},
|
||||
"fromVerdict": {
|
||||
"$ref": "#/$defs/verdictSummary"
|
||||
},
|
||||
"toVerdict": {
|
||||
"$ref": "#/$defs/verdictSummary"
|
||||
},
|
||||
"findingChanges": {
|
||||
"type": "array",
|
||||
"description": "Individual finding verdicts that changed",
|
||||
"items": { "$ref": "#/$defs/findingChange" }
|
||||
},
|
||||
"ruleChanges": {
|
||||
"type": "array",
|
||||
"description": "Rule evaluations that changed",
|
||||
"items": { "$ref": "#/$defs/ruleChange" }
|
||||
},
|
||||
"summary": {
|
||||
"$ref": "#/$defs/deltaSummary"
|
||||
},
|
||||
"comparedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "When the comparison was performed"
|
||||
},
|
||||
"algorithmVersion": {
|
||||
"type": "string",
|
||||
"description": "Version of the delta computation algorithm"
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"verdictSummary": {
|
||||
"type": "object",
|
||||
"required": ["outcome", "confidence", "riskScore", "passingRules", "failingRules", "warningRules"],
|
||||
"properties": {
|
||||
"outcome": {
|
||||
"type": "string",
|
||||
"enum": ["pass", "fail", "warn"]
|
||||
},
|
||||
"confidence": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"riskScore": { "type": "number" },
|
||||
"verdictDigest": { "type": "string" },
|
||||
"passingRules": { "type": "integer", "minimum": 0 },
|
||||
"failingRules": { "type": "integer", "minimum": 0 },
|
||||
"warningRules": { "type": "integer", "minimum": 0 }
|
||||
}
|
||||
},
|
||||
"findingChange": {
|
||||
"type": "object",
|
||||
"required": ["vulnerabilityId", "purl", "previousVerdict", "currentVerdict", "changeReason", "riskDirection"],
|
||||
"properties": {
|
||||
"vulnerabilityId": { "type": "string" },
|
||||
"purl": { "type": "string" },
|
||||
"previousVerdict": { "type": "string" },
|
||||
"currentVerdict": { "type": "string" },
|
||||
"changeReason": { "type": "string" },
|
||||
"riskDirection": {
|
||||
"type": "string",
|
||||
"enum": ["increased", "decreased", "neutral"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"ruleChange": {
|
||||
"type": "object",
|
||||
"required": ["ruleId", "ruleName", "previousResult", "currentResult"],
|
||||
"properties": {
|
||||
"ruleId": { "type": "string" },
|
||||
"ruleName": { "type": "string" },
|
||||
"previousResult": {
|
||||
"type": "string",
|
||||
"enum": ["pass", "fail", "warn", "skip"]
|
||||
},
|
||||
"currentResult": {
|
||||
"type": "string",
|
||||
"enum": ["pass", "fail", "warn", "skip"]
|
||||
},
|
||||
"previousMessage": { "type": "string" },
|
||||
"currentMessage": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"deltaSummary": {
|
||||
"type": "object",
|
||||
"required": ["verdictChanged", "riskDirection", "riskScoreDelta", "confidenceDelta", "findingsImproved", "findingsWorsened", "findingsNew", "findingsResolved", "rulesChanged"],
|
||||
"properties": {
|
||||
"verdictChanged": { "type": "boolean" },
|
||||
"riskDirection": {
|
||||
"type": "string",
|
||||
"enum": ["increased", "decreased", "neutral"]
|
||||
},
|
||||
"riskScoreDelta": { "type": "number" },
|
||||
"confidenceDelta": { "type": "number" },
|
||||
"findingsImproved": { "type": "integer", "minimum": 0 },
|
||||
"findingsWorsened": { "type": "integer", "minimum": 0 },
|
||||
"findingsNew": { "type": "integer", "minimum": 0 },
|
||||
"findingsResolved": { "type": "integer", "minimum": 0 },
|
||||
"rulesChanged": { "type": "integer", "minimum": 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "stella.ops/vex-delta@v1",
|
||||
"title": "VEX Delta Predicate",
|
||||
"description": "Schema for VEX delta attestations between artifact versions",
|
||||
"type": "object",
|
||||
"required": ["fromDigest", "toDigest", "tenantId", "summary", "comparedAt"],
|
||||
"properties": {
|
||||
"fromDigest": {
|
||||
"type": "string",
|
||||
"description": "Digest of the source (baseline) artifact",
|
||||
"pattern": "^(sha256|sha384|sha512|blake3):[a-fA-F0-9]+$"
|
||||
},
|
||||
"toDigest": {
|
||||
"type": "string",
|
||||
"description": "Digest of the target (current) artifact",
|
||||
"pattern": "^(sha256|sha384|sha512|blake3):[a-fA-F0-9]+$"
|
||||
},
|
||||
"tenantId": {
|
||||
"type": "string",
|
||||
"description": "Tenant identifier"
|
||||
},
|
||||
"added": {
|
||||
"type": "array",
|
||||
"description": "VEX statements added in the target artifact",
|
||||
"items": { "$ref": "#/$defs/vexStatement" }
|
||||
},
|
||||
"removed": {
|
||||
"type": "array",
|
||||
"description": "VEX statements removed from the baseline artifact",
|
||||
"items": { "$ref": "#/$defs/vexStatement" }
|
||||
},
|
||||
"changed": {
|
||||
"type": "array",
|
||||
"description": "VEX statements that changed status between versions",
|
||||
"items": { "$ref": "#/$defs/vexChange" }
|
||||
},
|
||||
"summary": {
|
||||
"$ref": "#/$defs/summary"
|
||||
},
|
||||
"comparedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "When the comparison was performed"
|
||||
},
|
||||
"algorithmVersion": {
|
||||
"type": "string",
|
||||
"description": "Version of the delta computation algorithm"
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"vexStatement": {
|
||||
"type": "object",
|
||||
"required": ["vulnerabilityId", "productId", "status"],
|
||||
"properties": {
|
||||
"vulnerabilityId": { "type": "string" },
|
||||
"productId": { "type": "string" },
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["not_affected", "affected", "fixed", "under_investigation"]
|
||||
},
|
||||
"justification": { "type": "string" },
|
||||
"issuer": { "type": "string" },
|
||||
"timestamp": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
},
|
||||
"vexChange": {
|
||||
"type": "object",
|
||||
"required": ["vulnerabilityId", "productId", "previousStatus", "currentStatus", "riskDirection"],
|
||||
"properties": {
|
||||
"vulnerabilityId": { "type": "string" },
|
||||
"productId": { "type": "string" },
|
||||
"previousStatus": { "type": "string" },
|
||||
"currentStatus": { "type": "string" },
|
||||
"previousJustification": { "type": "string" },
|
||||
"currentJustification": { "type": "string" },
|
||||
"riskDirection": {
|
||||
"type": "string",
|
||||
"enum": ["increased", "decreased", "neutral"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
"type": "object",
|
||||
"required": ["addedCount", "removedCount", "changedCount", "unchangedCount", "netRiskDirection"],
|
||||
"properties": {
|
||||
"addedCount": { "type": "integer", "minimum": 0 },
|
||||
"removedCount": { "type": "integer", "minimum": 0 },
|
||||
"changedCount": { "type": "integer", "minimum": 0 },
|
||||
"unchangedCount": { "type": "integer", "minimum": 0 },
|
||||
"netRiskDirection": {
|
||||
"type": "string",
|
||||
"enum": ["increased", "decreased", "neutral"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,16 @@
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="JsonSchema.Net" Version="7.3.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="JsonSchema.Net" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Schemas\*.json" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography.Kms\StellaOps.Cryptography.Kms.csproj" />
|
||||
<ProjectReference Include="..\..\..\Signer\StellaOps.Signer\StellaOps.Signer.Core\StellaOps.Signer.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -114,13 +114,13 @@ public sealed class PredicateSchemaValidator : IPredicateSchemaValidator
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
if (results.HasErrors)
|
||||
if (!results.IsValid && results.Details is not null)
|
||||
{
|
||||
foreach (var detail in results.Details)
|
||||
foreach (var detail in results.Details!)
|
||||
{
|
||||
if (detail.HasErrors && detail.Errors is not null)
|
||||
if (!detail.IsValid && detail.Errors is not null)
|
||||
{
|
||||
foreach (var error in detail.Errors)
|
||||
foreach (var error in detail.Errors!)
|
||||
{
|
||||
var errorMsg = error.Value ?? "Unknown error";
|
||||
var location = detail.InstanceLocation.ToString();
|
||||
@@ -148,7 +148,11 @@ public sealed class PredicateSchemaValidator : IPredicateSchemaValidator
|
||||
("reachability@v1", "reachability.v1.schema.json"),
|
||||
("boundary@v1", "boundary.v1.schema.json"),
|
||||
("policy-decision@v1", "policy-decision.v1.schema.json"),
|
||||
("human-approval@v1", "human-approval.v1.schema.json")
|
||||
("human-approval@v1", "human-approval.v1.schema.json"),
|
||||
// Delta predicate schemas (Sprint 20251228_007 LIN-BE-024)
|
||||
("vex-delta@v1", "vex-delta.v1.schema.json"),
|
||||
("sbom-delta@v1", "sbom-delta.v1.schema.json"),
|
||||
("verdict-delta@v1", "verdict-delta.v1.schema.json")
|
||||
};
|
||||
|
||||
foreach (var (key, fileName) in schemaFiles)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Archived Pre-1.0 Migrations
|
||||
|
||||
This directory contains the original migrations that were compacted into `001_initial_schema.sql`
|
||||
in the `StellaOps.Attestor.Persistence` project for the 1.0.0 release.
|
||||
|
||||
## Original Files
|
||||
- `20251216_001_create_rekor_submission_queue.sql` - Rekor submission queue for durable retry
|
||||
|
||||
## Why Archived
|
||||
Pre-1.0, the schema evolved incrementally. For 1.0.0, migrations were compacted into a single
|
||||
initial schema (in `StellaOps.Attestor.Persistence`) to:
|
||||
- Simplify new deployments
|
||||
- Reduce startup time
|
||||
- Provide cleaner upgrade path
|
||||
|
||||
## For Existing Deployments
|
||||
If upgrading from pre-1.0, run the reset script directly with psql:
|
||||
```bash
|
||||
psql -h <host> -U <user> -d <db> -f devops/scripts/migrations-reset-pre-1.0.sql
|
||||
```
|
||||
This updates `schema_migrations` to recognize the compacted schema.
|
||||
@@ -1,3 +1,5 @@
|
||||
#pragma warning disable CS0618 // FallbackCredentialsFactory is obsolete - transitioning to DefaultAWSCredentialsIdentityResolver
|
||||
|
||||
using System;
|
||||
using Amazon.Runtime;
|
||||
using Amazon.S3;
|
||||
|
||||
@@ -13,17 +13,17 @@
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography.Plugin.BouncyCastle\StellaOps.Cryptography.Plugin.BouncyCastle.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography.Kms\StellaOps.Cryptography.Kms.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography.Plugin.SmSoft\StellaOps.Cryptography.Plugin.SmSoft.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj" />
|
||||
<ProjectReference Include="..\..\..\Router/__Libraries/StellaOps.Messaging\StellaOps.Messaging.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.37" />
|
||||
<PackageReference Include="AWSSDK.S3" Version="4.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
<PackageReference Include="StackExchange.Redis" />
|
||||
<PackageReference Include="AWSSDK.S3" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Verify\StellaOps.Attestor.Verify.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography.Plugin.BouncyCastle\StellaOps.Cryptography.Plugin.BouncyCastle.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography.Kms\StellaOps.Cryptography.Kms.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography.Plugin.SmSoft\StellaOps.Cryptography.Plugin.SmSoft.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.37" />
|
||||
<PackageReference Include="AWSSDK.S3" Version="4.0.2" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -33,6 +33,7 @@ using StellaOps.Attestor.Core.Transparency;
|
||||
using StellaOps.Attestor.Core.Bulk;
|
||||
using StellaOps.Attestor.WebService;
|
||||
using StellaOps.Attestor.Tests.Support;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
@@ -66,7 +67,6 @@ public sealed class AttestationBundleEndpointsTests
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var repository = scope.ServiceProvider.GetRequiredService<IAttestorEntryRepository>();
|
||||
using StellaOps.TestKit;
|
||||
var archiveStore = scope.ServiceProvider.GetRequiredService<IAttestorArchiveStore>();
|
||||
|
||||
var entry = new AttestorEntry
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
@@ -256,7 +256,6 @@ public sealed class AttestorSigningServiceTests : IDisposable
|
||||
|
||||
using var metrics = new AttestorMetrics();
|
||||
using var registry = new AttestorSigningKeyRegistry(options, TimeProvider.System, NullLogger<AttestorSigningKeyRegistry>.Instance);
|
||||
using StellaOps.TestKit;
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var service = new AttestorSigningService(
|
||||
registry,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
@@ -277,7 +277,6 @@ public sealed class AttestorSubmissionServiceTests
|
||||
var logger = new NullLogger<AttestorSubmissionService>();
|
||||
using var metrics = new AttestorMetrics();
|
||||
|
||||
using StellaOps.TestKit;
|
||||
var service = new AttestorSubmissionService(
|
||||
validator,
|
||||
repository,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
@@ -700,7 +700,6 @@ public sealed class AttestorVerificationServiceTests
|
||||
private static byte[] ComputeMerkleNode(byte[] left, byte[] right)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
using StellaOps.TestKit;
|
||||
var buffer = new byte[1 + left.Length + right.Length];
|
||||
buffer[0] = 0x01;
|
||||
Buffer.BlockCopy(left, 0, buffer, 1, left.Length);
|
||||
|
||||
@@ -12,7 +12,6 @@ using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Tests.Auth;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@@ -24,7 +24,6 @@ public sealed class BulkVerificationWorkerTests
|
||||
var jobStore = new InMemoryBulkVerificationJobStore();
|
||||
var verificationService = new StubVerificationService();
|
||||
using var metrics = new AttestorMetrics();
|
||||
using StellaOps.TestKit;
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
BulkVerification = new AttestorOptions.BulkVerificationOptions
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@@ -86,7 +86,6 @@ public sealed class CachedAttestorVerificationServiceTests
|
||||
var options = Options.Create(new AttestorOptions());
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
using var metrics = new AttestorMetrics();
|
||||
using StellaOps.TestKit;
|
||||
var cache = new InMemoryAttestorVerificationCache(memoryCache, options, new NullLogger<InMemoryAttestorVerificationCache>());
|
||||
var inner = new StubVerificationService();
|
||||
var service = new CachedAttestorVerificationService(
|
||||
|
||||
@@ -13,7 +13,6 @@ using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Tests.Contract;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
@@ -136,7 +136,6 @@ public sealed class HttpTransparencyWitnessClientTests
|
||||
using var metrics = new AttestorMetrics();
|
||||
using var activitySource = new AttestorActivitySource();
|
||||
|
||||
using StellaOps.TestKit;
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
TransparencyWitness = new AttestorOptions.TransparencyWitnessOptions
|
||||
|
||||
@@ -150,7 +150,7 @@ public class PostgresRekorSubmissionQueueIntegrationTests : IAsyncLifetime
|
||||
|
||||
// Assert
|
||||
var count = await GetQueueCountAsync();
|
||||
count.Should().BeGreaterOrEqualTo(5);
|
||||
count.Should().BeGreaterThanOrEqualTo(5);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -168,7 +168,7 @@ public class PostgresRekorSubmissionQueueIntegrationTests : IAsyncLifetime
|
||||
var items = await _queue.DequeueAsync(10);
|
||||
|
||||
// Assert
|
||||
items.Should().HaveCountGreaterOrEqualTo(2);
|
||||
items.Should().HaveCountGreaterThanOrEqualTo(2);
|
||||
items.Should().OnlyContain(i => i.Status == RekorSubmissionStatus.Submitting);
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ public class PostgresRekorSubmissionQueueIntegrationTests : IAsyncLifetime
|
||||
var items = await _queue.DequeueAsync(3);
|
||||
|
||||
// Assert
|
||||
items.Should().HaveCountLessOrEqualTo(3);
|
||||
items.Should().HaveCountLessThanOrEqualTo(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -213,7 +213,7 @@ public class PostgresRekorSubmissionQueueIntegrationTests : IAsyncLifetime
|
||||
|
||||
// Assert - Item should only appear in one result
|
||||
var allItems = results.SelectMany(r => r).Where(i => i.BundleSha256 == uniqueBundle).ToList();
|
||||
allItems.Should().HaveCountLessOrEqualTo(1);
|
||||
allItems.Should().HaveCountLessThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -295,7 +295,7 @@ public class PostgresRekorSubmissionQueueIntegrationTests : IAsyncLifetime
|
||||
var newDepth = await _queue.GetQueueDepthAsync();
|
||||
|
||||
// Assert
|
||||
newDepth.Should().BeGreaterOrEqualTo(baseDepth + 2);
|
||||
newDepth.Should().BeGreaterThanOrEqualTo(baseDepth + 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -317,7 +317,7 @@ public class PostgresRekorSubmissionQueueIntegrationTests : IAsyncLifetime
|
||||
var dlqCount = await queue.GetDeadLetterCountAsync();
|
||||
|
||||
// Assert
|
||||
dlqCount.Should().BeGreaterOrEqualTo(1);
|
||||
dlqCount.Should().BeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -13,7 +13,6 @@ using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Tests.Negative;
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Tests.Observability;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Text;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using Xunit;
|
||||
@@ -309,7 +309,6 @@ public sealed class RekorInclusionVerificationIntegrationTests
|
||||
private static byte[] ComputeInteriorHash(byte[] left, byte[] right)
|
||||
{
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
using StellaOps.TestKit;
|
||||
var combined = new byte[1 + left.Length + right.Length];
|
||||
combined[0] = 0x01; // Interior node prefix
|
||||
left.CopyTo(combined, 1);
|
||||
|
||||
@@ -5,20 +5,26 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="NSubstitute" Version="5.1.0" />
|
||||
<PackageReference Include="Testcontainers" Version="4.3.0" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" Version="4.3.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<Using Include="Xunit.Abstractions" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="Testcontainers" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" >
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" >
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Attestor.WebService\StellaOps.Attestor.WebService.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Infrastructure\StellaOps.Attestor.Infrastructure.csproj" />
|
||||
@@ -29,4 +35,4 @@
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography.Plugin.SmSoft\StellaOps.Cryptography.Plugin.SmSoft.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -97,7 +97,7 @@ public sealed class ProofChainController : ControllerBase
|
||||
|
||||
var chain = await _queryService.GetProofChainAsync(subjectDigest, depth, cancellationToken);
|
||||
|
||||
if (chain is null || chain.Nodes.Count == 0)
|
||||
if (chain is null || chain.Nodes.Length == 0)
|
||||
{
|
||||
return NotFound(new { error = $"No proof chain found for subject {subjectDigest}" });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"StellaOps.Attestor.WebService": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:62507;http://localhost:62508"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,11 @@ public sealed class PredicateTypeRouter : IPredicateTypeRouter
|
||||
"https://stella-ops.org/predicates/reachability-subgraph/v1",
|
||||
"https://stella-ops.org/predicates/delta-verdict/v1",
|
||||
"https://stella-ops.org/predicates/policy-decision/v1",
|
||||
"https://stella-ops.org/predicates/unknowns-budget/v1"
|
||||
"https://stella-ops.org/predicates/unknowns-budget/v1",
|
||||
// Delta predicate types for lineage comparison (Sprint 20251228_007)
|
||||
"stella.ops/vex-delta@v1",
|
||||
"stella.ops/sbom-delta@v1",
|
||||
"stella.ops/verdict-delta@v1"
|
||||
};
|
||||
|
||||
public PredicateTypeRouter(
|
||||
@@ -169,7 +173,7 @@ public sealed class PredicateTypeRouter : IPredicateTypeRouter
|
||||
Metadata = new PredicateMetadata
|
||||
{
|
||||
Format = parseResult.Metadata.Format,
|
||||
Version = parseResult.Metadata.Version,
|
||||
Version = parseResult.Metadata.Version ?? "unknown",
|
||||
Properties = parseResult.Metadata.Properties.ToImmutableDictionary()
|
||||
},
|
||||
Sbom = sbom,
|
||||
|
||||
@@ -37,18 +37,17 @@ public sealed class ProofChainQueryService : IProofChainQueryService
|
||||
// Query attestor entries by artifact sha256
|
||||
var query = new AttestorEntryQuery
|
||||
{
|
||||
ArtifactSha256 = NormalizeDigest(subjectDigest),
|
||||
PageSize = 100,
|
||||
SortBy = "CreatedAt",
|
||||
SortDirection = "Descending"
|
||||
Subject = NormalizeDigest(subjectDigest),
|
||||
PageSize = 100
|
||||
};
|
||||
|
||||
var entries = await _entryRepository.QueryAsync(query, cancellationToken);
|
||||
|
||||
var proofs = entries.Items
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.Select(entry => new ProofSummary
|
||||
{
|
||||
ProofId = entry.RekorUuid ?? entry.Id.ToString(),
|
||||
ProofId = entry.RekorUuid,
|
||||
Type = DetermineProofType(entry.Artifact.Kind),
|
||||
Digest = entry.BundleSha256,
|
||||
CreatedAt = entry.CreatedAt,
|
||||
@@ -154,7 +153,7 @@ public sealed class ProofChainQueryService : IProofChainQueryService
|
||||
|
||||
var detail = new ProofDetail
|
||||
{
|
||||
ProofId = entry.RekorUuid ?? entry.Id.ToString(),
|
||||
ProofId = entry.RekorUuid,
|
||||
Type = DetermineProofType(entry.Artifact.Kind),
|
||||
Digest = entry.BundleSha256,
|
||||
CreatedAt = entry.CreatedAt,
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.37" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
|
||||
<PackageReference Include="Serilog.AspNetCore" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" />
|
||||
<PackageReference Include="StackExchange.Redis" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
|
||||
@@ -27,7 +27,7 @@
|
||||
<ProjectReference Include="../../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="../../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Attestor.StandardPredicates/StellaOps.Attestor.StandardPredicates.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
|
||||
<ProjectReference Include="../../../Router/__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Bundling\StellaOps.Attestor.Bundling.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Core", "StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj", "{C0FE77EB-933C-4E47-8195-758AB049157A}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Infrastructure", "StellaOps.Attestor.Infrastructure\StellaOps.Attestor.Infrastructure.csproj", "{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.WebService", "StellaOps.Attestor.WebService\StellaOps.Attestor.WebService.csproj", "{B238B098-32B1-4875-99A7-393A63AC3CCF}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Configuration", "..\..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj", "{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{82EFA477-307D-4B47-A4CF-1627F076D60A}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "..\..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{21327A4F-2586-49F8-9D4A-3840DE64C48E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Tests", "StellaOps.Attestor.Tests\StellaOps.Attestor.Tests.csproj", "{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Verify", "..\StellaOps.Attestor.Verify\StellaOps.Attestor.Verify.csproj", "{99EC90D8-0D5E-41E4-A895-585A7680916C}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{C0FE77EB-933C-4E47-8195-758AB049157A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C0FE77EB-933C-4E47-8195-758AB049157A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C0FE77EB-933C-4E47-8195-758AB049157A}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C0FE77EB-933C-4E47-8195-758AB049157A}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C0FE77EB-933C-4E47-8195-758AB049157A}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C0FE77EB-933C-4E47-8195-758AB049157A}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C0FE77EB-933C-4E47-8195-758AB049157A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C0FE77EB-933C-4E47-8195-758AB049157A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C0FE77EB-933C-4E47-8195-758AB049157A}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C0FE77EB-933C-4E47-8195-758AB049157A}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C0FE77EB-933C-4E47-8195-758AB049157A}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C0FE77EB-933C-4E47-8195-758AB049157A}.Release|x86.Build.0 = Release|Any CPU
|
||||
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Release|x64.Build.0 = Release|Any CPU
|
||||
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Release|x86.Build.0 = Release|Any CPU
|
||||
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Release|x64.Build.0 = Release|Any CPU
|
||||
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Release|x86.Build.0 = Release|Any CPU
|
||||
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Release|x64.Build.0 = Release|Any CPU
|
||||
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Release|x86.Build.0 = Release|Any CPU
|
||||
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Release|x64.Build.0 = Release|Any CPU
|
||||
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Release|x86.Build.0 = Release|Any CPU
|
||||
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Release|x64.Build.0 = Release|Any CPU
|
||||
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Release|x86.Build.0 = Release|Any CPU
|
||||
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Release|x64.Build.0 = Release|Any CPU
|
||||
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Release|x86.Build.0 = Release|Any CPU
|
||||
{99EC90D8-0D5E-41E4-A895-585A7680916C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{99EC90D8-0D5E-41E4-A895-585A7680916C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{99EC90D8-0D5E-41E4-A895-585A7680916C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{99EC90D8-0D5E-41E4-A895-585A7680916C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{99EC90D8-0D5E-41E4-A895-585A7680916C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{99EC90D8-0D5E-41E4-A895-585A7680916C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{99EC90D8-0D5E-41E4-A895-585A7680916C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{99EC90D8-0D5E-41E4-A895-585A7680916C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{99EC90D8-0D5E-41E4-A895-585A7680916C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{99EC90D8-0D5E-41E4-A895-585A7680916C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{99EC90D8-0D5E-41E4-A895-585A7680916C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{99EC90D8-0D5E-41E4-A895-585A7680916C}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -9,8 +9,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="BouncyCastle.Cryptography" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="BouncyCastle.Cryptography" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>StellaOps.Attestor.Bundling</RootNamespace>
|
||||
<Description>Attestation bundle aggregation and rotation for long-term verification in air-gapped environments.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Bundle\StellaOps.Attestor.Bundle.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -9,12 +9,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>StellaOps.Attestor.GraphRoot</RootNamespace>
|
||||
<Description>Graph root attestation service for creating and verifying DSSE attestations of Merkle graph roots.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Evidence.Core\StellaOps.Evidence.Core.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,299 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IOciAttestationAttacher.cs
|
||||
// Sprint: SPRINT_20251228_002_BE_oci_attestation_attach (T1)
|
||||
// Task: Create OciAttestationAttacher service interface
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Attestor.Envelope;
|
||||
|
||||
namespace StellaOps.Attestor.Oci.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for attaching and retrieving DSSE attestations from OCI registries.
|
||||
/// Implements OCI Distribution Spec 1.1 referrers API for cosign compatibility.
|
||||
/// </summary>
|
||||
public interface IOciAttestationAttacher
|
||||
{
|
||||
/// <summary>
|
||||
/// Attaches a DSSE attestation to an OCI artifact.
|
||||
/// </summary>
|
||||
/// <param name="imageRef">Reference to the OCI artifact.</param>
|
||||
/// <param name="attestation">DSSE envelope containing the attestation.</param>
|
||||
/// <param name="options">Attachment options.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Result of the attachment operation.</returns>
|
||||
Task<AttachmentResult> AttachAsync(
|
||||
OciReference imageRef,
|
||||
DsseEnvelope attestation,
|
||||
AttachmentOptions? options = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all attestations attached to an OCI artifact.
|
||||
/// </summary>
|
||||
/// <param name="imageRef">Reference to the OCI artifact.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of attached attestations.</returns>
|
||||
Task<IReadOnlyList<AttachedAttestation>> ListAsync(
|
||||
OciReference imageRef,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a specific attestation by predicate type.
|
||||
/// </summary>
|
||||
/// <param name="imageRef">Reference to the OCI artifact.</param>
|
||||
/// <param name="predicateType">Predicate type URI to filter by.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The DSSE envelope if found, null otherwise.</returns>
|
||||
Task<DsseEnvelope?> FetchAsync(
|
||||
OciReference imageRef,
|
||||
string predicateType,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes an attestation from an OCI artifact.
|
||||
/// </summary>
|
||||
/// <param name="imageRef">Reference to the OCI artifact.</param>
|
||||
/// <param name="attestationDigest">Digest of the attestation to remove.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>True if removed, false if not found.</returns>
|
||||
Task<bool> RemoveAsync(
|
||||
OciReference imageRef,
|
||||
string attestationDigest,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to an OCI artifact.
|
||||
/// </summary>
|
||||
public sealed record OciReference
|
||||
{
|
||||
/// <summary>
|
||||
/// Registry hostname (e.g., "registry.example.com").
|
||||
/// </summary>
|
||||
public required string Registry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Repository name (e.g., "myorg/myapp").
|
||||
/// </summary>
|
||||
public required string Repository { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressable digest (e.g., "sha256:abc123...").
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional tag (e.g., "v1.0.0").
|
||||
/// </summary>
|
||||
public string? Tag { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the full reference string.
|
||||
/// </summary>
|
||||
public string FullReference => Tag is not null
|
||||
? $"{Registry}/{Repository}:{Tag}"
|
||||
: $"{Registry}/{Repository}@{Digest}";
|
||||
|
||||
/// <summary>
|
||||
/// Parses an OCI reference string.
|
||||
/// </summary>
|
||||
public static OciReference Parse(string reference)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(reference);
|
||||
|
||||
// Handle digest references: registry/repo@sha256:...
|
||||
var digestIndex = reference.IndexOf('@');
|
||||
if (digestIndex > 0)
|
||||
{
|
||||
var beforeDigest = reference[..digestIndex];
|
||||
var digest = reference[(digestIndex + 1)..];
|
||||
var (registry, repo) = ParseRegistryAndRepo(beforeDigest);
|
||||
return new OciReference
|
||||
{
|
||||
Registry = registry,
|
||||
Repository = repo,
|
||||
Digest = digest
|
||||
};
|
||||
}
|
||||
|
||||
// Handle tag references: registry/repo:tag
|
||||
var tagIndex = reference.LastIndexOf(':');
|
||||
if (tagIndex > 0)
|
||||
{
|
||||
var beforeTag = reference[..tagIndex];
|
||||
var tag = reference[(tagIndex + 1)..];
|
||||
|
||||
// Check if this is actually a port number
|
||||
if (!beforeTag.Contains('/') || tag.Contains('/'))
|
||||
{
|
||||
throw new ArgumentException($"Invalid OCI reference: {reference}", nameof(reference));
|
||||
}
|
||||
|
||||
var (registry, repo) = ParseRegistryAndRepo(beforeTag);
|
||||
return new OciReference
|
||||
{
|
||||
Registry = registry,
|
||||
Repository = repo,
|
||||
Digest = string.Empty, // Will be resolved
|
||||
Tag = tag
|
||||
};
|
||||
}
|
||||
|
||||
throw new ArgumentException($"Invalid OCI reference: {reference}", nameof(reference));
|
||||
}
|
||||
|
||||
private static (string Registry, string Repo) ParseRegistryAndRepo(string reference)
|
||||
{
|
||||
var firstSlash = reference.IndexOf('/');
|
||||
if (firstSlash < 0)
|
||||
{
|
||||
throw new ArgumentException($"Invalid OCI reference: {reference}");
|
||||
}
|
||||
|
||||
var registry = reference[..firstSlash];
|
||||
var repo = reference[(firstSlash + 1)..];
|
||||
|
||||
return (registry, repo);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for attestation attachment.
|
||||
/// </summary>
|
||||
public sealed record AttachmentOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Media type for the attestation. Default: DSSE envelope.
|
||||
/// </summary>
|
||||
public string MediaType { get; init; } = MediaTypes.DsseEnvelope;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to replace existing attestations with the same predicate type.
|
||||
/// </summary>
|
||||
public bool ReplaceExisting { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Additional OCI annotations to attach.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to record in Sigstore Rekor transparency log.
|
||||
/// </summary>
|
||||
public bool RecordInRekor { get; init; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of an attestation attachment operation.
|
||||
/// </summary>
|
||||
public sealed record AttachmentResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Content-addressable digest of the attestation blob.
|
||||
/// </summary>
|
||||
public required string AttestationDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Full OCI reference to the attached attestation manifest.
|
||||
/// </summary>
|
||||
public required string AttestationRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when attachment completed.
|
||||
/// </summary>
|
||||
public required DateTimeOffset AttachedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log entry ID if recorded in transparency log.
|
||||
/// </summary>
|
||||
public string? RekorLogId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about an attestation attached to an OCI artifact.
|
||||
/// </summary>
|
||||
public sealed record AttachedAttestation
|
||||
{
|
||||
/// <summary>
|
||||
/// Content-addressable digest of the attestation.
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Predicate type URI (e.g., "https://in-toto.io/attestation/vulns/v0.1").
|
||||
/// </summary>
|
||||
public required string PredicateType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when attestation was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// OCI annotations on the attestation manifest.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size of the attestation blob in bytes.
|
||||
/// </summary>
|
||||
public long Size { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Standard media types for attestation artifacts.
|
||||
/// </summary>
|
||||
public static class MediaTypes
|
||||
{
|
||||
/// <summary>
|
||||
/// DSSE envelope media type.
|
||||
/// </summary>
|
||||
public const string DsseEnvelope = "application/vnd.dsse.envelope.v1+json";
|
||||
|
||||
/// <summary>
|
||||
/// In-toto attestation bundle media type.
|
||||
/// </summary>
|
||||
public const string InTotoBundle = "application/vnd.in-toto+json";
|
||||
|
||||
/// <summary>
|
||||
/// Sigstore bundle media type.
|
||||
/// </summary>
|
||||
public const string SigstoreBundle = "application/vnd.dev.sigstore.bundle.v0.3+json";
|
||||
|
||||
/// <summary>
|
||||
/// OCI image manifest media type.
|
||||
/// </summary>
|
||||
public const string OciManifest = "application/vnd.oci.image.manifest.v1+json";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Standard annotation keys for attestation metadata.
|
||||
/// </summary>
|
||||
public static class AnnotationKeys
|
||||
{
|
||||
/// <summary>
|
||||
/// OCI standard: creation timestamp.
|
||||
/// </summary>
|
||||
public const string Created = "org.opencontainers.image.created";
|
||||
|
||||
/// <summary>
|
||||
/// StellaOps: predicate type.
|
||||
/// </summary>
|
||||
public const string PredicateType = "dev.stellaops/predicate-type";
|
||||
|
||||
/// <summary>
|
||||
/// StellaOps: signer identity.
|
||||
/// </summary>
|
||||
public const string SignerIdentity = "dev.stellaops/signer-identity";
|
||||
|
||||
/// <summary>
|
||||
/// Cosign compatibility: signature placeholder.
|
||||
/// </summary>
|
||||
public const string CosignSignature = "dev.sigstore.cosign/signature";
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log index.
|
||||
/// </summary>
|
||||
public const string RekorLogIndex = "dev.sigstore.rekor/logIndex";
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IOciRegistryClient.cs
|
||||
// Sprint: SPRINT_20251228_002_BE_oci_attestation_attach (T2)
|
||||
// Task: Define OCI registry client interface for ORAS operations
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Attestor.Oci.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for OCI registry operations using OCI Distribution Spec 1.1.
|
||||
/// </summary>
|
||||
public interface IOciRegistryClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Pushes a blob to the registry.
|
||||
/// </summary>
|
||||
/// <param name="registry">Registry hostname.</param>
|
||||
/// <param name="repository">Repository name.</param>
|
||||
/// <param name="content">Blob content.</param>
|
||||
/// <param name="digest">Expected content digest.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task PushBlobAsync(
|
||||
string registry,
|
||||
string repository,
|
||||
ReadOnlyMemory<byte> content,
|
||||
string digest,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a blob from the registry.
|
||||
/// </summary>
|
||||
/// <param name="registry">Registry hostname.</param>
|
||||
/// <param name="repository">Repository name.</param>
|
||||
/// <param name="digest">Blob digest.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Blob content.</returns>
|
||||
Task<ReadOnlyMemory<byte>> FetchBlobAsync(
|
||||
string registry,
|
||||
string repository,
|
||||
string digest,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Pushes a manifest to the registry.
|
||||
/// </summary>
|
||||
/// <param name="registry">Registry hostname.</param>
|
||||
/// <param name="repository">Repository name.</param>
|
||||
/// <param name="manifest">OCI manifest.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Manifest digest.</returns>
|
||||
Task<string> PushManifestAsync(
|
||||
string registry,
|
||||
string repository,
|
||||
OciManifest manifest,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a manifest from the registry.
|
||||
/// </summary>
|
||||
/// <param name="registry">Registry hostname.</param>
|
||||
/// <param name="repository">Repository name.</param>
|
||||
/// <param name="reference">Digest or tag.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>OCI manifest.</returns>
|
||||
Task<OciManifest> FetchManifestAsync(
|
||||
string registry,
|
||||
string repository,
|
||||
string reference,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists referrers to an artifact using OCI Distribution Spec 1.1 referrers API.
|
||||
/// </summary>
|
||||
/// <param name="registry">Registry hostname.</param>
|
||||
/// <param name="repository">Repository name.</param>
|
||||
/// <param name="digest">Subject artifact digest.</param>
|
||||
/// <param name="artifactType">Optional artifact type filter.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of referrer descriptors.</returns>
|
||||
Task<IReadOnlyList<OciDescriptor>> ListReferrersAsync(
|
||||
string registry,
|
||||
string repository,
|
||||
string digest,
|
||||
string? artifactType = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a manifest from the registry.
|
||||
/// </summary>
|
||||
/// <param name="registry">Registry hostname.</param>
|
||||
/// <param name="repository">Repository name.</param>
|
||||
/// <param name="digest">Manifest digest.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>True if deleted, false if not found.</returns>
|
||||
Task<bool> DeleteManifestAsync(
|
||||
string registry,
|
||||
string repository,
|
||||
string digest,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a tag to a digest.
|
||||
/// </summary>
|
||||
/// <param name="registry">Registry hostname.</param>
|
||||
/// <param name="repository">Repository name.</param>
|
||||
/// <param name="tag">Tag to resolve.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Content digest.</returns>
|
||||
Task<string> ResolveTagAsync(
|
||||
string registry,
|
||||
string repository,
|
||||
string tag,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OCI manifest structure per OCI Image Spec.
|
||||
/// </summary>
|
||||
public sealed record OciManifest
|
||||
{
|
||||
/// <summary>
|
||||
/// Schema version (always 2).
|
||||
/// </summary>
|
||||
public int SchemaVersion { get; init; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Media type of this manifest.
|
||||
/// </summary>
|
||||
public string MediaType { get; init; } = MediaTypes.OciManifest;
|
||||
|
||||
/// <summary>
|
||||
/// Optional artifact type for OCI 1.1 artifacts.
|
||||
/// </summary>
|
||||
public string? ArtifactType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Config descriptor.
|
||||
/// </summary>
|
||||
public required OciDescriptor Config { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Layer descriptors.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<OciDescriptor> Layers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject descriptor for OCI 1.1 referrers.
|
||||
/// </summary>
|
||||
public OciDescriptor? Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Annotations on this manifest.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OCI content descriptor.
|
||||
/// </summary>
|
||||
public sealed record OciDescriptor
|
||||
{
|
||||
/// <summary>
|
||||
/// Media type of the referenced content.
|
||||
/// </summary>
|
||||
public required string MediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressable digest.
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size in bytes.
|
||||
/// </summary>
|
||||
public required long Size { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional annotations.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional artifact type (for OCI 1.1).
|
||||
/// </summary>
|
||||
public string? ArtifactType { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// OrasAttestationAttacher.cs
|
||||
// Sprint: SPRINT_20251228_002_BE_oci_attestation_attach (T2)
|
||||
// Task: Implement OCI registry attachment via ORAS
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
|
||||
namespace StellaOps.Attestor.Oci.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of <see cref="IOciAttestationAttacher"/> using OCI Distribution Spec 1.1.
|
||||
/// Stores attestations as OCI artifacts with subject references for cosign compatibility.
|
||||
/// </summary>
|
||||
public sealed class OrasAttestationAttacher : IOciAttestationAttacher
|
||||
{
|
||||
private readonly IOciRegistryClient _registryClient;
|
||||
private readonly ILogger<OrasAttestationAttacher> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public OrasAttestationAttacher(
|
||||
IOciRegistryClient registryClient,
|
||||
ILogger<OrasAttestationAttacher> logger)
|
||||
{
|
||||
_registryClient = registryClient ?? throw new ArgumentNullException(nameof(registryClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<AttachmentResult> AttachAsync(
|
||||
OciReference imageRef,
|
||||
DsseEnvelope attestation,
|
||||
AttachmentOptions? options = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(imageRef);
|
||||
ArgumentNullException.ThrowIfNull(attestation);
|
||||
|
||||
options ??= new AttachmentOptions();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Attaching attestation to {Registry}/{Repository}@{Digest}",
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
TruncateDigest(imageRef.Digest));
|
||||
|
||||
// 1. Serialize DSSE envelope to canonical JSON
|
||||
var attestationBytes = SerializeCanonical(attestation);
|
||||
var attestationDigest = ComputeDigest(attestationBytes);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Attestation serialized: {Size} bytes, digest {Digest}",
|
||||
attestationBytes.Length,
|
||||
TruncateDigest(attestationDigest));
|
||||
|
||||
// 2. Check for existing attestation if ReplaceExisting=false
|
||||
if (!options.ReplaceExisting)
|
||||
{
|
||||
var existing = await FindExistingAttestationAsync(
|
||||
imageRef,
|
||||
attestation.PayloadType,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Attestation with predicate type {PredicateType} already exists at {Digest}",
|
||||
attestation.PayloadType,
|
||||
TruncateDigest(existing.Digest));
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Attestation with predicate type '{attestation.PayloadType}' already exists. " +
|
||||
"Use ReplaceExisting=true to overwrite.");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Push attestation blob
|
||||
await _registryClient.PushBlobAsync(
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
attestationBytes,
|
||||
attestationDigest,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug("Pushed attestation blob {Digest}", TruncateDigest(attestationDigest));
|
||||
|
||||
// 4. Create empty config blob (required by OCI spec)
|
||||
var emptyConfig = "{}"u8.ToArray();
|
||||
var emptyConfigDigest = ComputeDigest(emptyConfig);
|
||||
|
||||
await _registryClient.PushBlobAsync(
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
emptyConfig,
|
||||
emptyConfigDigest,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
// 5. Build manifest with subject reference
|
||||
var annotations = BuildAnnotations(attestation, options);
|
||||
var manifest = new OciManifest
|
||||
{
|
||||
SchemaVersion = 2,
|
||||
MediaType = MediaTypes.OciManifest,
|
||||
ArtifactType = options.MediaType,
|
||||
Subject = new OciDescriptor
|
||||
{
|
||||
MediaType = MediaTypes.OciManifest,
|
||||
Digest = imageRef.Digest,
|
||||
Size = 0 // Referrer doesn't need subject size
|
||||
},
|
||||
Config = new OciDescriptor
|
||||
{
|
||||
MediaType = "application/vnd.oci.empty.v1+json",
|
||||
Digest = emptyConfigDigest,
|
||||
Size = emptyConfig.Length
|
||||
},
|
||||
Layers =
|
||||
[
|
||||
new OciDescriptor
|
||||
{
|
||||
MediaType = options.MediaType,
|
||||
Digest = attestationDigest,
|
||||
Size = attestationBytes.Length,
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
[AnnotationKeys.PredicateType] = attestation.PayloadType
|
||||
}
|
||||
}
|
||||
],
|
||||
Annotations = annotations
|
||||
};
|
||||
|
||||
// 6. Push manifest
|
||||
var manifestDigest = await _registryClient.PushManifestAsync(
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
manifest,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Attached attestation {PredicateType} to {Registry}/{Repository}@{ImageDigest} as {ManifestDigest}",
|
||||
attestation.PayloadType,
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
TruncateDigest(imageRef.Digest),
|
||||
TruncateDigest(manifestDigest));
|
||||
|
||||
return new AttachmentResult
|
||||
{
|
||||
AttestationDigest = attestationDigest,
|
||||
AttestationRef = $"{imageRef.Registry}/{imageRef.Repository}@{manifestDigest}",
|
||||
AttachedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<AttachedAttestation>> ListAsync(
|
||||
OciReference imageRef,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(imageRef);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Listing attestations for {Registry}/{Repository}@{Digest}",
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
TruncateDigest(imageRef.Digest));
|
||||
|
||||
var referrers = await _registryClient.ListReferrersAsync(
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
imageRef.Digest,
|
||||
artifactType: null, // Get all types
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
var attestations = new List<AttachedAttestation>();
|
||||
|
||||
foreach (var referrer in referrers)
|
||||
{
|
||||
// Filter to DSSE envelope types
|
||||
if (referrer.MediaType != MediaTypes.DsseEnvelope &&
|
||||
referrer.ArtifactType != MediaTypes.DsseEnvelope)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var predicateType = referrer.Annotations?.GetValueOrDefault(AnnotationKeys.PredicateType)
|
||||
?? "unknown";
|
||||
|
||||
var createdAtStr = referrer.Annotations?.GetValueOrDefault(AnnotationKeys.Created);
|
||||
var createdAt = DateTimeOffset.TryParse(createdAtStr, out var dt)
|
||||
? dt
|
||||
: DateTimeOffset.MinValue;
|
||||
|
||||
attestations.Add(new AttachedAttestation
|
||||
{
|
||||
Digest = referrer.Digest,
|
||||
PredicateType = predicateType,
|
||||
CreatedAt = createdAt,
|
||||
Annotations = referrer.Annotations,
|
||||
Size = referrer.Size
|
||||
});
|
||||
}
|
||||
|
||||
// Deterministic ordering: by predicate type, then by creation time (newest first)
|
||||
return attestations
|
||||
.OrderBy(a => a.PredicateType, StringComparer.Ordinal)
|
||||
.ThenByDescending(a => a.CreatedAt)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<DsseEnvelope?> FetchAsync(
|
||||
OciReference imageRef,
|
||||
string predicateType,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(imageRef);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(predicateType);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Fetching attestation {PredicateType} from {Registry}/{Repository}@{Digest}",
|
||||
predicateType,
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
TruncateDigest(imageRef.Digest));
|
||||
|
||||
var attestations = await ListAsync(imageRef, ct).ConfigureAwait(false);
|
||||
var target = attestations.FirstOrDefault(a => a.PredicateType == predicateType);
|
||||
|
||||
if (target is null)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"No attestation with predicate type {PredicateType} found",
|
||||
predicateType);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch the attestation manifest to get the layer digest
|
||||
var manifest = await _registryClient.FetchManifestAsync(
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
target.Digest,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (manifest.Layers.Count == 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Attestation manifest {Digest} has no layers",
|
||||
TruncateDigest(target.Digest));
|
||||
return null;
|
||||
}
|
||||
|
||||
var layerDigest = manifest.Layers[0].Digest;
|
||||
|
||||
// Fetch the attestation blob
|
||||
var blobBytes = await _registryClient.FetchBlobAsync(
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
layerDigest,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
return DeserializeEnvelope(blobBytes);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<bool> RemoveAsync(
|
||||
OciReference imageRef,
|
||||
string attestationDigest,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(imageRef);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(attestationDigest);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Removing attestation {AttestationDigest} from {Registry}/{Repository}@{Digest}",
|
||||
TruncateDigest(attestationDigest),
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
TruncateDigest(imageRef.Digest));
|
||||
|
||||
return await _registryClient.DeleteManifestAsync(
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
attestationDigest,
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<AttachedAttestation?> FindExistingAttestationAsync(
|
||||
OciReference imageRef,
|
||||
string predicateType,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var attestations = await ListAsync(imageRef, ct).ConfigureAwait(false);
|
||||
return attestations.FirstOrDefault(a => a.PredicateType == predicateType);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildAnnotations(
|
||||
DsseEnvelope envelope,
|
||||
AttachmentOptions options)
|
||||
{
|
||||
var annotations = new Dictionary<string, string>
|
||||
{
|
||||
[AnnotationKeys.Created] = DateTimeOffset.UtcNow.ToString("O"),
|
||||
[AnnotationKeys.PredicateType] = envelope.PayloadType,
|
||||
[AnnotationKeys.CosignSignature] = "" // Cosign compatibility placeholder
|
||||
};
|
||||
|
||||
// Add signer identity if available
|
||||
var firstSignature = envelope.Signatures.FirstOrDefault();
|
||||
if (firstSignature?.KeyId is not null)
|
||||
{
|
||||
annotations[AnnotationKeys.SignerIdentity] = firstSignature.KeyId;
|
||||
}
|
||||
|
||||
// Merge user-provided annotations
|
||||
if (options.Annotations is not null)
|
||||
{
|
||||
foreach (var (key, value) in options.Annotations)
|
||||
{
|
||||
annotations[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return annotations;
|
||||
}
|
||||
|
||||
private static byte[] SerializeCanonical(DsseEnvelope envelope)
|
||||
{
|
||||
// Use the serializer from StellaOps.Attestor.Envelope
|
||||
var options = new DsseEnvelopeSerializationOptions
|
||||
{
|
||||
EmitCompactJson = true,
|
||||
EmitExpandedJson = false
|
||||
};
|
||||
|
||||
var result = DsseEnvelopeSerializer.Serialize(envelope, options);
|
||||
|
||||
return result.CompactJson ?? throw new InvalidOperationException(
|
||||
"Failed to serialize DSSE envelope to compact JSON");
|
||||
}
|
||||
|
||||
private static DsseEnvelope DeserializeEnvelope(ReadOnlyMemory<byte> bytes)
|
||||
{
|
||||
// Parse the compact DSSE envelope format
|
||||
var json = JsonDocument.Parse(bytes);
|
||||
var root = json.RootElement;
|
||||
|
||||
var payloadType = root.GetProperty("payloadType").GetString()
|
||||
?? throw new InvalidOperationException("Missing payloadType");
|
||||
|
||||
var payloadBase64 = root.GetProperty("payload").GetString()
|
||||
?? throw new InvalidOperationException("Missing payload");
|
||||
|
||||
var payload = Convert.FromBase64String(payloadBase64);
|
||||
|
||||
var signatures = new List<DsseSignature>();
|
||||
if (root.TryGetProperty("signatures", out var sigsElement))
|
||||
{
|
||||
foreach (var sigElement in sigsElement.EnumerateArray())
|
||||
{
|
||||
var keyId = sigElement.TryGetProperty("keyid", out var keyIdProp)
|
||||
? keyIdProp.GetString()
|
||||
: null;
|
||||
|
||||
var sig = sigElement.GetProperty("sig").GetString()
|
||||
?? throw new InvalidOperationException("Missing sig");
|
||||
|
||||
signatures.Add(new DsseSignature(signature: sig, keyId: keyId));
|
||||
}
|
||||
}
|
||||
|
||||
return new DsseEnvelope(payloadType, payload, signatures);
|
||||
}
|
||||
|
||||
private static string ComputeDigest(ReadOnlySpan<byte> content)
|
||||
{
|
||||
var hash = SHA256.HashData(content);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
private static string TruncateDigest(string digest)
|
||||
{
|
||||
if (string.IsNullOrEmpty(digest))
|
||||
{
|
||||
return digest;
|
||||
}
|
||||
|
||||
var colonIndex = digest.IndexOf(':');
|
||||
if (colonIndex < 0 || digest.Length < colonIndex + 13)
|
||||
{
|
||||
return digest;
|
||||
}
|
||||
|
||||
return digest[..(colonIndex + 13)] + "...";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RootNamespace>StellaOps.Attestor.Oci</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -284,7 +284,7 @@ public sealed class FileSystemRootStore : IOfflineRootStore
|
||||
.Trim();
|
||||
|
||||
var certBytes = Convert.FromBase64String(base64Content);
|
||||
collection.Add(new X509Certificate2(certBytes));
|
||||
collection.Add(X509CertificateLoader.LoadCertificate(certBytes));
|
||||
|
||||
startIndex = end + endMarker.Length;
|
||||
}
|
||||
|
||||
@@ -689,7 +689,7 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
{
|
||||
// Try as raw base64
|
||||
var certBytes = Convert.FromBase64String(pem.Trim());
|
||||
return new X509Certificate2(certBytes);
|
||||
return X509CertificateLoader.LoadCertificate(certBytes);
|
||||
}
|
||||
|
||||
var base64Start = begin + beginMarker.Length;
|
||||
@@ -699,7 +699,7 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
.Trim();
|
||||
|
||||
var bytes = Convert.FromBase64String(base64Content);
|
||||
return new X509Certificate2(bytes);
|
||||
return X509CertificateLoader.LoadCertificate(bytes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="BouncyCastle.Cryptography" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>StellaOps.Attestor.Offline</RootNamespace>
|
||||
<Description>Offline verification of attestation bundles for air-gapped environments.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Bundle\StellaOps.Attestor.Bundle.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Bundling\StellaOps.Attestor.Bundling.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Verify\StellaOps.Attestor.Verify.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,245 @@
|
||||
-- Attestor Schema Migration 001: Initial Schema (Compacted)
|
||||
-- Consolidated from 20251214000001_AddProofChainSchema.sql and 20251216_001_create_rekor_submission_queue.sql
|
||||
-- for 1.0.0 release
|
||||
-- Creates the proofchain schema for proof chain persistence and attestor schema for Rekor queue
|
||||
|
||||
-- ============================================================================
|
||||
-- Extensions
|
||||
-- ============================================================================
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
-- ============================================================================
|
||||
-- Schema Creation
|
||||
-- ============================================================================
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS proofchain;
|
||||
CREATE SCHEMA IF NOT EXISTS attestor;
|
||||
|
||||
-- ============================================================================
|
||||
-- Enum Types
|
||||
-- ============================================================================
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'verification_result' AND typnamespace = 'proofchain'::regnamespace) THEN
|
||||
CREATE TYPE proofchain.verification_result AS ENUM ('pass', 'fail', 'pending');
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ============================================================================
|
||||
-- ProofChain Schema Tables
|
||||
-- ============================================================================
|
||||
|
||||
-- Trust anchors table (create first - no dependencies)
|
||||
CREATE TABLE IF NOT EXISTS proofchain.trust_anchors (
|
||||
anchor_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
purl_pattern TEXT NOT NULL,
|
||||
allowed_keyids TEXT[] NOT NULL,
|
||||
allowed_predicate_types TEXT[],
|
||||
policy_ref TEXT,
|
||||
policy_version TEXT,
|
||||
revoked_keys TEXT[] DEFAULT '{}',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_trust_anchors_pattern ON proofchain.trust_anchors(purl_pattern);
|
||||
CREATE INDEX IF NOT EXISTS idx_trust_anchors_active ON proofchain.trust_anchors(is_active) WHERE is_active = TRUE;
|
||||
|
||||
COMMENT ON TABLE proofchain.trust_anchors IS 'Trust anchor configurations for dependency verification';
|
||||
COMMENT ON COLUMN proofchain.trust_anchors.purl_pattern IS 'PURL glob pattern (e.g., pkg:npm/*)';
|
||||
COMMENT ON COLUMN proofchain.trust_anchors.revoked_keys IS 'Key IDs that have been revoked but may appear in old proofs';
|
||||
|
||||
-- SBOM entries table
|
||||
CREATE TABLE IF NOT EXISTS proofchain.sbom_entries (
|
||||
entry_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
bom_digest VARCHAR(64) NOT NULL,
|
||||
purl TEXT NOT NULL,
|
||||
version TEXT,
|
||||
artifact_digest VARCHAR(64),
|
||||
trust_anchor_id UUID REFERENCES proofchain.trust_anchors(anchor_id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT uq_sbom_entry UNIQUE (bom_digest, purl, version)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sbom_entries_bom_digest ON proofchain.sbom_entries(bom_digest);
|
||||
CREATE INDEX IF NOT EXISTS idx_sbom_entries_purl ON proofchain.sbom_entries(purl);
|
||||
CREATE INDEX IF NOT EXISTS idx_sbom_entries_artifact ON proofchain.sbom_entries(artifact_digest);
|
||||
CREATE INDEX IF NOT EXISTS idx_sbom_entries_anchor ON proofchain.sbom_entries(trust_anchor_id);
|
||||
|
||||
COMMENT ON TABLE proofchain.sbom_entries IS 'SBOM component entries with content-addressed identifiers';
|
||||
COMMENT ON COLUMN proofchain.sbom_entries.bom_digest IS 'SHA-256 hash of the parent SBOM document';
|
||||
COMMENT ON COLUMN proofchain.sbom_entries.purl IS 'Package URL (PURL) of the component';
|
||||
COMMENT ON COLUMN proofchain.sbom_entries.artifact_digest IS 'SHA-256 hash of the component artifact if available';
|
||||
|
||||
-- DSSE envelopes table
|
||||
CREATE TABLE IF NOT EXISTS proofchain.dsse_envelopes (
|
||||
env_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
entry_id UUID NOT NULL REFERENCES proofchain.sbom_entries(entry_id) ON DELETE CASCADE,
|
||||
predicate_type TEXT NOT NULL,
|
||||
signer_keyid TEXT NOT NULL,
|
||||
body_hash VARCHAR(64) NOT NULL,
|
||||
envelope_blob_ref TEXT NOT NULL,
|
||||
signed_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT uq_dsse_envelope UNIQUE (entry_id, predicate_type, body_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dsse_entry_predicate ON proofchain.dsse_envelopes(entry_id, predicate_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_dsse_signer ON proofchain.dsse_envelopes(signer_keyid);
|
||||
CREATE INDEX IF NOT EXISTS idx_dsse_body_hash ON proofchain.dsse_envelopes(body_hash);
|
||||
|
||||
COMMENT ON TABLE proofchain.dsse_envelopes IS 'Signed DSSE envelopes for proof chain statements';
|
||||
COMMENT ON COLUMN proofchain.dsse_envelopes.predicate_type IS 'Predicate type URI (e.g., evidence.stella/v1)';
|
||||
COMMENT ON COLUMN proofchain.dsse_envelopes.envelope_blob_ref IS 'Reference to blob storage (OCI, S3, file)';
|
||||
|
||||
-- Spines table
|
||||
CREATE TABLE IF NOT EXISTS proofchain.spines (
|
||||
entry_id UUID PRIMARY KEY REFERENCES proofchain.sbom_entries(entry_id) ON DELETE CASCADE,
|
||||
bundle_id VARCHAR(64) NOT NULL,
|
||||
evidence_ids TEXT[] NOT NULL,
|
||||
reasoning_id VARCHAR(64) NOT NULL,
|
||||
vex_id VARCHAR(64) NOT NULL,
|
||||
anchor_id UUID REFERENCES proofchain.trust_anchors(anchor_id) ON DELETE SET NULL,
|
||||
policy_version TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT uq_spine_bundle UNIQUE (bundle_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_spines_bundle ON proofchain.spines(bundle_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_spines_anchor ON proofchain.spines(anchor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_spines_policy ON proofchain.spines(policy_version);
|
||||
|
||||
COMMENT ON TABLE proofchain.spines IS 'Proof spines linking evidence to verdicts via merkle aggregation';
|
||||
COMMENT ON COLUMN proofchain.spines.bundle_id IS 'ProofBundleID (merkle root of all components)';
|
||||
COMMENT ON COLUMN proofchain.spines.evidence_ids IS 'Array of EvidenceIDs in sorted order';
|
||||
|
||||
-- Rekor entries table
|
||||
CREATE TABLE IF NOT EXISTS proofchain.rekor_entries (
|
||||
dsse_sha256 VARCHAR(64) PRIMARY KEY,
|
||||
log_index BIGINT NOT NULL,
|
||||
log_id TEXT NOT NULL,
|
||||
uuid TEXT NOT NULL,
|
||||
integrated_time BIGINT NOT NULL,
|
||||
inclusion_proof JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
env_id UUID REFERENCES proofchain.dsse_envelopes(env_id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rekor_log_index ON proofchain.rekor_entries(log_index);
|
||||
CREATE INDEX IF NOT EXISTS idx_rekor_log_id ON proofchain.rekor_entries(log_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_rekor_uuid ON proofchain.rekor_entries(uuid);
|
||||
CREATE INDEX IF NOT EXISTS idx_rekor_env ON proofchain.rekor_entries(env_id);
|
||||
|
||||
COMMENT ON TABLE proofchain.rekor_entries IS 'Rekor transparency log entries for verification';
|
||||
COMMENT ON COLUMN proofchain.rekor_entries.inclusion_proof IS 'Merkle inclusion proof from Rekor';
|
||||
|
||||
-- Audit log table
|
||||
CREATE TABLE IF NOT EXISTS proofchain.audit_log (
|
||||
log_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
operation TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
actor TEXT,
|
||||
details JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_entity ON proofchain.audit_log(entity_type, entity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_created ON proofchain.audit_log(created_at DESC);
|
||||
|
||||
COMMENT ON TABLE proofchain.audit_log IS 'Audit log for proof chain operations';
|
||||
|
||||
-- ============================================================================
|
||||
-- Attestor Schema Tables
|
||||
-- ============================================================================
|
||||
|
||||
-- Rekor submission queue table
|
||||
CREATE TABLE IF NOT EXISTS attestor.rekor_submission_queue (
|
||||
id UUID PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
bundle_sha256 TEXT NOT NULL,
|
||||
dsse_payload BYTEA NOT NULL,
|
||||
backend TEXT NOT NULL DEFAULT 'primary',
|
||||
|
||||
-- Status lifecycle: pending -> submitting -> submitted | retrying -> dead_letter
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'submitting', 'retrying', 'submitted', 'dead_letter')),
|
||||
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||
max_attempts INTEGER NOT NULL DEFAULT 5,
|
||||
next_retry_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
-- Populated on success
|
||||
rekor_uuid TEXT,
|
||||
rekor_index BIGINT,
|
||||
|
||||
-- Populated on failure
|
||||
last_error TEXT
|
||||
);
|
||||
|
||||
COMMENT ON TABLE attestor.rekor_submission_queue IS
|
||||
'Durable retry queue for Rekor transparency log submissions';
|
||||
COMMENT ON COLUMN attestor.rekor_submission_queue.status IS
|
||||
'Submission lifecycle: pending -> submitting -> (submitted | retrying -> dead_letter)';
|
||||
COMMENT ON COLUMN attestor.rekor_submission_queue.backend IS
|
||||
'Target Rekor backend (primary or mirror)';
|
||||
COMMENT ON COLUMN attestor.rekor_submission_queue.dsse_payload IS
|
||||
'Serialized DSSE envelope to submit';
|
||||
|
||||
-- Index for dequeue operations (status + next_retry_at for SKIP LOCKED queries)
|
||||
CREATE INDEX IF NOT EXISTS idx_rekor_queue_dequeue
|
||||
ON attestor.rekor_submission_queue (status, next_retry_at)
|
||||
WHERE status IN ('pending', 'retrying');
|
||||
|
||||
-- Index for tenant-scoped queries
|
||||
CREATE INDEX IF NOT EXISTS idx_rekor_queue_tenant
|
||||
ON attestor.rekor_submission_queue (tenant_id);
|
||||
|
||||
-- Index for bundle lookup (deduplication check)
|
||||
CREATE INDEX IF NOT EXISTS idx_rekor_queue_bundle
|
||||
ON attestor.rekor_submission_queue (tenant_id, bundle_sha256);
|
||||
|
||||
-- Index for dead letter management
|
||||
CREATE INDEX IF NOT EXISTS idx_rekor_queue_dead_letter
|
||||
ON attestor.rekor_submission_queue (status, updated_at)
|
||||
WHERE status = 'dead_letter';
|
||||
|
||||
-- Index for cleanup of completed submissions
|
||||
CREATE INDEX IF NOT EXISTS idx_rekor_queue_completed
|
||||
ON attestor.rekor_submission_queue (status, updated_at)
|
||||
WHERE status = 'submitted';
|
||||
|
||||
-- ============================================================================
|
||||
-- Trigger Functions
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION proofchain.update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Apply updated_at trigger to trust_anchors
|
||||
DROP TRIGGER IF EXISTS update_trust_anchors_updated_at ON proofchain.trust_anchors;
|
||||
CREATE TRIGGER update_trust_anchors_updated_at
|
||||
BEFORE UPDATE ON proofchain.trust_anchors
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION proofchain.update_updated_at_column();
|
||||
|
||||
-- Apply updated_at trigger to rekor_submission_queue
|
||||
DROP TRIGGER IF EXISTS update_rekor_queue_updated_at ON attestor.rekor_submission_queue;
|
||||
CREATE TRIGGER update_rekor_queue_updated_at
|
||||
BEFORE UPDATE ON attestor.rekor_submission_queue
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION proofchain.update_updated_at_column();
|
||||
@@ -0,0 +1,22 @@
|
||||
# Archived Pre-1.0 Migrations
|
||||
|
||||
This directory contains the original migrations that were compacted into `001_initial_schema.sql`
|
||||
for the 1.0.0 release.
|
||||
|
||||
## Original Files
|
||||
- `20251214000001_AddProofChainSchema.sql` - ProofChain schema (trust_anchors, sbom_entries, dsse_envelopes, spines, rekor_entries, audit_log)
|
||||
- `20251214000002_RollbackProofChainSchema.sql` - Rollback script (reference only)
|
||||
|
||||
## Why Archived
|
||||
Pre-1.0, the schema evolved incrementally. For 1.0.0, migrations were compacted into a single
|
||||
initial schema to:
|
||||
- Simplify new deployments
|
||||
- Reduce startup time
|
||||
- Provide cleaner upgrade path
|
||||
|
||||
## For Existing Deployments
|
||||
If upgrading from pre-1.0, run the reset script directly with psql:
|
||||
```bash
|
||||
psql -h <host> -U <user> -d <db> -f devops/scripts/migrations-reset-pre-1.0.sql
|
||||
```
|
||||
This updates `schema_migrations` to recognize the compacted schema.
|
||||
@@ -10,8 +10,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -161,6 +161,16 @@ public sealed class PredicateSchemaValidator : IJsonSchemaValidator
|
||||
case "reachability-subgraph.stella/v1":
|
||||
errors.AddRange(ValidateReachabilitySubgraphPredicate(root));
|
||||
break;
|
||||
// Delta predicate types for lineage comparison (Sprint 20251228_007)
|
||||
case "stella.ops/vex-delta@v1":
|
||||
errors.AddRange(ValidateVexDeltaPredicate(root));
|
||||
break;
|
||||
case "stella.ops/sbom-delta@v1":
|
||||
errors.AddRange(ValidateSbomDeltaPredicate(root));
|
||||
break;
|
||||
case "stella.ops/verdict-delta@v1":
|
||||
errors.AddRange(ValidateVerdictDeltaPredicate(root));
|
||||
break;
|
||||
}
|
||||
|
||||
return errors.Count > 0
|
||||
@@ -200,6 +210,10 @@ public sealed class PredicateSchemaValidator : IJsonSchemaValidator
|
||||
"https://stella-ops.org/predicates/sbom-linkage/v1" => true,
|
||||
"delta-verdict.stella/v1" => true,
|
||||
"reachability-subgraph.stella/v1" => true,
|
||||
// Delta predicate types for lineage comparison (Sprint 20251228_007)
|
||||
"stella.ops/vex-delta@v1" => true,
|
||||
"stella.ops/sbom-delta@v1" => true,
|
||||
"stella.ops/verdict-delta@v1" => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
@@ -282,4 +296,61 @@ public sealed class PredicateSchemaValidator : IJsonSchemaValidator
|
||||
if (!root.TryGetProperty("analysis", out _))
|
||||
yield return new() { Path = "/analysis", Message = "Required property missing", Keyword = "required" };
|
||||
}
|
||||
|
||||
private static IEnumerable<SchemaValidationError> ValidateVexDeltaPredicate(JsonElement root)
|
||||
{
|
||||
// Required: fromDigest, toDigest, tenantId, summary, comparedAt
|
||||
if (!root.TryGetProperty("fromDigest", out _))
|
||||
yield return new() { Path = "/fromDigest", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("toDigest", out _))
|
||||
yield return new() { Path = "/toDigest", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("tenantId", out _))
|
||||
yield return new() { Path = "/tenantId", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("summary", out _))
|
||||
yield return new() { Path = "/summary", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("comparedAt", out _))
|
||||
yield return new() { Path = "/comparedAt", Message = "Required property missing", Keyword = "required" };
|
||||
}
|
||||
|
||||
private static IEnumerable<SchemaValidationError> ValidateSbomDeltaPredicate(JsonElement root)
|
||||
{
|
||||
// Required: fromDigest, toDigest, fromSbomDigest, toSbomDigest, tenantId, summary, comparedAt
|
||||
if (!root.TryGetProperty("fromDigest", out _))
|
||||
yield return new() { Path = "/fromDigest", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("toDigest", out _))
|
||||
yield return new() { Path = "/toDigest", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("fromSbomDigest", out _))
|
||||
yield return new() { Path = "/fromSbomDigest", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("toSbomDigest", out _))
|
||||
yield return new() { Path = "/toSbomDigest", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("tenantId", out _))
|
||||
yield return new() { Path = "/tenantId", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("summary", out _))
|
||||
yield return new() { Path = "/summary", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("comparedAt", out _))
|
||||
yield return new() { Path = "/comparedAt", Message = "Required property missing", Keyword = "required" };
|
||||
}
|
||||
|
||||
private static IEnumerable<SchemaValidationError> ValidateVerdictDeltaPredicate(JsonElement root)
|
||||
{
|
||||
// Required: fromDigest, toDigest, tenantId, fromPolicyVersion, toPolicyVersion, fromVerdict, toVerdict, summary, comparedAt
|
||||
if (!root.TryGetProperty("fromDigest", out _))
|
||||
yield return new() { Path = "/fromDigest", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("toDigest", out _))
|
||||
yield return new() { Path = "/toDigest", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("tenantId", out _))
|
||||
yield return new() { Path = "/tenantId", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("fromPolicyVersion", out _))
|
||||
yield return new() { Path = "/fromPolicyVersion", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("toPolicyVersion", out _))
|
||||
yield return new() { Path = "/toPolicyVersion", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("fromVerdict", out _))
|
||||
yield return new() { Path = "/fromVerdict", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("toVerdict", out _))
|
||||
yield return new() { Path = "/toVerdict", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("summary", out _))
|
||||
yield return new() { Path = "/summary", Message = "Required property missing", Keyword = "required" };
|
||||
if (!root.TryGetProperty("comparedAt", out _))
|
||||
yield return new() { Path = "/comparedAt", Message = "Required property missing", Keyword = "required" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ public sealed class Rfc8785JsonCanonicalizer : IJsonCanonicalizer
|
||||
|
||||
foreach (var (name, value) in properties)
|
||||
{
|
||||
writer.WritePropertyName(NormalizeString(name));
|
||||
writer.WritePropertyName(NormalizeString(name)!);
|
||||
WriteCanonical(writer, value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
@@ -159,7 +159,7 @@ public sealed class Rfc8785JsonCanonicalizer : IJsonCanonicalizer
|
||||
writer.WriteStartObject();
|
||||
foreach (var (name, value) in properties)
|
||||
{
|
||||
writer.WritePropertyName(NormalizeString(name));
|
||||
writer.WritePropertyName(NormalizeString(name)!);
|
||||
WriteCanonical(writer, value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SbomDeltaPredicate.cs
|
||||
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii
|
||||
// Task: LIN-BE-024-DELTA-PREDICATES
|
||||
// Description: DSSE predicate for SBOM delta attestations between artifact versions.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Predicates;
|
||||
|
||||
/// <summary>
|
||||
/// DSSE predicate for SBOM delta attestation between two artifact versions.
|
||||
/// predicateType: stella.ops/sbom-delta@v1
|
||||
/// </summary>
|
||||
public sealed record SbomDeltaPredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// The predicate type URI for SBOM delta attestations.
|
||||
/// </summary>
|
||||
public const string PredicateType = "stella.ops/sbom-delta@v1";
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the source (baseline) artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fromDigest")]
|
||||
public required string FromDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the target (current) artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("toDigest")]
|
||||
public required string ToDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the source SBOM.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fromSbomDigest")]
|
||||
public required string FromSbomDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the target SBOM.
|
||||
/// </summary>
|
||||
[JsonPropertyName("toSbomDigest")]
|
||||
public required string ToSbomDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tenantId")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Components added in the target SBOM.
|
||||
/// </summary>
|
||||
[JsonPropertyName("added")]
|
||||
public ImmutableArray<SbomDeltaComponent> Added { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Components removed from the baseline SBOM.
|
||||
/// </summary>
|
||||
[JsonPropertyName("removed")]
|
||||
public ImmutableArray<SbomDeltaComponent> Removed { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Components that changed version between SBOMs.
|
||||
/// </summary>
|
||||
[JsonPropertyName("versionChanged")]
|
||||
public ImmutableArray<SbomDeltaVersionChange> VersionChanged { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Summary counts for the delta.
|
||||
/// </summary>
|
||||
[JsonPropertyName("summary")]
|
||||
public required SbomDeltaSummary Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the comparison was performed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("comparedAt")]
|
||||
public required DateTimeOffset ComparedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the delta computation algorithm.
|
||||
/// </summary>
|
||||
[JsonPropertyName("algorithmVersion")]
|
||||
public string AlgorithmVersion { get; init; } = "1.0";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A component included in an SBOM delta.
|
||||
/// </summary>
|
||||
public sealed record SbomDeltaComponent
|
||||
{
|
||||
/// <summary>
|
||||
/// Package URL (PURL) of the component.
|
||||
/// </summary>
|
||||
[JsonPropertyName("purl")]
|
||||
public required string Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component type (library, framework, application, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ecosystem (npm, nuget, maven, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("ecosystem")]
|
||||
public string? Ecosystem { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Known vulnerabilities associated with this component.
|
||||
/// </summary>
|
||||
[JsonPropertyName("knownVulnerabilities")]
|
||||
public ImmutableArray<string> KnownVulnerabilities { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// License identifiers for this component.
|
||||
/// </summary>
|
||||
[JsonPropertyName("licenses")]
|
||||
public ImmutableArray<string> Licenses { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A component version change in SBOM delta.
|
||||
/// </summary>
|
||||
public sealed record SbomDeltaVersionChange
|
||||
{
|
||||
/// <summary>
|
||||
/// Package URL (PURL) of the component (without version).
|
||||
/// </summary>
|
||||
[JsonPropertyName("purl")]
|
||||
public required string Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("previousVersion")]
|
||||
public required string PreviousVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("currentVersion")]
|
||||
public required string CurrentVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of version change (major, minor, patch, unknown).
|
||||
/// </summary>
|
||||
[JsonPropertyName("changeType")]
|
||||
public required string ChangeType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerabilities fixed by the upgrade.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerabilitiesFixed")]
|
||||
public ImmutableArray<string> VulnerabilitiesFixed { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerabilities introduced by the change.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerabilitiesIntroduced")]
|
||||
public ImmutableArray<string> VulnerabilitiesIntroduced { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of SBOM delta counts.
|
||||
/// </summary>
|
||||
public sealed record SbomDeltaSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of components added.
|
||||
/// </summary>
|
||||
[JsonPropertyName("addedCount")]
|
||||
public required int AddedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of components removed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("removedCount")]
|
||||
public required int RemovedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of components with version changes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("versionChangedCount")]
|
||||
public required int VersionChangedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of unchanged components.
|
||||
/// </summary>
|
||||
[JsonPropertyName("unchangedCount")]
|
||||
public required int UnchangedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total component count in baseline SBOM.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fromTotalCount")]
|
||||
public required int FromTotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total component count in target SBOM.
|
||||
/// </summary>
|
||||
[JsonPropertyName("toTotalCount")]
|
||||
public required int ToTotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of vulnerabilities fixed by changes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerabilitiesFixedCount")]
|
||||
public required int VulnerabilitiesFixedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of vulnerabilities introduced by changes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerabilitiesIntroducedCount")]
|
||||
public required int VulnerabilitiesIntroducedCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VerdictDeltaPredicate.cs
|
||||
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii
|
||||
// Task: LIN-BE-024-DELTA-PREDICATES
|
||||
// Description: DSSE predicate for policy verdict delta attestations between artifact versions.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Predicates;
|
||||
|
||||
/// <summary>
|
||||
/// DSSE predicate for policy verdict delta attestation between two artifact versions.
|
||||
/// predicateType: stella.ops/verdict-delta@v1
|
||||
/// </summary>
|
||||
public sealed record VerdictDeltaPredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// The predicate type URI for verdict delta attestations.
|
||||
/// </summary>
|
||||
public const string PredicateType = "stella.ops/verdict-delta@v1";
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the source (baseline) artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fromDigest")]
|
||||
public required string FromDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the target (current) artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("toDigest")]
|
||||
public required string ToDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tenantId")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy pack version used for baseline evaluation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fromPolicyVersion")]
|
||||
public required string FromPolicyVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy pack version used for target evaluation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("toPolicyVersion")]
|
||||
public required string ToPolicyVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overall verdict for the baseline artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fromVerdict")]
|
||||
public required VerdictSummary FromVerdict { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overall verdict for the target artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("toVerdict")]
|
||||
public required VerdictSummary ToVerdict { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Individual finding verdicts that changed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("findingChanges")]
|
||||
public ImmutableArray<VerdictFindingChange> FindingChanges { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Rule evaluations that changed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ruleChanges")]
|
||||
public ImmutableArray<VerdictRuleChange> RuleChanges { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Summary of the verdict delta.
|
||||
/// </summary>
|
||||
[JsonPropertyName("summary")]
|
||||
public required VerdictDeltaSummary Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the comparison was performed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("comparedAt")]
|
||||
public required DateTimeOffset ComparedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the delta computation algorithm.
|
||||
/// </summary>
|
||||
[JsonPropertyName("algorithmVersion")]
|
||||
public string AlgorithmVersion { get; init; } = "1.0";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a policy verdict.
|
||||
/// </summary>
|
||||
public sealed record VerdictSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Overall verdict (pass, fail, warn).
|
||||
/// </summary>
|
||||
[JsonPropertyName("outcome")]
|
||||
public required string Outcome { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score (0.0 to 1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Risk score.
|
||||
/// </summary>
|
||||
[JsonPropertyName("riskScore")]
|
||||
public required double RiskScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the verdict attestation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verdictDigest")]
|
||||
public string? VerdictDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Count of passing rules.
|
||||
/// </summary>
|
||||
[JsonPropertyName("passingRules")]
|
||||
public required int PassingRules { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Count of failing rules.
|
||||
/// </summary>
|
||||
[JsonPropertyName("failingRules")]
|
||||
public required int FailingRules { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Count of warning rules.
|
||||
/// </summary>
|
||||
[JsonPropertyName("warningRules")]
|
||||
public required int WarningRules { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A finding verdict change between versions.
|
||||
/// </summary>
|
||||
public sealed record VerdictFindingChange
|
||||
{
|
||||
/// <summary>
|
||||
/// Vulnerability identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component PURL.
|
||||
/// </summary>
|
||||
[JsonPropertyName("purl")]
|
||||
public required string Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous verdict for this finding.
|
||||
/// </summary>
|
||||
[JsonPropertyName("previousVerdict")]
|
||||
public required string PreviousVerdict { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current verdict for this finding.
|
||||
/// </summary>
|
||||
[JsonPropertyName("currentVerdict")]
|
||||
public required string CurrentVerdict { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for the change.
|
||||
/// </summary>
|
||||
[JsonPropertyName("changeReason")]
|
||||
public required string ChangeReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Direction of risk change.
|
||||
/// </summary>
|
||||
[JsonPropertyName("riskDirection")]
|
||||
public required string RiskDirection { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A rule evaluation change between versions.
|
||||
/// </summary>
|
||||
public sealed record VerdictRuleChange
|
||||
{
|
||||
/// <summary>
|
||||
/// Rule identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ruleId")]
|
||||
public required string RuleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ruleName")]
|
||||
public required string RuleName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous rule result (pass, fail, warn, skip).
|
||||
/// </summary>
|
||||
[JsonPropertyName("previousResult")]
|
||||
public required string PreviousResult { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current rule result.
|
||||
/// </summary>
|
||||
[JsonPropertyName("currentResult")]
|
||||
public required string CurrentResult { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous rule message.
|
||||
/// </summary>
|
||||
[JsonPropertyName("previousMessage")]
|
||||
public string? PreviousMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current rule message.
|
||||
/// </summary>
|
||||
[JsonPropertyName("currentMessage")]
|
||||
public string? CurrentMessage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of verdict delta.
|
||||
/// </summary>
|
||||
public sealed record VerdictDeltaSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the overall verdict changed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verdictChanged")]
|
||||
public required bool VerdictChanged { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Direction of overall risk change (increased, decreased, neutral).
|
||||
/// </summary>
|
||||
[JsonPropertyName("riskDirection")]
|
||||
public required string RiskDirection { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Change in risk score.
|
||||
/// </summary>
|
||||
[JsonPropertyName("riskScoreDelta")]
|
||||
public required double RiskScoreDelta { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Change in confidence score.
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidenceDelta")]
|
||||
public required double ConfidenceDelta { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of findings with improved verdicts.
|
||||
/// </summary>
|
||||
[JsonPropertyName("findingsImproved")]
|
||||
public required int FindingsImproved { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of findings with worsened verdicts.
|
||||
/// </summary>
|
||||
[JsonPropertyName("findingsWorsened")]
|
||||
public required int FindingsWorsened { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of new findings.
|
||||
/// </summary>
|
||||
[JsonPropertyName("findingsNew")]
|
||||
public required int FindingsNew { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of resolved findings.
|
||||
/// </summary>
|
||||
[JsonPropertyName("findingsResolved")]
|
||||
public required int FindingsResolved { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of rules that changed result.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rulesChanged")]
|
||||
public required int RulesChanged { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VexDeltaPredicate.cs
|
||||
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii
|
||||
// Task: LIN-BE-024-DELTA-PREDICATES
|
||||
// Description: DSSE predicate for VEX delta attestations between artifact versions.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Predicates;
|
||||
|
||||
/// <summary>
|
||||
/// DSSE predicate for VEX delta attestation between two artifact versions.
|
||||
/// predicateType: stella.ops/vex-delta@v1
|
||||
/// </summary>
|
||||
public sealed record VexDeltaPredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// The predicate type URI for VEX delta attestations.
|
||||
/// </summary>
|
||||
public const string PredicateType = "stella.ops/vex-delta@v1";
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the source (baseline) artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fromDigest")]
|
||||
public required string FromDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the target (current) artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("toDigest")]
|
||||
public required string ToDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tenantId")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX statements added in the target artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("added")]
|
||||
public ImmutableArray<VexDeltaStatement> Added { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// VEX statements removed from the baseline artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("removed")]
|
||||
public ImmutableArray<VexDeltaStatement> Removed { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// VEX statements that changed status between versions.
|
||||
/// </summary>
|
||||
[JsonPropertyName("changed")]
|
||||
public ImmutableArray<VexDeltaChange> Changed { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Summary counts for the delta.
|
||||
/// </summary>
|
||||
[JsonPropertyName("summary")]
|
||||
public required VexDeltaSummary Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the comparison was performed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("comparedAt")]
|
||||
public required DateTimeOffset ComparedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the delta computation algorithm.
|
||||
/// </summary>
|
||||
[JsonPropertyName("algorithmVersion")]
|
||||
public string AlgorithmVersion { get; init; } = "1.0";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A VEX statement included in a delta.
|
||||
/// </summary>
|
||||
public sealed record VexDeltaStatement
|
||||
{
|
||||
/// <summary>
|
||||
/// Vulnerability identifier (CVE, GHSA, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Product identifier affected.
|
||||
/// </summary>
|
||||
[JsonPropertyName("productId")]
|
||||
public required string ProductId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX status (not_affected, affected, fixed, under_investigation).
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Justification for the status.
|
||||
/// </summary>
|
||||
[JsonPropertyName("justification")]
|
||||
public string? Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Issuer of the VEX statement.
|
||||
/// </summary>
|
||||
[JsonPropertyName("issuer")]
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the VEX statement was issued.
|
||||
/// </summary>
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A VEX statement that changed between versions.
|
||||
/// </summary>
|
||||
public sealed record VexDeltaChange
|
||||
{
|
||||
/// <summary>
|
||||
/// Vulnerability identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Product identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("productId")]
|
||||
public required string ProductId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous VEX status.
|
||||
/// </summary>
|
||||
[JsonPropertyName("previousStatus")]
|
||||
public required string PreviousStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current VEX status.
|
||||
/// </summary>
|
||||
[JsonPropertyName("currentStatus")]
|
||||
public required string CurrentStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous justification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("previousJustification")]
|
||||
public string? PreviousJustification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current justification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("currentJustification")]
|
||||
public string? CurrentJustification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Direction of risk change (increased, decreased, neutral).
|
||||
/// </summary>
|
||||
[JsonPropertyName("riskDirection")]
|
||||
public required string RiskDirection { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of VEX delta counts.
|
||||
/// </summary>
|
||||
public sealed record VexDeltaSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of VEX statements added.
|
||||
/// </summary>
|
||||
[JsonPropertyName("addedCount")]
|
||||
public required int AddedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of VEX statements removed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("removedCount")]
|
||||
public required int RemovedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of VEX statements that changed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("changedCount")]
|
||||
public required int ChangedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of VEX statements unchanged.
|
||||
/// </summary>
|
||||
[JsonPropertyName("unchangedCount")]
|
||||
public required int UnchangedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Net risk direction (increased, decreased, neutral).
|
||||
/// </summary>
|
||||
[JsonPropertyName("netRiskDirection")]
|
||||
public required string NetRiskDirection { get; init; }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
@@ -7,7 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -10,9 +10,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="System.Text.Json" Version="10.0.0" />
|
||||
<PackageReference Include="JsonSchema.Net" Version="7.2.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="JsonSchema.Net" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RootNamespace>StellaOps.Attestor.TrustVerdict.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Attestor.TrustVerdict\StellaOps.Attestor.TrustVerdict.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,355 @@
|
||||
// TrustEvidenceMerkleBuilderTests - Unit tests for Merkle tree operations
|
||||
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.TrustVerdict.Evidence;
|
||||
using StellaOps.Attestor.TrustVerdict.Predicates;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.TrustVerdict.Tests;
|
||||
|
||||
public class TrustEvidenceMerkleBuilderTests
|
||||
{
|
||||
private readonly TrustEvidenceMerkleBuilder _builder = new();
|
||||
|
||||
[Fact]
|
||||
public void Build_WithEmptyItems_ReturnsEmptyTreeWithRoot()
|
||||
{
|
||||
// Act
|
||||
var tree = _builder.Build([]);
|
||||
|
||||
// Assert
|
||||
tree.Root.Should().StartWith("sha256:");
|
||||
tree.LeafCount.Should().Be(0);
|
||||
tree.Height.Should().Be(0);
|
||||
tree.NodeCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithSingleItem_ReturnsTreeWithOneLeaf()
|
||||
{
|
||||
// Arrange
|
||||
var items = new[]
|
||||
{
|
||||
CreateEvidenceItem("sha256:item1")
|
||||
};
|
||||
|
||||
// Act
|
||||
var tree = _builder.Build(items);
|
||||
|
||||
// Assert
|
||||
tree.LeafCount.Should().Be(1);
|
||||
tree.Height.Should().Be(0);
|
||||
tree.Root.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithTwoItems_ReturnsCorrectTree()
|
||||
{
|
||||
// Arrange
|
||||
var items = new[]
|
||||
{
|
||||
CreateEvidenceItem("sha256:item1"),
|
||||
CreateEvidenceItem("sha256:item2")
|
||||
};
|
||||
|
||||
// Act
|
||||
var tree = _builder.Build(items);
|
||||
|
||||
// Assert
|
||||
tree.LeafCount.Should().Be(2);
|
||||
tree.Height.Should().Be(1);
|
||||
tree.LeafHashes.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_SortsItemsByDigest()
|
||||
{
|
||||
// Arrange - Items in reverse order
|
||||
var items = new[]
|
||||
{
|
||||
CreateEvidenceItem("sha256:zzz"),
|
||||
CreateEvidenceItem("sha256:aaa"),
|
||||
CreateEvidenceItem("sha256:mmm")
|
||||
};
|
||||
|
||||
// Act
|
||||
var tree = _builder.Build(items);
|
||||
|
||||
// Assert
|
||||
tree.LeafCount.Should().Be(3);
|
||||
// First leaf should correspond to "sha256:aaa"
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var items = new[]
|
||||
{
|
||||
CreateEvidenceItem("sha256:item1"),
|
||||
CreateEvidenceItem("sha256:item2"),
|
||||
CreateEvidenceItem("sha256:item3")
|
||||
};
|
||||
|
||||
// Act
|
||||
var tree1 = _builder.Build(items);
|
||||
var tree2 = _builder.Build(items);
|
||||
|
||||
// Assert
|
||||
tree1.Root.Should().Be(tree2.Root);
|
||||
tree1.LeafHashes.Should().BeEquivalentTo(tree2.LeafHashes, opts => opts.WithStrictOrdering());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_DifferentOrderSameRoot()
|
||||
{
|
||||
// Arrange
|
||||
var items1 = new[]
|
||||
{
|
||||
CreateEvidenceItem("sha256:aaa"),
|
||||
CreateEvidenceItem("sha256:bbb")
|
||||
};
|
||||
|
||||
var items2 = new[]
|
||||
{
|
||||
CreateEvidenceItem("sha256:bbb"),
|
||||
CreateEvidenceItem("sha256:aaa")
|
||||
};
|
||||
|
||||
// Act
|
||||
var tree1 = _builder.Build(items1);
|
||||
var tree2 = _builder.Build(items2);
|
||||
|
||||
// Assert - Same root because items are sorted by digest
|
||||
tree1.Root.Should().Be(tree2.Root);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateProof_ForValidIndex_ReturnsProof()
|
||||
{
|
||||
// Arrange
|
||||
var items = new[]
|
||||
{
|
||||
CreateEvidenceItem("sha256:aaa"),
|
||||
CreateEvidenceItem("sha256:bbb"),
|
||||
CreateEvidenceItem("sha256:ccc"),
|
||||
CreateEvidenceItem("sha256:ddd")
|
||||
};
|
||||
var tree = _builder.Build(items);
|
||||
|
||||
// Act
|
||||
var proof = tree.GenerateProof(0);
|
||||
|
||||
// Assert
|
||||
proof.LeafIndex.Should().Be(0);
|
||||
proof.Root.Should().Be(tree.Root);
|
||||
proof.Siblings.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateProof_ForInvalidIndex_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var items = new[] { CreateEvidenceItem("sha256:item1") };
|
||||
var tree = _builder.Build(items);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => tree.GenerateProof(-1));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => tree.GenerateProof(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyProof_WithValidProof_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var items = new[]
|
||||
{
|
||||
CreateEvidenceItem("sha256:aaa"),
|
||||
CreateEvidenceItem("sha256:bbb"),
|
||||
CreateEvidenceItem("sha256:ccc"),
|
||||
CreateEvidenceItem("sha256:ddd")
|
||||
};
|
||||
var tree = _builder.Build(items);
|
||||
var proof = tree.GenerateProof(1);
|
||||
|
||||
// Get the item at sorted index 1 (should be "sha256:bbb")
|
||||
var sortedItems = items.OrderBy(i => i.Digest).ToList();
|
||||
var item = sortedItems[1];
|
||||
|
||||
// Act
|
||||
var valid = _builder.VerifyProof(item, proof, tree.Root);
|
||||
|
||||
// Assert
|
||||
valid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyProof_WithWrongItem_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var items = new[]
|
||||
{
|
||||
CreateEvidenceItem("sha256:aaa"),
|
||||
CreateEvidenceItem("sha256:bbb")
|
||||
};
|
||||
var tree = _builder.Build(items);
|
||||
var proof = tree.GenerateProof(0);
|
||||
|
||||
var wrongItem = CreateEvidenceItem("sha256:wrong");
|
||||
|
||||
// Act
|
||||
var valid = _builder.VerifyProof(wrongItem, proof, tree.Root);
|
||||
|
||||
// Assert
|
||||
valid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyProof_WithWrongRoot_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var items = new[] { CreateEvidenceItem("sha256:aaa") };
|
||||
var tree = _builder.Build(items);
|
||||
var proof = tree.GenerateProof(0);
|
||||
|
||||
// Act
|
||||
var valid = _builder.VerifyProof(items[0], proof, "sha256:wrongroot");
|
||||
|
||||
// Assert
|
||||
valid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeLeafHash_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var item = CreateEvidenceItem("sha256:test", "vex-doc", "https://example.com");
|
||||
|
||||
// Act
|
||||
var hash1 = _builder.ComputeLeafHash(item);
|
||||
var hash2 = _builder.ComputeLeafHash(item);
|
||||
|
||||
// Assert
|
||||
hash1.Should().BeEquivalentTo(hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeLeafHash_DifferentItemsProduceDifferentHashes()
|
||||
{
|
||||
// Arrange
|
||||
var item1 = CreateEvidenceItem("sha256:item1");
|
||||
var item2 = CreateEvidenceItem("sha256:item2");
|
||||
|
||||
// Act
|
||||
var hash1 = _builder.ComputeLeafHash(item1);
|
||||
var hash2 = _builder.ComputeLeafHash(item2);
|
||||
|
||||
// Assert
|
||||
hash1.Should().NotBeEquivalentTo(hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateChain_WithMatchingRoot_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var items = new[]
|
||||
{
|
||||
CreateEvidenceItem("sha256:aaa"),
|
||||
CreateEvidenceItem("sha256:bbb")
|
||||
};
|
||||
var tree = _builder.Build(items);
|
||||
var chain = tree.ToEvidenceChain(items.OrderBy(i => i.Digest).ToList());
|
||||
|
||||
// Act
|
||||
var valid = _builder.ValidateChain(chain);
|
||||
|
||||
// Assert
|
||||
valid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateChain_WithMismatchedRoot_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var items = new[] { CreateEvidenceItem("sha256:aaa") };
|
||||
var chain = new TrustEvidenceChain
|
||||
{
|
||||
MerkleRoot = "sha256:wrongroot",
|
||||
Items = items.ToList()
|
||||
};
|
||||
|
||||
// Act
|
||||
var valid = _builder.ValidateChain(chain);
|
||||
|
||||
// Assert
|
||||
valid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
[InlineData(7)]
|
||||
[InlineData(8)]
|
||||
[InlineData(15)]
|
||||
[InlineData(16)]
|
||||
public void Build_WithVariousItemCounts_ProducesValidTree(int count)
|
||||
{
|
||||
// Arrange
|
||||
var items = Enumerable.Range(1, count)
|
||||
.Select(i => CreateEvidenceItem($"sha256:{i:D8}"))
|
||||
.ToArray();
|
||||
|
||||
// Act
|
||||
var tree = _builder.Build(items);
|
||||
|
||||
// Assert
|
||||
tree.LeafCount.Should().Be(count);
|
||||
tree.Root.Should().StartWith("sha256:");
|
||||
tree.NodeCount.Should().BeGreaterThan(0);
|
||||
|
||||
// Verify all proofs work
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var proof = tree.GenerateProof(i);
|
||||
var sortedItems = items.OrderBy(x => x.Digest).ToList();
|
||||
var valid = _builder.VerifyProof(sortedItems[i], proof, tree.Root);
|
||||
valid.Should().BeTrue($"proof for index {i} should be valid");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToEvidenceChain_PreservesItems()
|
||||
{
|
||||
// Arrange
|
||||
var items = new[]
|
||||
{
|
||||
CreateEvidenceItem("sha256:aaa"),
|
||||
CreateEvidenceItem("sha256:bbb")
|
||||
};
|
||||
var tree = _builder.Build(items);
|
||||
|
||||
// Act
|
||||
var chain = tree.ToEvidenceChain(items.ToList());
|
||||
|
||||
// Assert
|
||||
chain.MerkleRoot.Should().Be(tree.Root);
|
||||
chain.Items.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
private static TrustEvidenceItem CreateEvidenceItem(
|
||||
string digest,
|
||||
string type = TrustEvidenceTypes.VexDocument,
|
||||
string? uri = null)
|
||||
{
|
||||
return new TrustEvidenceItem
|
||||
{
|
||||
Type = type,
|
||||
Digest = digest,
|
||||
Uri = uri,
|
||||
CollectedAt = new DateTimeOffset(2025, 1, 15, 12, 0, 0, TimeSpan.Zero)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
// TrustVerdictCacheTests - Unit tests for verdict caching
|
||||
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Attestor.TrustVerdict.Caching;
|
||||
using StellaOps.Attestor.TrustVerdict.Predicates;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.TrustVerdict.Tests;
|
||||
|
||||
public class TrustVerdictCacheTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly IOptionsMonitor<TrustVerdictCacheOptions> _options;
|
||||
private readonly InMemoryTrustVerdictCache _cache;
|
||||
|
||||
public TrustVerdictCacheTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 12, 0, 0, TimeSpan.Zero));
|
||||
_options = CreateOptions(new TrustVerdictCacheOptions
|
||||
{
|
||||
MaxEntries = 100,
|
||||
DefaultTtl = TimeSpan.FromHours(1)
|
||||
});
|
||||
_cache = new InMemoryTrustVerdictCache(_options, _timeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAndGet_ReturnsStoredEntry()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateCacheEntry("sha256:verdict1", "sha256:vex1", "tenant1");
|
||||
|
||||
// Act
|
||||
await _cache.SetAsync(entry);
|
||||
var result = await _cache.GetAsync("sha256:verdict1");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.VerdictDigest.Should().Be("sha256:verdict1");
|
||||
result.VexDigest.Should().Be("sha256:vex1");
|
||||
result.TenantId.Should().Be("tenant1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_NonexistentKey_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = await _cache.GetAsync("sha256:nonexistent");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByVexDigest_ReturnsMatchingEntry()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateCacheEntry("sha256:verdict1", "sha256:vex1", "tenant1");
|
||||
await _cache.SetAsync(entry);
|
||||
|
||||
// Act
|
||||
var result = await _cache.GetByVexDigestAsync("sha256:vex1", "tenant1");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.VerdictDigest.Should().Be("sha256:verdict1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByVexDigest_WrongTenant_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateCacheEntry("sha256:verdict1", "sha256:vex1", "tenant1");
|
||||
await _cache.SetAsync(entry);
|
||||
|
||||
// Act
|
||||
var result = await _cache.GetByVexDigestAsync("sha256:vex1", "tenant2");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_ExpiredEntry_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateCacheEntry("sha256:verdict1", "sha256:vex1", "tenant1",
|
||||
expiresAt: _timeProvider.GetUtcNow().AddMinutes(30));
|
||||
await _cache.SetAsync(entry);
|
||||
|
||||
// Advance time past expiration
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(31));
|
||||
|
||||
// Act
|
||||
var result = await _cache.GetAsync("sha256:verdict1");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invalidate_RemovesEntry()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateCacheEntry("sha256:verdict1", "sha256:vex1", "tenant1");
|
||||
await _cache.SetAsync(entry);
|
||||
|
||||
// Act
|
||||
await _cache.InvalidateAsync("sha256:verdict1");
|
||||
var result = await _cache.GetAsync("sha256:verdict1");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidateByVexDigest_RemovesEntry()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateCacheEntry("sha256:verdict1", "sha256:vex1", "tenant1");
|
||||
await _cache.SetAsync(entry);
|
||||
|
||||
// Act
|
||||
await _cache.InvalidateByVexDigestAsync("sha256:vex1", "tenant1");
|
||||
var result = await _cache.GetByVexDigestAsync("sha256:vex1", "tenant1");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBatch_ReturnsAllCachedEntries()
|
||||
{
|
||||
// Arrange
|
||||
await _cache.SetAsync(CreateCacheEntry("sha256:v1", "sha256:vex1", "tenant1"));
|
||||
await _cache.SetAsync(CreateCacheEntry("sha256:v2", "sha256:vex2", "tenant1"));
|
||||
await _cache.SetAsync(CreateCacheEntry("sha256:v3", "sha256:vex3", "tenant1"));
|
||||
|
||||
// Act
|
||||
var results = await _cache.GetBatchAsync(
|
||||
["sha256:vex1", "sha256:vex2", "sha256:vex4"],
|
||||
"tenant1");
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(2);
|
||||
results.Should().ContainKey("sha256:vex1");
|
||||
results.Should().ContainKey("sha256:vex2");
|
||||
results.Should().NotContainKey("sha256:vex4");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Set_EvictsOldestWhenFull()
|
||||
{
|
||||
// Arrange - Options with max 3 entries
|
||||
var options = CreateOptions(new TrustVerdictCacheOptions { MaxEntries = 3 });
|
||||
var cache = new InMemoryTrustVerdictCache(options, _timeProvider);
|
||||
|
||||
await cache.SetAsync(CreateCacheEntry("sha256:v1", "sha256:vex1", "tenant1",
|
||||
cachedAt: _timeProvider.GetUtcNow()));
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await cache.SetAsync(CreateCacheEntry("sha256:v2", "sha256:vex2", "tenant1",
|
||||
cachedAt: _timeProvider.GetUtcNow()));
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await cache.SetAsync(CreateCacheEntry("sha256:v3", "sha256:vex3", "tenant1",
|
||||
cachedAt: _timeProvider.GetUtcNow()));
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
|
||||
// Act - Add 4th entry, should evict oldest (v1)
|
||||
await cache.SetAsync(CreateCacheEntry("sha256:v4", "sha256:vex4", "tenant1",
|
||||
cachedAt: _timeProvider.GetUtcNow()));
|
||||
|
||||
// Assert
|
||||
var result1 = await cache.GetAsync("sha256:v1");
|
||||
var result4 = await cache.GetAsync("sha256:v4");
|
||||
|
||||
result1.Should().BeNull("oldest entry should be evicted");
|
||||
result4.Should().NotBeNull("new entry should be cached");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStats_ReturnsAccurateStats()
|
||||
{
|
||||
// Arrange
|
||||
await _cache.SetAsync(CreateCacheEntry("sha256:v1", "sha256:vex1", "tenant1"));
|
||||
await _cache.SetAsync(CreateCacheEntry("sha256:v2", "sha256:vex2", "tenant1"));
|
||||
|
||||
// Generate hits and misses
|
||||
await _cache.GetAsync("sha256:v1"); // hit
|
||||
await _cache.GetAsync("sha256:v1"); // hit
|
||||
await _cache.GetAsync("sha256:missing"); // miss
|
||||
|
||||
// Act
|
||||
var stats = await _cache.GetStatsAsync();
|
||||
|
||||
// Assert
|
||||
stats.TotalEntries.Should().Be(2);
|
||||
stats.TotalHits.Should().Be(2);
|
||||
stats.TotalMisses.Should().Be(1);
|
||||
stats.HitRatio.Should().BeApproximately(0.666, 0.01);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Set_UpdatesExistingEntry()
|
||||
{
|
||||
// Arrange
|
||||
var entry1 = CreateCacheEntry("sha256:verdict1", "sha256:vex1", "tenant1");
|
||||
await _cache.SetAsync(entry1);
|
||||
|
||||
// Create updated entry with same key
|
||||
var entry2 = entry1 with
|
||||
{
|
||||
Predicate = entry1.Predicate with
|
||||
{
|
||||
Composite = entry1.Predicate.Composite with { Score = 0.99m }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
await _cache.SetAsync(entry2);
|
||||
var result = await _cache.GetAsync("sha256:verdict1");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Predicate.Composite.Score.Should().Be(0.99m);
|
||||
}
|
||||
|
||||
private TrustVerdictCacheEntry CreateCacheEntry(
|
||||
string verdictDigest,
|
||||
string vexDigest,
|
||||
string tenantId,
|
||||
DateTimeOffset? cachedAt = null,
|
||||
DateTimeOffset? expiresAt = null)
|
||||
{
|
||||
var now = cachedAt ?? _timeProvider.GetUtcNow();
|
||||
return new TrustVerdictCacheEntry
|
||||
{
|
||||
VerdictDigest = verdictDigest,
|
||||
VexDigest = vexDigest,
|
||||
TenantId = tenantId,
|
||||
CachedAt = now,
|
||||
ExpiresAt = expiresAt ?? now.AddHours(1),
|
||||
Predicate = new TrustVerdictPredicate
|
||||
{
|
||||
SchemaVersion = "1.0.0",
|
||||
Subject = new TrustVerdictSubject
|
||||
{
|
||||
VexDigest = vexDigest,
|
||||
VexFormat = "openvex",
|
||||
ProviderId = "test-provider",
|
||||
StatementId = "stmt-1",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
ProductKey = "pkg:npm/test@1.0.0"
|
||||
},
|
||||
Origin = new OriginVerification { Valid = true, Method = "dsse", Score = 1.0m },
|
||||
Freshness = new FreshnessEvaluation
|
||||
{
|
||||
Status = "fresh",
|
||||
IssuedAt = now,
|
||||
AgeInDays = 0,
|
||||
Score = 1.0m
|
||||
},
|
||||
Reputation = new ReputationScore
|
||||
{
|
||||
Composite = 0.8m,
|
||||
Authority = 0.8m, Accuracy = 0.8m, Timeliness = 0.8m,
|
||||
Coverage = 0.8m, Verification = 0.8m,
|
||||
ComputedAt = now, SampleCount = 100
|
||||
},
|
||||
Composite = new TrustComposite
|
||||
{
|
||||
Score = 0.9m,
|
||||
Tier = "high",
|
||||
Reasons = ["Test reason"],
|
||||
Formula = "test"
|
||||
},
|
||||
Evidence = new TrustEvidenceChain { MerkleRoot = "sha256:root", Items = [] },
|
||||
Metadata = new TrustEvaluationMetadata
|
||||
{
|
||||
EvaluatedAt = now,
|
||||
EvaluatorVersion = "1.0.0",
|
||||
CryptoProfile = "world",
|
||||
TenantId = tenantId
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static IOptionsMonitor<TrustVerdictCacheOptions> CreateOptions(TrustVerdictCacheOptions options)
|
||||
{
|
||||
var monitor = new Moq.Mock<IOptionsMonitor<TrustVerdictCacheOptions>>();
|
||||
monitor.Setup(m => m.CurrentValue).Returns(options);
|
||||
return monitor.Object;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
// TrustVerdictServiceTests - Unit tests for TrustVerdictService
|
||||
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Attestor.TrustVerdict.Predicates;
|
||||
using StellaOps.Attestor.TrustVerdict.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.TrustVerdict.Tests;
|
||||
|
||||
public class TrustVerdictServiceTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly IOptionsMonitor<TrustVerdictServiceOptions> _options;
|
||||
private readonly TrustVerdictService _service;
|
||||
|
||||
public TrustVerdictServiceTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 12, 0, 0, TimeSpan.Zero));
|
||||
_options = CreateOptions(new TrustVerdictServiceOptions { EvaluatorVersion = "1.0.0-test" });
|
||||
_service = new TrustVerdictService(_options, NullLogger<TrustVerdictService>.Instance, _timeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateVerdictAsync_WithValidInput_ReturnsSuccessResult()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidRequest();
|
||||
|
||||
// Act
|
||||
var result = await _service.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Predicate.Should().NotBeNull();
|
||||
result.VerdictDigest.Should().StartWith("sha256:");
|
||||
result.ErrorMessage.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateVerdictAsync_SetsCorrectPredicateType()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidRequest();
|
||||
|
||||
// Act
|
||||
var result = await _service.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
TrustVerdictPredicate.PredicateType.Should().Be("https://stellaops.dev/predicates/trust-verdict@v1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateVerdictAsync_CopiesSubjectFields()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidRequest();
|
||||
|
||||
// Act
|
||||
var result = await _service.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
var subject = result.Predicate!.Subject;
|
||||
subject.VexDigest.Should().Be(request.VexDigest);
|
||||
subject.VexFormat.Should().Be(request.VexFormat);
|
||||
subject.ProviderId.Should().Be(request.ProviderId);
|
||||
subject.StatementId.Should().Be(request.StatementId);
|
||||
subject.VulnerabilityId.Should().Be(request.VulnerabilityId);
|
||||
subject.ProductKey.Should().Be(request.ProductKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateVerdictAsync_WithVerifiedSignature_SetsOriginScoreToOne()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidRequest() with
|
||||
{
|
||||
Origin = new TrustVerdictOriginInput
|
||||
{
|
||||
Valid = true,
|
||||
Method = VerificationMethods.Dsse,
|
||||
KeyId = "key-123"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Predicate!.Origin.Score.Should().Be(1.0m);
|
||||
result.Predicate.Origin.Valid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateVerdictAsync_WithUnverifiedSignature_SetsOriginScoreToZero()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidRequest() with
|
||||
{
|
||||
Origin = new TrustVerdictOriginInput
|
||||
{
|
||||
Valid = false,
|
||||
Method = VerificationMethods.Dsse,
|
||||
FailureReason = "Invalid signature"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Predicate!.Origin.Score.Should().Be(0.0m);
|
||||
result.Predicate.Origin.Valid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(FreshnessStatuses.Fresh, 0, 1.0)]
|
||||
[InlineData(FreshnessStatuses.Stale, 0, 0.6)]
|
||||
[InlineData(FreshnessStatuses.Superseded, 0, 0.3)]
|
||||
[InlineData(FreshnessStatuses.Expired, 0, 0.1)]
|
||||
public async Task GenerateVerdictAsync_ComputesFreshnessScoreCorrectly(
|
||||
string status,
|
||||
int ageInDays,
|
||||
double expectedBaseScore)
|
||||
{
|
||||
// Arrange
|
||||
var issuedAt = _timeProvider.GetUtcNow().AddDays(-ageInDays);
|
||||
var request = CreateValidRequest() with
|
||||
{
|
||||
Freshness = new TrustVerdictFreshnessInput
|
||||
{
|
||||
Status = status,
|
||||
IssuedAt = issuedAt
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Predicate!.Freshness.Status.Should().Be(status);
|
||||
result.Predicate.Freshness.Score.Should().BeApproximately((decimal)expectedBaseScore, 0.001m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateVerdictAsync_AppliesAgeDecayToFreshnessScore()
|
||||
{
|
||||
// Arrange - 90 days old should decay to ~50% of base score
|
||||
var issuedAt = _timeProvider.GetUtcNow().AddDays(-90);
|
||||
var request = CreateValidRequest() with
|
||||
{
|
||||
Freshness = new TrustVerdictFreshnessInput
|
||||
{
|
||||
Status = FreshnessStatuses.Fresh,
|
||||
IssuedAt = issuedAt
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
// Fresh base score (1.0) * decay(90 days, 90-day half-life) ≈ 0.368
|
||||
result.Predicate!.Freshness.Score.Should().BeLessThan(0.5m);
|
||||
result.Predicate.Freshness.AgeInDays.Should().Be(90);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateVerdictAsync_ComputesReputationComposite()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidRequest() with
|
||||
{
|
||||
Reputation = new TrustVerdictReputationInput
|
||||
{
|
||||
Authority = 0.9m,
|
||||
Accuracy = 0.85m,
|
||||
Timeliness = 0.8m,
|
||||
Coverage = 0.75m,
|
||||
Verification = 0.7m,
|
||||
ComputedAt = _timeProvider.GetUtcNow(),
|
||||
SampleCount = 100
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
// Weighted: 0.25*0.9 + 0.30*0.85 + 0.15*0.8 + 0.15*0.75 + 0.15*0.7
|
||||
// = 0.225 + 0.255 + 0.12 + 0.1125 + 0.105 = 0.8175
|
||||
result.Predicate!.Reputation.Composite.Should().BeApproximately(0.818m, 0.001m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateVerdictAsync_ComputesCompositeScore()
|
||||
{
|
||||
// Arrange - All factors at max
|
||||
var request = CreateValidRequest() with
|
||||
{
|
||||
Origin = new TrustVerdictOriginInput { Valid = true, Method = VerificationMethods.Dsse },
|
||||
Freshness = new TrustVerdictFreshnessInput
|
||||
{
|
||||
Status = FreshnessStatuses.Fresh,
|
||||
IssuedAt = _timeProvider.GetUtcNow()
|
||||
},
|
||||
Reputation = new TrustVerdictReputationInput
|
||||
{
|
||||
Authority = 1.0m,
|
||||
Accuracy = 1.0m,
|
||||
Timeliness = 1.0m,
|
||||
Coverage = 1.0m,
|
||||
Verification = 1.0m,
|
||||
ComputedAt = _timeProvider.GetUtcNow(),
|
||||
SampleCount = 100
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
// Formula: 0.50*Origin + 0.30*Freshness + 0.20*Reputation
|
||||
// = 0.50*1.0 + 0.30*1.0 + 0.20*1.0 = 1.0
|
||||
result.Predicate!.Composite.Score.Should().Be(1.0m);
|
||||
result.Predicate.Composite.Tier.Should().Be(TrustTiers.VeryHigh);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.95, TrustTiers.VeryHigh)]
|
||||
[InlineData(0.85, TrustTiers.High)]
|
||||
[InlineData(0.65, TrustTiers.Medium)]
|
||||
[InlineData(0.45, TrustTiers.Low)]
|
||||
[InlineData(0.15, TrustTiers.VeryLow)]
|
||||
public void TrustTiers_FromScore_ReturnsCorrectTier(double score, string expectedTier)
|
||||
{
|
||||
// Act
|
||||
var tier = TrustTiers.FromScore((decimal)score);
|
||||
|
||||
// Assert
|
||||
tier.Should().Be(expectedTier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateVerdictAsync_SetsMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidRequest() with
|
||||
{
|
||||
Options = new TrustVerdictOptions
|
||||
{
|
||||
TenantId = "tenant-123",
|
||||
CryptoProfile = "fips",
|
||||
Environment = "production",
|
||||
PolicyDigest = "sha256:abc123",
|
||||
CorrelationId = "corr-456"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
var metadata = result.Predicate!.Metadata;
|
||||
metadata.TenantId.Should().Be("tenant-123");
|
||||
metadata.CryptoProfile.Should().Be("fips");
|
||||
metadata.Environment.Should().Be("production");
|
||||
metadata.PolicyDigest.Should().Be("sha256:abc123");
|
||||
metadata.CorrelationId.Should().Be("corr-456");
|
||||
metadata.EvaluatorVersion.Should().Be("1.0.0-test");
|
||||
metadata.EvaluatedAt.Should().Be(_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateVerdictAsync_BuildsEvidenceChain()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidRequest() with
|
||||
{
|
||||
EvidenceItems =
|
||||
[
|
||||
new TrustVerdictEvidenceInput
|
||||
{
|
||||
Type = TrustEvidenceTypes.VexDocument,
|
||||
Digest = "sha256:vex123",
|
||||
Uri = "https://example.com/vex/123"
|
||||
},
|
||||
new TrustVerdictEvidenceInput
|
||||
{
|
||||
Type = TrustEvidenceTypes.Signature,
|
||||
Digest = "sha256:sig456",
|
||||
Description = "DSSE signature bundle"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Predicate!.Evidence.Items.Should().HaveCount(2);
|
||||
result.Predicate.Evidence.MerkleRoot.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateVerdictAsync_EvidenceIsSortedByDigest()
|
||||
{
|
||||
// Arrange - Items in reverse digest order
|
||||
var request = CreateValidRequest() with
|
||||
{
|
||||
EvidenceItems =
|
||||
[
|
||||
new TrustVerdictEvidenceInput { Type = "type1", Digest = "sha256:zzz" },
|
||||
new TrustVerdictEvidenceInput { Type = "type2", Digest = "sha256:aaa" },
|
||||
new TrustVerdictEvidenceInput { Type = "type3", Digest = "sha256:mmm" }
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
var digests = result.Predicate!.Evidence.Items.Select(i => i.Digest).ToList();
|
||||
digests.Should().BeInAscendingOrder();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateVerdictAsync_ChecksPolicyThreshold()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidRequest() with
|
||||
{
|
||||
Origin = new TrustVerdictOriginInput { Valid = true, Method = VerificationMethods.Dsse },
|
||||
Freshness = new TrustVerdictFreshnessInput
|
||||
{
|
||||
Status = FreshnessStatuses.Fresh,
|
||||
IssuedAt = _timeProvider.GetUtcNow()
|
||||
},
|
||||
Reputation = new TrustVerdictReputationInput
|
||||
{
|
||||
Authority = 0.8m, Accuracy = 0.8m, Timeliness = 0.8m,
|
||||
Coverage = 0.8m, Verification = 0.8m,
|
||||
ComputedAt = _timeProvider.GetUtcNow(), SampleCount = 50
|
||||
},
|
||||
Options = new TrustVerdictOptions
|
||||
{
|
||||
TenantId = "test",
|
||||
CryptoProfile = "world",
|
||||
PolicyThreshold = 0.7m
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GenerateVerdictAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Predicate!.Composite.MeetsPolicyThreshold.Should().BeTrue();
|
||||
result.Predicate.Composite.PolicyThreshold.Should().Be(0.7m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateBatchAsync_ProcessesMultipleRequests()
|
||||
{
|
||||
// Arrange
|
||||
var requests = Enumerable.Range(1, 5)
|
||||
.Select(i => CreateValidRequest() with
|
||||
{
|
||||
VexDigest = $"sha256:vex{i}",
|
||||
StatementId = $"stmt-{i}"
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Act
|
||||
var results = await _service.GenerateBatchAsync(requests);
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(5);
|
||||
results.Should().OnlyContain(r => r.Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeVerdictDigest_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var predicate = new TrustVerdictPredicate
|
||||
{
|
||||
SchemaVersion = "1.0.0",
|
||||
Subject = new TrustVerdictSubject
|
||||
{
|
||||
VexDigest = "sha256:test",
|
||||
VexFormat = "openvex",
|
||||
ProviderId = "provider-1",
|
||||
StatementId = "stmt-1",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
ProductKey = "pkg:npm/example@1.0.0"
|
||||
},
|
||||
Origin = new OriginVerification { Valid = true, Method = "dsse", Score = 1.0m },
|
||||
Freshness = new FreshnessEvaluation
|
||||
{
|
||||
Status = "fresh",
|
||||
IssuedAt = new DateTimeOffset(2025, 1, 15, 0, 0, 0, TimeSpan.Zero),
|
||||
AgeInDays = 0,
|
||||
Score = 1.0m
|
||||
},
|
||||
Reputation = new ReputationScore
|
||||
{
|
||||
Composite = 0.8m,
|
||||
Authority = 0.8m, Accuracy = 0.8m, Timeliness = 0.8m,
|
||||
Coverage = 0.8m, Verification = 0.8m,
|
||||
ComputedAt = new DateTimeOffset(2025, 1, 15, 0, 0, 0, TimeSpan.Zero),
|
||||
SampleCount = 100
|
||||
},
|
||||
Composite = new TrustComposite
|
||||
{
|
||||
Score = 0.9m,
|
||||
Tier = "high",
|
||||
Reasons = ["Verified signature"],
|
||||
Formula = "test"
|
||||
},
|
||||
Evidence = new TrustEvidenceChain { MerkleRoot = "sha256:root", Items = [] },
|
||||
Metadata = new TrustEvaluationMetadata
|
||||
{
|
||||
EvaluatedAt = new DateTimeOffset(2025, 1, 15, 0, 0, 0, TimeSpan.Zero),
|
||||
EvaluatorVersion = "1.0.0",
|
||||
CryptoProfile = "world",
|
||||
TenantId = "tenant-1"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var digest1 = _service.ComputeVerdictDigest(predicate);
|
||||
var digest2 = _service.ComputeVerdictDigest(predicate);
|
||||
|
||||
// Assert
|
||||
digest1.Should().Be(digest2);
|
||||
digest1.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
private TrustVerdictRequest CreateValidRequest() => new()
|
||||
{
|
||||
VexDigest = "sha256:abc123def456",
|
||||
VexFormat = "openvex",
|
||||
ProviderId = "github-security-advisories",
|
||||
StatementId = "stmt-2024-001",
|
||||
VulnerabilityId = "CVE-2024-12345",
|
||||
ProductKey = "pkg:npm/example@1.0.0",
|
||||
VexStatus = "not_affected",
|
||||
Origin = new TrustVerdictOriginInput
|
||||
{
|
||||
Valid = true,
|
||||
Method = VerificationMethods.Dsse,
|
||||
KeyId = "key-123",
|
||||
IssuerName = "GitHub Security"
|
||||
},
|
||||
Freshness = new TrustVerdictFreshnessInput
|
||||
{
|
||||
Status = FreshnessStatuses.Fresh,
|
||||
IssuedAt = _timeProvider.GetUtcNow()
|
||||
},
|
||||
Reputation = new TrustVerdictReputationInput
|
||||
{
|
||||
Authority = 0.9m,
|
||||
Accuracy = 0.85m,
|
||||
Timeliness = 0.8m,
|
||||
Coverage = 0.75m,
|
||||
Verification = 0.8m,
|
||||
ComputedAt = _timeProvider.GetUtcNow(),
|
||||
SampleCount = 500
|
||||
},
|
||||
EvidenceItems = [],
|
||||
Options = new TrustVerdictOptions
|
||||
{
|
||||
TenantId = "test-tenant",
|
||||
CryptoProfile = "world"
|
||||
}
|
||||
};
|
||||
|
||||
private static IOptionsMonitor<TrustVerdictServiceOptions> CreateOptions(TrustVerdictServiceOptions options)
|
||||
{
|
||||
var monitor = new Moq.Mock<IOptionsMonitor<TrustVerdictServiceOptions>>();
|
||||
monitor.Setup(m => m.CurrentValue).Returns(options);
|
||||
return monitor.Object;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
// TrustVerdictCache - Valkey-backed cache for TrustVerdict lookups
|
||||
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.TrustVerdict.Predicates;
|
||||
|
||||
namespace StellaOps.Attestor.TrustVerdict.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Cache for TrustVerdict predicates, enabling fast lookups by digest.
|
||||
/// </summary>
|
||||
public interface ITrustVerdictCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Get a cached verdict by its digest.
|
||||
/// </summary>
|
||||
/// <param name="verdictDigest">Deterministic verdict digest.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Cached verdict or null if not found.</returns>
|
||||
Task<TrustVerdictCacheEntry?> GetAsync(string verdictDigest, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get a verdict by VEX digest (content-addressed lookup).
|
||||
/// </summary>
|
||||
/// <param name="vexDigest">VEX document digest.</param>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Cached verdict or null if not found.</returns>
|
||||
Task<TrustVerdictCacheEntry?> GetByVexDigestAsync(
|
||||
string vexDigest,
|
||||
string tenantId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Store a verdict in cache.
|
||||
/// </summary>
|
||||
/// <param name="entry">The cache entry to store.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task SetAsync(TrustVerdictCacheEntry entry, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Invalidate a cached verdict.
|
||||
/// </summary>
|
||||
/// <param name="verdictDigest">Verdict digest to invalidate.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task InvalidateAsync(string verdictDigest, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Invalidate all verdicts for a VEX document.
|
||||
/// </summary>
|
||||
/// <param name="vexDigest">VEX document digest.</param>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task InvalidateByVexDigestAsync(string vexDigest, string tenantId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Batch get verdicts by VEX digests.
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<string, TrustVerdictCacheEntry>> GetBatchAsync(
|
||||
IEnumerable<string> vexDigests,
|
||||
string tenantId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get cache statistics.
|
||||
/// </summary>
|
||||
Task<TrustVerdictCacheStats> GetStatsAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A cached TrustVerdict entry.
|
||||
/// </summary>
|
||||
public sealed record TrustVerdictCacheEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Deterministic verdict digest.
|
||||
/// </summary>
|
||||
public required string VerdictDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX document digest.
|
||||
/// </summary>
|
||||
public required string VexDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The cached predicate.
|
||||
/// </summary>
|
||||
public required TrustVerdictPredicate Predicate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signed envelope if available (base64).
|
||||
/// </summary>
|
||||
public string? EnvelopeBase64 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the entry was cached.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CachedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the entry expires.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hit count for analytics.
|
||||
/// </summary>
|
||||
public int HitCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cache statistics.
|
||||
/// </summary>
|
||||
public sealed record TrustVerdictCacheStats
|
||||
{
|
||||
public long TotalEntries { get; init; }
|
||||
public long TotalHits { get; init; }
|
||||
public long TotalMisses { get; init; }
|
||||
public long TotalEvictions { get; init; }
|
||||
public double HitRatio => TotalHits + TotalMisses > 0
|
||||
? (double)TotalHits / (TotalHits + TotalMisses)
|
||||
: 0;
|
||||
public long MemoryUsedBytes { get; init; }
|
||||
public DateTimeOffset CollectedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of ITrustVerdictCache for development/testing.
|
||||
/// Production should use ValkeyTrustVerdictCache.
|
||||
/// </summary>
|
||||
public sealed class InMemoryTrustVerdictCache : ITrustVerdictCache
|
||||
{
|
||||
private readonly Dictionary<string, TrustVerdictCacheEntry> _byVerdictDigest = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, string> _vexToVerdictIndex = new(StringComparer.Ordinal);
|
||||
private readonly object _lock = new();
|
||||
private readonly IOptionsMonitor<TrustVerdictCacheOptions> _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private long _hitCount;
|
||||
private long _missCount;
|
||||
private long _evictionCount;
|
||||
|
||||
public InMemoryTrustVerdictCache(
|
||||
IOptionsMonitor<TrustVerdictCacheOptions> options,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<TrustVerdictCacheEntry?> GetAsync(string verdictDigest, CancellationToken ct = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_byVerdictDigest.TryGetValue(verdictDigest, out var entry))
|
||||
{
|
||||
if (_timeProvider.GetUtcNow() < entry.ExpiresAt)
|
||||
{
|
||||
Interlocked.Increment(ref _hitCount);
|
||||
return Task.FromResult<TrustVerdictCacheEntry?>(entry with { HitCount = entry.HitCount + 1 });
|
||||
}
|
||||
|
||||
// Expired, remove
|
||||
_byVerdictDigest.Remove(verdictDigest);
|
||||
Interlocked.Increment(ref _evictionCount);
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _missCount);
|
||||
return Task.FromResult<TrustVerdictCacheEntry?>(null);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<TrustVerdictCacheEntry?> GetByVexDigestAsync(
|
||||
string vexDigest,
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var key = BuildVexKey(vexDigest, tenantId);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_vexToVerdictIndex.TryGetValue(key, out var verdictDigest))
|
||||
{
|
||||
return GetAsync(verdictDigest, ct);
|
||||
}
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _missCount);
|
||||
return Task.FromResult<TrustVerdictCacheEntry?>(null);
|
||||
}
|
||||
|
||||
public Task SetAsync(TrustVerdictCacheEntry entry, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
var options = _options.CurrentValue;
|
||||
var vexKey = BuildVexKey(entry.VexDigest, entry.TenantId);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
// Enforce max entries
|
||||
if (_byVerdictDigest.Count >= options.MaxEntries && !_byVerdictDigest.ContainsKey(entry.VerdictDigest))
|
||||
{
|
||||
EvictOldest();
|
||||
}
|
||||
|
||||
_byVerdictDigest[entry.VerdictDigest] = entry;
|
||||
_vexToVerdictIndex[vexKey] = entry.VerdictDigest;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task InvalidateAsync(string verdictDigest, CancellationToken ct = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_byVerdictDigest.TryGetValue(verdictDigest, out var entry))
|
||||
{
|
||||
_byVerdictDigest.Remove(verdictDigest);
|
||||
|
||||
var vexKey = BuildVexKey(entry.VexDigest, entry.TenantId);
|
||||
_vexToVerdictIndex.Remove(vexKey);
|
||||
|
||||
Interlocked.Increment(ref _evictionCount);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task InvalidateByVexDigestAsync(string vexDigest, string tenantId, CancellationToken ct = default)
|
||||
{
|
||||
var vexKey = BuildVexKey(vexDigest, tenantId);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_vexToVerdictIndex.TryGetValue(vexKey, out var verdictDigest))
|
||||
{
|
||||
_byVerdictDigest.Remove(verdictDigest);
|
||||
_vexToVerdictIndex.Remove(vexKey);
|
||||
Interlocked.Increment(ref _evictionCount);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyDictionary<string, TrustVerdictCacheEntry>> GetBatchAsync(
|
||||
IEnumerable<string> vexDigests,
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var results = new Dictionary<string, TrustVerdictCacheEntry>(StringComparer.Ordinal);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var vexDigest in vexDigests)
|
||||
{
|
||||
var vexKey = BuildVexKey(vexDigest, tenantId);
|
||||
|
||||
if (_vexToVerdictIndex.TryGetValue(vexKey, out var verdictDigest) &&
|
||||
_byVerdictDigest.TryGetValue(verdictDigest, out var entry) &&
|
||||
now < entry.ExpiresAt)
|
||||
{
|
||||
results[vexDigest] = entry;
|
||||
Interlocked.Increment(ref _hitCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
Interlocked.Increment(ref _missCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyDictionary<string, TrustVerdictCacheEntry>>(results);
|
||||
}
|
||||
|
||||
public Task<TrustVerdictCacheStats> GetStatsAsync(CancellationToken ct = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return Task.FromResult(new TrustVerdictCacheStats
|
||||
{
|
||||
TotalEntries = _byVerdictDigest.Count,
|
||||
TotalHits = _hitCount,
|
||||
TotalMisses = _missCount,
|
||||
TotalEvictions = _evictionCount,
|
||||
MemoryUsedBytes = EstimateMemoryUsage(),
|
||||
CollectedAt = _timeProvider.GetUtcNow()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildVexKey(string vexDigest, string tenantId)
|
||||
=> $"{tenantId}:{vexDigest}";
|
||||
|
||||
private void EvictOldest()
|
||||
{
|
||||
// Simple LRU-ish: evict entry with oldest CachedAt
|
||||
var oldest = _byVerdictDigest.Values
|
||||
.OrderBy(e => e.CachedAt)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (oldest != null)
|
||||
{
|
||||
_byVerdictDigest.Remove(oldest.VerdictDigest);
|
||||
var vexKey = BuildVexKey(oldest.VexDigest, oldest.TenantId);
|
||||
_vexToVerdictIndex.Remove(vexKey);
|
||||
Interlocked.Increment(ref _evictionCount);
|
||||
}
|
||||
}
|
||||
|
||||
private long EstimateMemoryUsage()
|
||||
{
|
||||
// Rough estimate: ~1KB per entry average
|
||||
return _byVerdictDigest.Count * 1024L;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Valkey-backed TrustVerdict cache (production use).
|
||||
/// </summary>
|
||||
public sealed class ValkeyTrustVerdictCache : ITrustVerdictCache, IAsyncDisposable
|
||||
{
|
||||
private readonly IOptionsMonitor<TrustVerdictCacheOptions> _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<ValkeyTrustVerdictCache> _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
// Note: In production, this would use StackExchange.Redis or similar Valkey client
|
||||
// For now, we delegate to in-memory as a fallback
|
||||
private readonly InMemoryTrustVerdictCache _fallback;
|
||||
|
||||
public ValkeyTrustVerdictCache(
|
||||
IOptionsMonitor<TrustVerdictCacheOptions> options,
|
||||
ILogger<ValkeyTrustVerdictCache> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
_fallback = new InMemoryTrustVerdictCache(options, timeProvider);
|
||||
}
|
||||
|
||||
public async Task<TrustVerdictCacheEntry?> GetAsync(string verdictDigest, CancellationToken ct = default)
|
||||
{
|
||||
var opts = _options.CurrentValue;
|
||||
|
||||
if (!opts.UseValkey)
|
||||
{
|
||||
return await _fallback.GetAsync(verdictDigest, ct);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// TODO: Implement Valkey lookup
|
||||
// var key = BuildKey(opts.KeyPrefix, "verdict", verdictDigest);
|
||||
// var value = await _valkeyClient.GetAsync(key);
|
||||
// if (value != null)
|
||||
// return JsonSerializer.Deserialize<TrustVerdictCacheEntry>(value, _jsonOptions);
|
||||
|
||||
return await _fallback.GetAsync(verdictDigest, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Valkey lookup failed for {Digest}, falling back to in-memory", verdictDigest);
|
||||
return await _fallback.GetAsync(verdictDigest, ct);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TrustVerdictCacheEntry?> GetByVexDigestAsync(
|
||||
string vexDigest,
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var opts = _options.CurrentValue;
|
||||
|
||||
if (!opts.UseValkey)
|
||||
{
|
||||
return await _fallback.GetByVexDigestAsync(vexDigest, tenantId, ct);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// TODO: Implement Valkey lookup via secondary index
|
||||
return await _fallback.GetByVexDigestAsync(vexDigest, tenantId, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Valkey lookup failed for VEX {Digest}, falling back", vexDigest);
|
||||
return await _fallback.GetByVexDigestAsync(vexDigest, tenantId, ct);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetAsync(TrustVerdictCacheEntry entry, CancellationToken ct = default)
|
||||
{
|
||||
var opts = _options.CurrentValue;
|
||||
|
||||
// Always set in fallback for local consistency
|
||||
await _fallback.SetAsync(entry, ct);
|
||||
|
||||
if (!opts.UseValkey)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// TODO: Implement Valkey SET with TTL
|
||||
// var key = BuildKey(opts.KeyPrefix, "verdict", entry.VerdictDigest);
|
||||
// var value = JsonSerializer.Serialize(entry, _jsonOptions);
|
||||
// await _valkeyClient.SetAsync(key, value, opts.DefaultTtl);
|
||||
|
||||
// Also set secondary index
|
||||
// var vexKey = BuildKey(opts.KeyPrefix, "vex", entry.TenantId, entry.VexDigest);
|
||||
// await _valkeyClient.SetAsync(vexKey, entry.VerdictDigest, opts.DefaultTtl);
|
||||
|
||||
_logger.LogDebug("Cached verdict {Digest} in Valkey", entry.VerdictDigest);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to cache verdict {Digest} in Valkey", entry.VerdictDigest);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task InvalidateAsync(string verdictDigest, CancellationToken ct = default)
|
||||
{
|
||||
await _fallback.InvalidateAsync(verdictDigest, ct);
|
||||
|
||||
var opts = _options.CurrentValue;
|
||||
if (!opts.UseValkey)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// TODO: Implement Valkey DEL
|
||||
_logger.LogDebug("Invalidated verdict {Digest} in Valkey", verdictDigest);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to invalidate verdict {Digest} in Valkey", verdictDigest);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task InvalidateByVexDigestAsync(string vexDigest, string tenantId, CancellationToken ct = default)
|
||||
{
|
||||
await _fallback.InvalidateByVexDigestAsync(vexDigest, tenantId, ct);
|
||||
|
||||
var opts = _options.CurrentValue;
|
||||
if (!opts.UseValkey)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// TODO: Implement Valkey DEL via secondary index
|
||||
_logger.LogDebug("Invalidated verdicts for VEX {Digest} in Valkey", vexDigest);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to invalidate VEX {Digest} in Valkey", vexDigest);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, TrustVerdictCacheEntry>> GetBatchAsync(
|
||||
IEnumerable<string> vexDigests,
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var opts = _options.CurrentValue;
|
||||
|
||||
if (!opts.UseValkey)
|
||||
{
|
||||
return await _fallback.GetBatchAsync(vexDigests, tenantId, ct);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// TODO: Implement Valkey MGET for batch lookup
|
||||
return await _fallback.GetBatchAsync(vexDigests, tenantId, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Valkey batch lookup failed, falling back");
|
||||
return await _fallback.GetBatchAsync(vexDigests, tenantId, ct);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<TrustVerdictCacheStats> GetStatsAsync(CancellationToken ct = default)
|
||||
{
|
||||
// TODO: Combine Valkey INFO stats with fallback stats
|
||||
return _fallback.GetStatsAsync(ct);
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
// TODO: Dispose Valkey client when implemented
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for TrustVerdict caching.
|
||||
/// </summary>
|
||||
public sealed class TrustVerdictCacheOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section key.
|
||||
/// </summary>
|
||||
public const string SectionKey = "TrustVerdictCache";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to use Valkey (production) or in-memory (dev/test).
|
||||
/// </summary>
|
||||
public bool UseValkey { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Valkey connection string.
|
||||
/// </summary>
|
||||
public string? ConnectionString { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Key prefix for namespacing.
|
||||
/// </summary>
|
||||
public string KeyPrefix { get; set; } = "stellaops:trustverdicts:";
|
||||
|
||||
/// <summary>
|
||||
/// Default TTL for cached entries.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultTtl { get; set; } = TimeSpan.FromHours(1);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum entries for in-memory cache.
|
||||
/// </summary>
|
||||
public int MaxEntries { get; set; } = 10_000;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable cache metrics.
|
||||
/// </summary>
|
||||
public bool EnableMetrics { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// TrustEvidenceMerkleBuilder - Merkle tree builder for evidence chains
|
||||
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
|
||||
|
||||
using System.Buffers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Attestor.TrustVerdict.Predicates;
|
||||
|
||||
namespace StellaOps.Attestor.TrustVerdict.Evidence;
|
||||
|
||||
/// <summary>
|
||||
/// Builder for constructing Merkle trees from trust evidence items.
|
||||
/// Provides deterministic, verifiable evidence chains for TrustVerdict attestations.
|
||||
/// </summary>
|
||||
public interface ITrustEvidenceMerkleBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Build a Merkle tree from evidence items.
|
||||
/// </summary>
|
||||
/// <param name="items">Evidence items to include.</param>
|
||||
/// <returns>The constructed tree with root and proof capabilities.</returns>
|
||||
TrustEvidenceMerkleTree Build(IEnumerable<TrustEvidenceItem> items);
|
||||
|
||||
/// <summary>
|
||||
/// Verify a Merkle proof for an evidence item.
|
||||
/// </summary>
|
||||
/// <param name="item">The item to verify.</param>
|
||||
/// <param name="proof">The inclusion proof.</param>
|
||||
/// <param name="root">Expected Merkle root.</param>
|
||||
/// <returns>True if the proof is valid.</returns>
|
||||
bool VerifyProof(TrustEvidenceItem item, MerkleProof proof, string root);
|
||||
|
||||
/// <summary>
|
||||
/// Compute the leaf hash for an evidence item.
|
||||
/// </summary>
|
||||
/// <param name="item">The evidence item.</param>
|
||||
/// <returns>SHA-256 hash of the canonical item representation.</returns>
|
||||
byte[] ComputeLeafHash(TrustEvidenceItem item);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of building a Merkle tree from evidence.
|
||||
/// </summary>
|
||||
public sealed class TrustEvidenceMerkleTree
|
||||
{
|
||||
/// <summary>
|
||||
/// The Merkle root hash (sha256:...).
|
||||
/// </summary>
|
||||
public required string Root { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ordered list of leaf hashes.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> LeafHashes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of leaves.
|
||||
/// </summary>
|
||||
public int LeafCount => LeafHashes.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Tree height (log2 of leaf count, rounded up).
|
||||
/// </summary>
|
||||
public int Height { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total nodes in the tree.
|
||||
/// </summary>
|
||||
public int NodeCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Internal tree structure for proof generation.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<IReadOnlyList<byte[]>> Levels { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Generate an inclusion proof for a leaf at the given index.
|
||||
/// </summary>
|
||||
/// <param name="leafIndex">Zero-based index of the leaf.</param>
|
||||
/// <returns>The Merkle proof.</returns>
|
||||
public MerkleProof GenerateProof(int leafIndex)
|
||||
{
|
||||
if (leafIndex < 0 || leafIndex >= LeafCount)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(leafIndex),
|
||||
$"Leaf index must be between 0 and {LeafCount - 1}");
|
||||
}
|
||||
|
||||
var siblings = new List<MerkleProofNode>();
|
||||
var currentIndex = leafIndex;
|
||||
|
||||
for (var level = 0; level < Levels.Count - 1; level++)
|
||||
{
|
||||
var currentLevel = Levels[level];
|
||||
var siblingIndex = currentIndex ^ 1; // XOR to get sibling
|
||||
|
||||
if (siblingIndex < currentLevel.Count)
|
||||
{
|
||||
var isLeft = currentIndex % 2 == 1;
|
||||
siblings.Add(new MerkleProofNode
|
||||
{
|
||||
Hash = $"sha256:{Convert.ToHexStringLower(currentLevel[siblingIndex])}",
|
||||
Position = isLeft ? MerkleNodePosition.Left : MerkleNodePosition.Right
|
||||
});
|
||||
}
|
||||
else if (currentIndex == currentLevel.Count - 1 && currentLevel.Count % 2 == 1)
|
||||
{
|
||||
// Odd last element: it was paired with itself during tree building
|
||||
// Include itself as sibling (always on the right since we're at even index due to being last odd)
|
||||
siblings.Add(new MerkleProofNode
|
||||
{
|
||||
Hash = $"sha256:{Convert.ToHexStringLower(currentLevel[currentIndex])}",
|
||||
Position = MerkleNodePosition.Right
|
||||
});
|
||||
}
|
||||
|
||||
currentIndex /= 2;
|
||||
}
|
||||
|
||||
return new MerkleProof
|
||||
{
|
||||
LeafIndex = leafIndex,
|
||||
LeafHash = LeafHashes[leafIndex],
|
||||
Root = Root,
|
||||
Siblings = siblings
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merkle inclusion proof for a single evidence item.
|
||||
/// </summary>
|
||||
public sealed record MerkleProof
|
||||
{
|
||||
/// <summary>
|
||||
/// Index of the leaf in the original list.
|
||||
/// </summary>
|
||||
public required int LeafIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash of the leaf node.
|
||||
/// </summary>
|
||||
public required string LeafHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected Merkle root.
|
||||
/// </summary>
|
||||
public required string Root { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sibling hashes for verification.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<MerkleProofNode> Siblings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A sibling node in a Merkle proof.
|
||||
/// </summary>
|
||||
public sealed record MerkleProofNode
|
||||
{
|
||||
/// <summary>
|
||||
/// Hash of the sibling.
|
||||
/// </summary>
|
||||
public required string Hash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Position of the sibling (left or right).
|
||||
/// </summary>
|
||||
public required MerkleNodePosition Position { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Position of a node in a Merkle tree.
|
||||
/// </summary>
|
||||
public enum MerkleNodePosition
|
||||
{
|
||||
Left,
|
||||
Right
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of ITrustEvidenceMerkleBuilder using SHA-256.
|
||||
/// </summary>
|
||||
public sealed class TrustEvidenceMerkleBuilder : ITrustEvidenceMerkleBuilder
|
||||
{
|
||||
private const string DigestPrefix = "sha256:";
|
||||
|
||||
/// <inheritdoc />
|
||||
public TrustEvidenceMerkleTree Build(IEnumerable<TrustEvidenceItem> items)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
|
||||
// Sort items deterministically by digest
|
||||
var sortedItems = items
|
||||
.OrderBy(i => i.Digest, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (sortedItems.Count == 0)
|
||||
{
|
||||
var emptyHash = SHA256.HashData([]);
|
||||
return new TrustEvidenceMerkleTree
|
||||
{
|
||||
Root = DigestPrefix + Convert.ToHexStringLower(emptyHash),
|
||||
LeafHashes = [],
|
||||
Height = 0,
|
||||
NodeCount = 1,
|
||||
Levels = [[emptyHash]]
|
||||
};
|
||||
}
|
||||
|
||||
// Compute leaf hashes
|
||||
var leafHashes = sortedItems
|
||||
.Select(ComputeLeafHash)
|
||||
.ToList();
|
||||
|
||||
// Build tree levels bottom-up
|
||||
var levels = new List<List<byte[]>> { new(leafHashes) };
|
||||
var currentLevel = leafHashes;
|
||||
|
||||
while (currentLevel.Count > 1)
|
||||
{
|
||||
var nextLevel = new List<byte[]>();
|
||||
|
||||
for (var i = 0; i < currentLevel.Count; i += 2)
|
||||
{
|
||||
if (i + 1 < currentLevel.Count)
|
||||
{
|
||||
nextLevel.Add(HashPair(currentLevel[i], currentLevel[i + 1]));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Odd node: hash with itself (standard padding)
|
||||
nextLevel.Add(HashPair(currentLevel[i], currentLevel[i]));
|
||||
}
|
||||
}
|
||||
|
||||
levels.Add(nextLevel);
|
||||
currentLevel = nextLevel;
|
||||
}
|
||||
|
||||
var root = currentLevel[0];
|
||||
var height = levels.Count - 1;
|
||||
var nodeCount = levels.Sum(l => l.Count);
|
||||
|
||||
return new TrustEvidenceMerkleTree
|
||||
{
|
||||
Root = DigestPrefix + Convert.ToHexStringLower(root),
|
||||
LeafHashes = leafHashes.Select(h => DigestPrefix + Convert.ToHexStringLower(h)).ToList(),
|
||||
Height = height,
|
||||
NodeCount = nodeCount,
|
||||
Levels = levels.Select(l => (IReadOnlyList<byte[]>)l.AsReadOnly()).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool VerifyProof(TrustEvidenceItem item, MerkleProof proof, string root)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
ArgumentNullException.ThrowIfNull(proof);
|
||||
|
||||
// Compute expected leaf hash
|
||||
var leafHash = ComputeLeafHash(item);
|
||||
var expectedLeafHashStr = DigestPrefix + Convert.ToHexStringLower(leafHash);
|
||||
|
||||
if (!string.Equals(expectedLeafHashStr, proof.LeafHash, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Walk up the tree using siblings
|
||||
var currentHash = leafHash;
|
||||
|
||||
foreach (var sibling in proof.Siblings)
|
||||
{
|
||||
var siblingHash = ParseHash(sibling.Hash);
|
||||
|
||||
currentHash = sibling.Position switch
|
||||
{
|
||||
MerkleNodePosition.Left => HashPair(siblingHash, currentHash),
|
||||
MerkleNodePosition.Right => HashPair(currentHash, siblingHash),
|
||||
_ => throw new ArgumentException($"Invalid node position: {sibling.Position}")
|
||||
};
|
||||
}
|
||||
|
||||
var computedRoot = DigestPrefix + Convert.ToHexStringLower(currentHash);
|
||||
return string.Equals(computedRoot, root, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public byte[] ComputeLeafHash(TrustEvidenceItem item)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
|
||||
// Canonical representation: type|digest|uri|description|collectedAt(ISO8601)
|
||||
var canonical = new StringBuilder();
|
||||
canonical.Append(item.Type ?? string.Empty);
|
||||
canonical.Append('|');
|
||||
canonical.Append(item.Digest ?? string.Empty);
|
||||
canonical.Append('|');
|
||||
canonical.Append(item.Uri ?? string.Empty);
|
||||
canonical.Append('|');
|
||||
canonical.Append(item.Description ?? string.Empty);
|
||||
canonical.Append('|');
|
||||
canonical.Append(item.CollectedAt?.ToString("o") ?? string.Empty);
|
||||
|
||||
return SHA256.HashData(Encoding.UTF8.GetBytes(canonical.ToString()));
|
||||
}
|
||||
|
||||
private static byte[] HashPair(byte[] left, byte[] right)
|
||||
{
|
||||
// Domain separation: prefix with 0x01 for internal nodes
|
||||
var combined = new byte[1 + left.Length + right.Length];
|
||||
combined[0] = 0x01;
|
||||
left.CopyTo(combined, 1);
|
||||
right.CopyTo(combined, 1 + left.Length);
|
||||
|
||||
return SHA256.HashData(combined);
|
||||
}
|
||||
|
||||
private static byte[] ParseHash(string hashStr)
|
||||
{
|
||||
if (hashStr.StartsWith(DigestPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
hashStr = hashStr[DigestPrefix.Length..];
|
||||
}
|
||||
|
||||
return Convert.FromHexString(hashStr);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for TrustEvidenceMerkleTree.
|
||||
/// </summary>
|
||||
public static class TrustEvidenceMerkleTreeExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Convert Merkle tree to the predicate chain format.
|
||||
/// </summary>
|
||||
public static TrustEvidenceChain ToEvidenceChain(
|
||||
this TrustEvidenceMerkleTree tree,
|
||||
IReadOnlyList<TrustEvidenceItem> items)
|
||||
{
|
||||
return new TrustEvidenceChain
|
||||
{
|
||||
MerkleRoot = tree.Root,
|
||||
Items = items
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate that the tree root matches the chain's declared root.
|
||||
/// </summary>
|
||||
public static bool ValidateChain(
|
||||
this ITrustEvidenceMerkleBuilder builder,
|
||||
TrustEvidenceChain chain)
|
||||
{
|
||||
if (chain.Items == null || chain.Items.Count == 0)
|
||||
{
|
||||
// Empty chain should have empty hash root
|
||||
var emptyTree = builder.Build([]);
|
||||
return string.Equals(emptyTree.Root, chain.MerkleRoot, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
var tree = builder.Build(chain.Items);
|
||||
return string.Equals(tree.Root, chain.MerkleRoot, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// JsonCanonicalizer - Deterministic JSON serialization for content addressing
|
||||
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
|
||||
|
||||
using System.Buffers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.TrustVerdict;
|
||||
|
||||
/// <summary>
|
||||
/// Produces RFC 8785 compliant canonical JSON for digest computation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Canonical form ensures:
|
||||
/// - Deterministic key ordering (lexicographic)
|
||||
/// - No whitespace between tokens
|
||||
/// - Numbers without exponent notation
|
||||
/// - Unicode escaping only where required
|
||||
/// - No duplicate keys
|
||||
/// </remarks>
|
||||
public static class JsonCanonicalizer
|
||||
{
|
||||
private static readonly JsonSerializerOptions s_canonicalOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Converters = { new SortedObjectConverter() }
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Serialize an object to canonical JSON string.
|
||||
/// </summary>
|
||||
public static string Canonicalize<T>(T value)
|
||||
{
|
||||
// First serialize to JSON document to get raw structure
|
||||
var json = JsonSerializer.Serialize(value, s_canonicalOptions);
|
||||
|
||||
// Re-parse and canonicalize
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return CanonicalizeElement(doc.RootElement);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonicalize a JSON string.
|
||||
/// </summary>
|
||||
public static string Canonicalize(string json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return CanonicalizeElement(doc.RootElement);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonicalize a JSON element to string.
|
||||
/// </summary>
|
||||
public static string CanonicalizeElement(JsonElement element)
|
||||
{
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
using var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions
|
||||
{
|
||||
Indented = false,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
});
|
||||
|
||||
WriteCanonical(writer, element);
|
||||
writer.Flush();
|
||||
|
||||
return Encoding.UTF8.GetString(buffer.WrittenSpan);
|
||||
}
|
||||
|
||||
private static void WriteCanonical(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
WriteCanonicalObject(writer, element);
|
||||
break;
|
||||
|
||||
case JsonValueKind.Array:
|
||||
WriteCanonicalArray(writer, element);
|
||||
break;
|
||||
|
||||
case JsonValueKind.String:
|
||||
writer.WriteStringValue(element.GetString());
|
||||
break;
|
||||
|
||||
case JsonValueKind.Number:
|
||||
WriteCanonicalNumber(writer, element);
|
||||
break;
|
||||
|
||||
case JsonValueKind.True:
|
||||
writer.WriteBooleanValue(true);
|
||||
break;
|
||||
|
||||
case JsonValueKind.False:
|
||||
writer.WriteBooleanValue(false);
|
||||
break;
|
||||
|
||||
case JsonValueKind.Null:
|
||||
writer.WriteNullValue();
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ArgumentException($"Unsupported JSON value kind: {element.ValueKind}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteCanonicalObject(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
|
||||
// Sort properties lexicographically by key
|
||||
var properties = element.EnumerateObject()
|
||||
.OrderBy(p => p.Name, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
foreach (var property in properties)
|
||||
{
|
||||
writer.WritePropertyName(property.Name);
|
||||
WriteCanonical(writer, property.Value);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteCanonicalArray(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
writer.WriteStartArray();
|
||||
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
WriteCanonical(writer, item);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
private static void WriteCanonicalNumber(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
// RFC 8785: Numbers must be represented without exponent notation
|
||||
// and with minimal significant digits
|
||||
if (element.TryGetInt64(out var longValue))
|
||||
{
|
||||
writer.WriteNumberValue(longValue);
|
||||
}
|
||||
else if (element.TryGetDecimal(out var decimalValue))
|
||||
{
|
||||
// Normalize to remove trailing zeros
|
||||
writer.WriteNumberValue(decimalValue);
|
||||
}
|
||||
else
|
||||
{
|
||||
writer.WriteRawValue(element.GetRawText());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Custom converter that ensures object properties are sorted.
|
||||
/// </summary>
|
||||
private sealed class SortedObjectConverter : JsonConverter<object>
|
||||
{
|
||||
public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
throw new NotSupportedException("Deserialization not supported");
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
writer.WriteNullValue();
|
||||
return;
|
||||
}
|
||||
|
||||
var type = value.GetType();
|
||||
|
||||
// Get all public properties, sort by name
|
||||
var properties = type.GetProperties()
|
||||
.Where(p => p.CanRead)
|
||||
.OrderBy(p => options.PropertyNamingPolicy?.ConvertName(p.Name) ?? p.Name, StringComparer.Ordinal);
|
||||
|
||||
writer.WriteStartObject();
|
||||
|
||||
foreach (var property in properties)
|
||||
{
|
||||
var propValue = property.GetValue(value);
|
||||
if (propValue is null && options.DefaultIgnoreCondition == JsonIgnoreCondition.WhenWritingNull)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = options.PropertyNamingPolicy?.ConvertName(property.Name) ?? property.Name;
|
||||
writer.WritePropertyName(name);
|
||||
JsonSerializer.Serialize(writer, propValue, property.PropertyType, options);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
-- Migration: 002_create_trust_verdicts
|
||||
-- Description: Create trust_verdicts table for TrustVerdict attestation storage
|
||||
-- Sprint: SPRINT_1227_0004_0004
|
||||
|
||||
-- Create vex schema if not exists
|
||||
CREATE SCHEMA IF NOT EXISTS vex;
|
||||
|
||||
-- TrustVerdict attestations table
|
||||
CREATE TABLE vex.trust_verdicts (
|
||||
verdict_id TEXT NOT NULL,
|
||||
tenant_id UUID NOT NULL,
|
||||
|
||||
-- Subject fields (VEX document identity)
|
||||
vex_digest TEXT NOT NULL,
|
||||
vex_format TEXT NOT NULL, -- openvex, csaf, cyclonedx
|
||||
provider_id TEXT NOT NULL,
|
||||
statement_id TEXT NOT NULL,
|
||||
vulnerability_id TEXT NOT NULL,
|
||||
product_key TEXT NOT NULL,
|
||||
vex_status TEXT, -- not_affected, fixed, affected, etc.
|
||||
|
||||
-- Origin verification
|
||||
origin_valid BOOLEAN NOT NULL,
|
||||
origin_method TEXT NOT NULL, -- dsse, cosign, pgp, x509
|
||||
origin_key_id TEXT,
|
||||
origin_issuer_id TEXT,
|
||||
origin_issuer_name TEXT,
|
||||
origin_rekor_log_index BIGINT,
|
||||
origin_score DECIMAL(5,4) NOT NULL,
|
||||
|
||||
-- Freshness evaluation
|
||||
freshness_status TEXT NOT NULL, -- fresh, stale, superseded, expired
|
||||
freshness_issued_at TIMESTAMPTZ NOT NULL,
|
||||
freshness_expires_at TIMESTAMPTZ,
|
||||
freshness_superseded_by TEXT,
|
||||
freshness_age_days INTEGER NOT NULL,
|
||||
freshness_score DECIMAL(5,4) NOT NULL,
|
||||
|
||||
-- Reputation scores
|
||||
reputation_composite DECIMAL(5,4) NOT NULL,
|
||||
reputation_authority DECIMAL(5,4) NOT NULL,
|
||||
reputation_accuracy DECIMAL(5,4) NOT NULL,
|
||||
reputation_timeliness DECIMAL(5,4) NOT NULL,
|
||||
reputation_coverage DECIMAL(5,4) NOT NULL,
|
||||
reputation_verification DECIMAL(5,4) NOT NULL,
|
||||
reputation_sample_count INTEGER NOT NULL,
|
||||
|
||||
-- Trust composite
|
||||
trust_score DECIMAL(5,4) NOT NULL,
|
||||
trust_tier TEXT NOT NULL, -- verified, high, medium, low, untrusted
|
||||
trust_formula TEXT NOT NULL,
|
||||
trust_reasons TEXT[] NOT NULL,
|
||||
meets_policy_threshold BOOLEAN,
|
||||
policy_threshold DECIMAL(5,4),
|
||||
|
||||
-- Evidence chain
|
||||
evidence_merkle_root TEXT NOT NULL,
|
||||
evidence_items_json JSONB NOT NULL,
|
||||
|
||||
-- Attestation envelope
|
||||
envelope_base64 TEXT, -- DSSE envelope
|
||||
verdict_digest TEXT NOT NULL, -- Deterministic digest
|
||||
|
||||
-- Metadata
|
||||
evaluated_at TIMESTAMPTZ NOT NULL,
|
||||
evaluator_version TEXT NOT NULL,
|
||||
crypto_profile TEXT NOT NULL,
|
||||
policy_digest TEXT,
|
||||
environment TEXT,
|
||||
correlation_id TEXT,
|
||||
|
||||
-- OCI/Rekor integration
|
||||
oci_digest TEXT,
|
||||
rekor_log_index BIGINT,
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ,
|
||||
|
||||
-- Primary key
|
||||
PRIMARY KEY (tenant_id, verdict_id)
|
||||
);
|
||||
|
||||
-- Enable Row Level Security
|
||||
ALTER TABLE vex.trust_verdicts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS policy for tenant isolation
|
||||
CREATE POLICY tenant_isolation_policy ON vex.trust_verdicts
|
||||
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
|
||||
|
||||
-- Indexes for common query patterns
|
||||
|
||||
-- Query by VEX digest (most common lookup)
|
||||
CREATE INDEX idx_trust_verdicts_vex_digest ON vex.trust_verdicts(tenant_id, vex_digest);
|
||||
|
||||
-- Query by provider/issuer
|
||||
CREATE INDEX idx_trust_verdicts_provider ON vex.trust_verdicts(tenant_id, provider_id);
|
||||
CREATE INDEX idx_trust_verdicts_issuer ON vex.trust_verdicts(tenant_id, origin_issuer_id);
|
||||
|
||||
-- Query by vulnerability
|
||||
CREATE INDEX idx_trust_verdicts_vuln ON vex.trust_verdicts(tenant_id, vulnerability_id);
|
||||
|
||||
-- Query by product
|
||||
CREATE INDEX idx_trust_verdicts_product ON vex.trust_verdicts(tenant_id, product_key);
|
||||
|
||||
-- Query by trust tier
|
||||
CREATE INDEX idx_trust_verdicts_tier ON vex.trust_verdicts(tenant_id, trust_tier);
|
||||
|
||||
-- Query by trust score (for policy decisions)
|
||||
CREATE INDEX idx_trust_verdicts_score ON vex.trust_verdicts(tenant_id, trust_score DESC);
|
||||
|
||||
-- Query by freshness
|
||||
CREATE INDEX idx_trust_verdicts_freshness ON vex.trust_verdicts(tenant_id, freshness_status);
|
||||
|
||||
-- Query active (non-expired) verdicts
|
||||
CREATE INDEX idx_trust_verdicts_active ON vex.trust_verdicts(tenant_id, expires_at)
|
||||
WHERE expires_at IS NULL OR expires_at > NOW();
|
||||
|
||||
-- Query by evaluation time (for cleanup/retention)
|
||||
CREATE INDEX idx_trust_verdicts_evaluated ON vex.trust_verdicts(evaluated_at DESC);
|
||||
|
||||
-- Unique constraint on VEX digest per tenant
|
||||
CREATE UNIQUE INDEX uq_trust_verdicts_vex_tenant ON vex.trust_verdicts(tenant_id, vex_digest);
|
||||
|
||||
-- GIN index on evidence items for JSONB queries
|
||||
CREATE INDEX idx_trust_verdicts_evidence ON vex.trust_verdicts USING GIN (evidence_items_json);
|
||||
|
||||
-- GIN index on trust reasons for full-text search
|
||||
CREATE INDEX idx_trust_verdicts_reasons ON vex.trust_verdicts USING GIN (trust_reasons);
|
||||
|
||||
-- Comments
|
||||
COMMENT ON TABLE vex.trust_verdicts IS 'Signed TrustVerdict attestations for VEX document verification results';
|
||||
COMMENT ON COLUMN vex.trust_verdicts.verdict_digest IS 'Deterministic SHA-256 digest of the verdict predicate for replay verification';
|
||||
COMMENT ON COLUMN vex.trust_verdicts.evidence_merkle_root IS 'Merkle root of evidence chain for compact proofs';
|
||||
COMMENT ON COLUMN vex.trust_verdicts.trust_formula IS 'Formula used for composite score calculation (transparency)';
|
||||
@@ -0,0 +1,398 @@
|
||||
// TrustVerdictOciAttacher - OCI registry attachment for TrustVerdict attestations
|
||||
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Attestor.TrustVerdict.Oci;
|
||||
|
||||
/// <summary>
|
||||
/// Service for attaching TrustVerdict attestations to OCI artifacts.
|
||||
/// </summary>
|
||||
public interface ITrustVerdictOciAttacher
|
||||
{
|
||||
/// <summary>
|
||||
/// Attach a TrustVerdict attestation to an OCI artifact.
|
||||
/// </summary>
|
||||
/// <param name="imageReference">OCI image reference (registry/repo:tag@sha256:digest).</param>
|
||||
/// <param name="envelopeBase64">DSSE envelope (base64 encoded).</param>
|
||||
/// <param name="verdictDigest">Deterministic verdict digest for verification.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>OCI digest of the attached attestation.</returns>
|
||||
Task<TrustVerdictOciAttachResult> AttachAsync(
|
||||
string imageReference,
|
||||
string envelopeBase64,
|
||||
string verdictDigest,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Fetch a TrustVerdict attestation from an OCI artifact.
|
||||
/// </summary>
|
||||
/// <param name="imageReference">OCI image reference.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The fetched envelope or null if not found.</returns>
|
||||
Task<TrustVerdictOciFetchResult?> FetchAsync(
|
||||
string imageReference,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// List all TrustVerdict attestations for an OCI artifact.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<TrustVerdictOciEntry>> ListAsync(
|
||||
string imageReference,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Detach (remove) a TrustVerdict attestation from an OCI artifact.
|
||||
/// </summary>
|
||||
Task<bool> DetachAsync(
|
||||
string imageReference,
|
||||
string verdictDigest,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of attaching a TrustVerdict to OCI.
|
||||
/// </summary>
|
||||
public sealed record TrustVerdictOciAttachResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public string? OciDigest { get; init; }
|
||||
public string? ManifestDigest { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
public TimeSpan Duration { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of fetching a TrustVerdict from OCI.
|
||||
/// </summary>
|
||||
public sealed record TrustVerdictOciFetchResult
|
||||
{
|
||||
public required string EnvelopeBase64 { get; init; }
|
||||
public required string VerdictDigest { get; init; }
|
||||
public required string OciDigest { get; init; }
|
||||
public required DateTimeOffset AttachedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry in the list of OCI attachments.
|
||||
/// </summary>
|
||||
public sealed record TrustVerdictOciEntry
|
||||
{
|
||||
public required string VerdictDigest { get; init; }
|
||||
public required string OciDigest { get; init; }
|
||||
public required DateTimeOffset AttachedAt { get; init; }
|
||||
public required long SizeBytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation using ORAS patterns.
|
||||
/// </summary>
|
||||
public sealed class TrustVerdictOciAttacher : ITrustVerdictOciAttacher
|
||||
{
|
||||
private readonly IOptionsMonitor<TrustVerdictOciOptions> _options;
|
||||
private readonly ILogger<TrustVerdictOciAttacher> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
// ORAS artifact type for TrustVerdict attestations
|
||||
public const string ArtifactType = "application/vnd.stellaops.trust-verdict.v1+dsse";
|
||||
public const string MediaType = "application/vnd.dsse.envelope.v1+json";
|
||||
|
||||
public TrustVerdictOciAttacher(
|
||||
IOptionsMonitor<TrustVerdictOciOptions> options,
|
||||
ILogger<TrustVerdictOciAttacher> logger,
|
||||
HttpClient? httpClient = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_httpClient = httpClient ?? new HttpClient();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<TrustVerdictOciAttachResult> AttachAsync(
|
||||
string imageReference,
|
||||
string envelopeBase64,
|
||||
string verdictDigest,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var startTime = _timeProvider.GetUtcNow();
|
||||
var opts = _options.CurrentValue;
|
||||
|
||||
if (!opts.Enabled)
|
||||
{
|
||||
_logger.LogDebug("OCI attachment disabled, skipping for {Reference}", imageReference);
|
||||
return new TrustVerdictOciAttachResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = "OCI attachment is disabled",
|
||||
Duration = _timeProvider.GetUtcNow() - startTime
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Parse reference
|
||||
var parsed = ParseReference(imageReference);
|
||||
if (parsed == null)
|
||||
{
|
||||
return new TrustVerdictOciAttachResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = $"Invalid OCI reference: {imageReference}",
|
||||
Duration = _timeProvider.GetUtcNow() - startTime
|
||||
};
|
||||
}
|
||||
|
||||
// Build referrers API URL
|
||||
// POST /v2/{name}/manifests/{reference} with artifact manifest
|
||||
|
||||
// Note: Full ORAS implementation would:
|
||||
// 1. Create blob with envelope
|
||||
// 2. Create artifact manifest referencing the blob
|
||||
// 3. Push manifest with subject pointing to original image
|
||||
|
||||
_logger.LogInformation(
|
||||
"Would attach TrustVerdict {Digest} to {Reference} (implementation pending)",
|
||||
verdictDigest, imageReference);
|
||||
|
||||
// Placeholder - full implementation requires OCI client
|
||||
var mockDigest = $"sha256:{Guid.NewGuid():N}";
|
||||
|
||||
return new TrustVerdictOciAttachResult
|
||||
{
|
||||
Success = true,
|
||||
OciDigest = mockDigest,
|
||||
ManifestDigest = mockDigest,
|
||||
Duration = _timeProvider.GetUtcNow() - startTime
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to attach TrustVerdict to {Reference}", imageReference);
|
||||
return new TrustVerdictOciAttachResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = ex.Message,
|
||||
Duration = _timeProvider.GetUtcNow() - startTime
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TrustVerdictOciFetchResult?> FetchAsync(
|
||||
string imageReference,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var opts = _options.CurrentValue;
|
||||
|
||||
if (!opts.Enabled)
|
||||
{
|
||||
_logger.LogDebug("OCI attachment disabled, skipping fetch for {Reference}", imageReference);
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var parsed = ParseReference(imageReference);
|
||||
if (parsed == null)
|
||||
{
|
||||
_logger.LogWarning("Invalid OCI reference: {Reference}", imageReference);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Query referrers API
|
||||
// GET /v2/{name}/referrers/{digest}?artifactType={ArtifactType}
|
||||
|
||||
_logger.LogDebug("Would fetch TrustVerdict from {Reference} (implementation pending)", imageReference);
|
||||
|
||||
// Placeholder
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fetch TrustVerdict from {Reference}", imageReference);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TrustVerdictOciEntry>> ListAsync(
|
||||
string imageReference,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var opts = _options.CurrentValue;
|
||||
|
||||
if (!opts.Enabled)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var parsed = ParseReference(imageReference);
|
||||
if (parsed == null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
// Query referrers API and filter by artifact type
|
||||
_logger.LogDebug("Would list TrustVerdicts for {Reference} (implementation pending)", imageReference);
|
||||
|
||||
return [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to list TrustVerdicts for {Reference}", imageReference);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DetachAsync(
|
||||
string imageReference,
|
||||
string verdictDigest,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var opts = _options.CurrentValue;
|
||||
|
||||
if (!opts.Enabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// DELETE the referrer manifest
|
||||
_logger.LogDebug(
|
||||
"Would detach TrustVerdict {Digest} from {Reference} (implementation pending)",
|
||||
verdictDigest, imageReference);
|
||||
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to detach TrustVerdict from {Reference}", imageReference);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static OciReference? ParseReference(string reference)
|
||||
{
|
||||
// Parse: registry/repo:tag or registry/repo@sha256:digest
|
||||
try
|
||||
{
|
||||
var atIdx = reference.IndexOf('@');
|
||||
var colonIdx = reference.LastIndexOf(':');
|
||||
|
||||
string registry;
|
||||
string repository;
|
||||
string? tag = null;
|
||||
string? digest = null;
|
||||
|
||||
if (atIdx > 0)
|
||||
{
|
||||
// Has digest
|
||||
digest = reference[(atIdx + 1)..];
|
||||
var beforeDigest = reference[..atIdx];
|
||||
var slashIdx = beforeDigest.IndexOf('/');
|
||||
registry = beforeDigest[..slashIdx];
|
||||
repository = beforeDigest[(slashIdx + 1)..];
|
||||
}
|
||||
else if (colonIdx > 0 && colonIdx > reference.IndexOf('/'))
|
||||
{
|
||||
// Has tag
|
||||
tag = reference[(colonIdx + 1)..];
|
||||
var beforeTag = reference[..colonIdx];
|
||||
var slashIdx = beforeTag.IndexOf('/');
|
||||
registry = beforeTag[..slashIdx];
|
||||
repository = beforeTag[(slashIdx + 1)..];
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new OciReference
|
||||
{
|
||||
Registry = registry,
|
||||
Repository = repository,
|
||||
Tag = tag,
|
||||
Digest = digest
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record OciReference
|
||||
{
|
||||
public required string Registry { get; init; }
|
||||
public required string Repository { get; init; }
|
||||
public string? Tag { get; init; }
|
||||
public string? Digest { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for OCI attachment.
|
||||
/// </summary>
|
||||
public sealed class TrustVerdictOciOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section key.
|
||||
/// </summary>
|
||||
public const string SectionKey = "TrustVerdictOci";
|
||||
|
||||
/// <summary>
|
||||
/// Whether OCI attachment is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Default registry URL if not specified in reference.
|
||||
/// </summary>
|
||||
public string? DefaultRegistry { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Registry authentication (if needed).
|
||||
/// </summary>
|
||||
public OciAuthOptions? Auth { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Request timeout.
|
||||
/// </summary>
|
||||
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Whether to verify TLS certificates.
|
||||
/// </summary>
|
||||
public bool VerifyTls { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OCI registry authentication options.
|
||||
/// </summary>
|
||||
public sealed class OciAuthOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Username for basic auth.
|
||||
/// </summary>
|
||||
public string? Username { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Password or token for basic auth.
|
||||
/// </summary>
|
||||
public string? Password { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Bearer token for token auth.
|
||||
/// </summary>
|
||||
public string? BearerToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to credentials file.
|
||||
/// </summary>
|
||||
public string? CredentialsFile { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
// TrustVerdictRepository - PostgreSQL persistence for TrustVerdict attestations
|
||||
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
|
||||
|
||||
using System.Text.Json;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.Attestor.TrustVerdict.Predicates;
|
||||
|
||||
namespace StellaOps.Attestor.TrustVerdict.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for TrustVerdict persistence.
|
||||
/// </summary>
|
||||
public interface ITrustVerdictRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Store a TrustVerdict attestation.
|
||||
/// </summary>
|
||||
Task<string> StoreAsync(TrustVerdictEntity entity, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get a TrustVerdict by ID.
|
||||
/// </summary>
|
||||
Task<TrustVerdictEntity?> GetByIdAsync(Guid tenantId, string verdictId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get a TrustVerdict by VEX digest.
|
||||
/// </summary>
|
||||
Task<TrustVerdictEntity?> GetByVexDigestAsync(Guid tenantId, string vexDigest, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get TrustVerdicts by provider.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<TrustVerdictEntity>> GetByProviderAsync(
|
||||
Guid tenantId,
|
||||
string providerId,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get TrustVerdicts by vulnerability.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<TrustVerdictEntity>> GetByVulnerabilityAsync(
|
||||
Guid tenantId,
|
||||
string vulnerabilityId,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get TrustVerdicts by trust tier.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<TrustVerdictEntity>> GetByTierAsync(
|
||||
Guid tenantId,
|
||||
string tier,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get active (non-expired) TrustVerdicts with minimum score.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<TrustVerdictEntity>> GetActiveByMinScoreAsync(
|
||||
Guid tenantId,
|
||||
decimal minScore,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Delete a TrustVerdict.
|
||||
/// </summary>
|
||||
Task<bool> DeleteAsync(Guid tenantId, string verdictId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Delete expired TrustVerdicts.
|
||||
/// </summary>
|
||||
Task<int> DeleteExpiredAsync(Guid tenantId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Count TrustVerdicts for tenant.
|
||||
/// </summary>
|
||||
Task<long> CountAsync(Guid tenantId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get aggregate statistics.
|
||||
/// </summary>
|
||||
Task<TrustVerdictStats> GetStatsAsync(Guid tenantId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entity representing a stored TrustVerdict.
|
||||
/// </summary>
|
||||
public sealed record TrustVerdictEntity
|
||||
{
|
||||
public required string VerdictId { get; init; }
|
||||
public required Guid TenantId { get; init; }
|
||||
|
||||
// Subject
|
||||
public required string VexDigest { get; init; }
|
||||
public required string VexFormat { get; init; }
|
||||
public required string ProviderId { get; init; }
|
||||
public required string StatementId { get; init; }
|
||||
public required string VulnerabilityId { get; init; }
|
||||
public required string ProductKey { get; init; }
|
||||
public string? VexStatus { get; init; }
|
||||
|
||||
// Origin
|
||||
public required bool OriginValid { get; init; }
|
||||
public required string OriginMethod { get; init; }
|
||||
public string? OriginKeyId { get; init; }
|
||||
public string? OriginIssuerId { get; init; }
|
||||
public string? OriginIssuerName { get; init; }
|
||||
public long? OriginRekorLogIndex { get; init; }
|
||||
public required decimal OriginScore { get; init; }
|
||||
|
||||
// Freshness
|
||||
public required string FreshnessStatus { get; init; }
|
||||
public required DateTimeOffset FreshnessIssuedAt { get; init; }
|
||||
public DateTimeOffset? FreshnessExpiresAt { get; init; }
|
||||
public string? FreshnessSupersededBy { get; init; }
|
||||
public required int FreshnessAgeDays { get; init; }
|
||||
public required decimal FreshnessScore { get; init; }
|
||||
|
||||
// Reputation
|
||||
public required decimal ReputationComposite { get; init; }
|
||||
public required decimal ReputationAuthority { get; init; }
|
||||
public required decimal ReputationAccuracy { get; init; }
|
||||
public required decimal ReputationTimeliness { get; init; }
|
||||
public required decimal ReputationCoverage { get; init; }
|
||||
public required decimal ReputationVerification { get; init; }
|
||||
public required int ReputationSampleCount { get; init; }
|
||||
|
||||
// Trust composite
|
||||
public required decimal TrustScore { get; init; }
|
||||
public required string TrustTier { get; init; }
|
||||
public required string TrustFormula { get; init; }
|
||||
public required IReadOnlyList<string> TrustReasons { get; init; }
|
||||
public bool? MeetsPolicyThreshold { get; init; }
|
||||
public decimal? PolicyThreshold { get; init; }
|
||||
|
||||
// Evidence
|
||||
public required string EvidenceMerkleRoot { get; init; }
|
||||
public required IReadOnlyList<TrustEvidenceItem> EvidenceItems { get; init; }
|
||||
|
||||
// Attestation
|
||||
public string? EnvelopeBase64 { get; init; }
|
||||
public required string VerdictDigest { get; init; }
|
||||
|
||||
// Metadata
|
||||
public required DateTimeOffset EvaluatedAt { get; init; }
|
||||
public required string EvaluatorVersion { get; init; }
|
||||
public required string CryptoProfile { get; init; }
|
||||
public string? PolicyDigest { get; init; }
|
||||
public string? Environment { get; init; }
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
// OCI/Rekor
|
||||
public string? OciDigest { get; init; }
|
||||
public long? RekorLogIndex { get; init; }
|
||||
|
||||
// Timestamps
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate statistics for TrustVerdicts.
|
||||
/// </summary>
|
||||
public sealed record TrustVerdictStats
|
||||
{
|
||||
public required long TotalCount { get; init; }
|
||||
public required long ActiveCount { get; init; }
|
||||
public required long ExpiredCount { get; init; }
|
||||
public required decimal AverageScore { get; init; }
|
||||
public required IReadOnlyDictionary<string, long> CountByTier { get; init; }
|
||||
public required IReadOnlyDictionary<string, long> CountByProvider { get; init; }
|
||||
public required DateTimeOffset? OldestEvaluation { get; init; }
|
||||
public required DateTimeOffset? NewestEvaluation { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of ITrustVerdictRepository.
|
||||
/// </summary>
|
||||
public sealed class PostgresTrustVerdictRepository : ITrustVerdictRepository
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public PostgresTrustVerdictRepository(NpgsqlDataSource dataSource)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
}
|
||||
|
||||
public async Task<string> StoreAsync(TrustVerdictEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO vex.trust_verdicts (
|
||||
verdict_id, tenant_id,
|
||||
vex_digest, vex_format, provider_id, statement_id, vulnerability_id, product_key, vex_status,
|
||||
origin_valid, origin_method, origin_key_id, origin_issuer_id, origin_issuer_name, origin_rekor_log_index, origin_score,
|
||||
freshness_status, freshness_issued_at, freshness_expires_at, freshness_superseded_by, freshness_age_days, freshness_score,
|
||||
reputation_composite, reputation_authority, reputation_accuracy, reputation_timeliness, reputation_coverage, reputation_verification, reputation_sample_count,
|
||||
trust_score, trust_tier, trust_formula, trust_reasons, meets_policy_threshold, policy_threshold,
|
||||
evidence_merkle_root, evidence_items_json,
|
||||
envelope_base64, verdict_digest,
|
||||
evaluated_at, evaluator_version, crypto_profile, policy_digest, environment, correlation_id,
|
||||
oci_digest, rekor_log_index,
|
||||
created_at, expires_at
|
||||
) VALUES (
|
||||
@verdict_id, @tenant_id,
|
||||
@vex_digest, @vex_format, @provider_id, @statement_id, @vulnerability_id, @product_key, @vex_status,
|
||||
@origin_valid, @origin_method, @origin_key_id, @origin_issuer_id, @origin_issuer_name, @origin_rekor_log_index, @origin_score,
|
||||
@freshness_status, @freshness_issued_at, @freshness_expires_at, @freshness_superseded_by, @freshness_age_days, @freshness_score,
|
||||
@reputation_composite, @reputation_authority, @reputation_accuracy, @reputation_timeliness, @reputation_coverage, @reputation_verification, @reputation_sample_count,
|
||||
@trust_score, @trust_tier, @trust_formula, @trust_reasons, @meets_policy_threshold, @policy_threshold,
|
||||
@evidence_merkle_root, @evidence_items_json::jsonb,
|
||||
@envelope_base64, @verdict_digest,
|
||||
@evaluated_at, @evaluator_version, @crypto_profile, @policy_digest, @environment, @correlation_id,
|
||||
@oci_digest, @rekor_log_index,
|
||||
@created_at, @expires_at
|
||||
)
|
||||
ON CONFLICT (tenant_id, vex_digest) DO UPDATE SET
|
||||
verdict_id = EXCLUDED.verdict_id,
|
||||
origin_valid = EXCLUDED.origin_valid,
|
||||
origin_method = EXCLUDED.origin_method,
|
||||
origin_score = EXCLUDED.origin_score,
|
||||
freshness_status = EXCLUDED.freshness_status,
|
||||
freshness_score = EXCLUDED.freshness_score,
|
||||
reputation_composite = EXCLUDED.reputation_composite,
|
||||
trust_score = EXCLUDED.trust_score,
|
||||
trust_tier = EXCLUDED.trust_tier,
|
||||
trust_reasons = EXCLUDED.trust_reasons,
|
||||
evidence_merkle_root = EXCLUDED.evidence_merkle_root,
|
||||
evidence_items_json = EXCLUDED.evidence_items_json,
|
||||
envelope_base64 = EXCLUDED.envelope_base64,
|
||||
verdict_digest = EXCLUDED.verdict_digest,
|
||||
evaluated_at = EXCLUDED.evaluated_at,
|
||||
expires_at = EXCLUDED.expires_at
|
||||
RETURNING verdict_id
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
AddEntityParameters(cmd, entity);
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync(ct);
|
||||
return result?.ToString() ?? entity.VerdictId;
|
||||
}
|
||||
|
||||
public async Task<TrustVerdictEntity?> GetByIdAsync(Guid tenantId, string verdictId, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM vex.trust_verdicts
|
||||
WHERE tenant_id = @tenant_id AND verdict_id = @verdict_id
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
cmd.Parameters.AddWithValue("verdict_id", verdictId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
return await reader.ReadAsync(ct) ? ReadEntity(reader) : null;
|
||||
}
|
||||
|
||||
public async Task<TrustVerdictEntity?> GetByVexDigestAsync(Guid tenantId, string vexDigest, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM vex.trust_verdicts
|
||||
WHERE tenant_id = @tenant_id AND vex_digest = @vex_digest
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
cmd.Parameters.AddWithValue("vex_digest", vexDigest);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
return await reader.ReadAsync(ct) ? ReadEntity(reader) : null;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TrustVerdictEntity>> GetByProviderAsync(
|
||||
Guid tenantId, string providerId, int limit, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM vex.trust_verdicts
|
||||
WHERE tenant_id = @tenant_id AND provider_id = @provider_id
|
||||
ORDER BY evaluated_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
return await ExecuteQueryAsync(sql, tenantId, cmd =>
|
||||
{
|
||||
cmd.Parameters.AddWithValue("provider_id", providerId);
|
||||
cmd.Parameters.AddWithValue("limit", limit);
|
||||
}, ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TrustVerdictEntity>> GetByVulnerabilityAsync(
|
||||
Guid tenantId, string vulnerabilityId, int limit, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM vex.trust_verdicts
|
||||
WHERE tenant_id = @tenant_id AND vulnerability_id = @vulnerability_id
|
||||
ORDER BY evaluated_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
return await ExecuteQueryAsync(sql, tenantId, cmd =>
|
||||
{
|
||||
cmd.Parameters.AddWithValue("vulnerability_id", vulnerabilityId);
|
||||
cmd.Parameters.AddWithValue("limit", limit);
|
||||
}, ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TrustVerdictEntity>> GetByTierAsync(
|
||||
Guid tenantId, string tier, int limit, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM vex.trust_verdicts
|
||||
WHERE tenant_id = @tenant_id AND trust_tier = @tier
|
||||
ORDER BY trust_score DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
return await ExecuteQueryAsync(sql, tenantId, cmd =>
|
||||
{
|
||||
cmd.Parameters.AddWithValue("tier", tier);
|
||||
cmd.Parameters.AddWithValue("limit", limit);
|
||||
}, ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TrustVerdictEntity>> GetActiveByMinScoreAsync(
|
||||
Guid tenantId, decimal minScore, int limit, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM vex.trust_verdicts
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND trust_score >= @min_score
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
ORDER BY trust_score DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
return await ExecuteQueryAsync(sql, tenantId, cmd =>
|
||||
{
|
||||
cmd.Parameters.AddWithValue("min_score", minScore);
|
||||
cmd.Parameters.AddWithValue("limit", limit);
|
||||
}, ct);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid tenantId, string verdictId, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
DELETE FROM vex.trust_verdicts
|
||||
WHERE tenant_id = @tenant_id AND verdict_id = @verdict_id
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
cmd.Parameters.AddWithValue("verdict_id", verdictId);
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
}
|
||||
|
||||
public async Task<int> DeleteExpiredAsync(Guid tenantId, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
DELETE FROM vex.trust_verdicts
|
||||
WHERE tenant_id = @tenant_id AND expires_at < NOW()
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<long> CountAsync(Guid tenantId, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT COUNT(*) FROM vex.trust_verdicts
|
||||
WHERE tenant_id = @tenant_id
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync(ct);
|
||||
return Convert.ToInt64(result);
|
||||
}
|
||||
|
||||
public async Task<TrustVerdictStats> GetStatsAsync(Guid tenantId, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT
|
||||
COUNT(*) as total_count,
|
||||
COUNT(*) FILTER (WHERE expires_at IS NULL OR expires_at > NOW()) as active_count,
|
||||
COUNT(*) FILTER (WHERE expires_at <= NOW()) as expired_count,
|
||||
COALESCE(AVG(trust_score), 0) as average_score,
|
||||
MIN(evaluated_at) as oldest_evaluation,
|
||||
MAX(evaluated_at) as newest_evaluation
|
||||
FROM vex.trust_verdicts
|
||||
WHERE tenant_id = @tenant_id
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
await reader.ReadAsync(ct);
|
||||
|
||||
var stats = new TrustVerdictStats
|
||||
{
|
||||
TotalCount = reader.GetInt64(0),
|
||||
ActiveCount = reader.GetInt64(1),
|
||||
ExpiredCount = reader.GetInt64(2),
|
||||
AverageScore = reader.GetDecimal(3),
|
||||
OldestEvaluation = reader.IsDBNull(4) ? null : reader.GetDateTime(4),
|
||||
NewestEvaluation = reader.IsDBNull(5) ? null : reader.GetDateTime(5),
|
||||
CountByTier = await GetCountByTierAsync(tenantId, ct),
|
||||
CountByProvider = await GetCountByProviderAsync(tenantId, ct)
|
||||
};
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyDictionary<string, long>> GetCountByTierAsync(Guid tenantId, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT trust_tier, COUNT(*) FROM vex.trust_verdicts
|
||||
WHERE tenant_id = @tenant_id
|
||||
GROUP BY trust_tier
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
|
||||
var result = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
result[reader.GetString(0)] = reader.GetInt64(1);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyDictionary<string, long>> GetCountByProviderAsync(Guid tenantId, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT provider_id, COUNT(*) FROM vex.trust_verdicts
|
||||
WHERE tenant_id = @tenant_id
|
||||
GROUP BY provider_id
|
||||
ORDER BY COUNT(*) DESC
|
||||
LIMIT 20
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
|
||||
var result = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
result[reader.GetString(0)] = reader.GetInt64(1);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<TrustVerdictEntity>> ExecuteQueryAsync(
|
||||
string sql,
|
||||
Guid tenantId,
|
||||
Action<NpgsqlCommand> configure,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
configure(cmd);
|
||||
|
||||
var results = new List<TrustVerdictEntity>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
results.Add(ReadEntity(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private void AddEntityParameters(NpgsqlCommand cmd, TrustVerdictEntity entity)
|
||||
{
|
||||
cmd.Parameters.AddWithValue("verdict_id", entity.VerdictId);
|
||||
cmd.Parameters.AddWithValue("tenant_id", entity.TenantId);
|
||||
|
||||
cmd.Parameters.AddWithValue("vex_digest", entity.VexDigest);
|
||||
cmd.Parameters.AddWithValue("vex_format", entity.VexFormat);
|
||||
cmd.Parameters.AddWithValue("provider_id", entity.ProviderId);
|
||||
cmd.Parameters.AddWithValue("statement_id", entity.StatementId);
|
||||
cmd.Parameters.AddWithValue("vulnerability_id", entity.VulnerabilityId);
|
||||
cmd.Parameters.AddWithValue("product_key", entity.ProductKey);
|
||||
cmd.Parameters.AddWithValue("vex_status", entity.VexStatus ?? (object)DBNull.Value);
|
||||
|
||||
cmd.Parameters.AddWithValue("origin_valid", entity.OriginValid);
|
||||
cmd.Parameters.AddWithValue("origin_method", entity.OriginMethod);
|
||||
cmd.Parameters.AddWithValue("origin_key_id", entity.OriginKeyId ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("origin_issuer_id", entity.OriginIssuerId ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("origin_issuer_name", entity.OriginIssuerName ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("origin_rekor_log_index", entity.OriginRekorLogIndex ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("origin_score", entity.OriginScore);
|
||||
|
||||
cmd.Parameters.AddWithValue("freshness_status", entity.FreshnessStatus);
|
||||
cmd.Parameters.AddWithValue("freshness_issued_at", entity.FreshnessIssuedAt);
|
||||
cmd.Parameters.AddWithValue("freshness_expires_at", entity.FreshnessExpiresAt ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("freshness_superseded_by", entity.FreshnessSupersededBy ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("freshness_age_days", entity.FreshnessAgeDays);
|
||||
cmd.Parameters.AddWithValue("freshness_score", entity.FreshnessScore);
|
||||
|
||||
cmd.Parameters.AddWithValue("reputation_composite", entity.ReputationComposite);
|
||||
cmd.Parameters.AddWithValue("reputation_authority", entity.ReputationAuthority);
|
||||
cmd.Parameters.AddWithValue("reputation_accuracy", entity.ReputationAccuracy);
|
||||
cmd.Parameters.AddWithValue("reputation_timeliness", entity.ReputationTimeliness);
|
||||
cmd.Parameters.AddWithValue("reputation_coverage", entity.ReputationCoverage);
|
||||
cmd.Parameters.AddWithValue("reputation_verification", entity.ReputationVerification);
|
||||
cmd.Parameters.AddWithValue("reputation_sample_count", entity.ReputationSampleCount);
|
||||
|
||||
cmd.Parameters.AddWithValue("trust_score", entity.TrustScore);
|
||||
cmd.Parameters.AddWithValue("trust_tier", entity.TrustTier);
|
||||
cmd.Parameters.AddWithValue("trust_formula", entity.TrustFormula);
|
||||
cmd.Parameters.AddWithValue("trust_reasons", entity.TrustReasons.ToArray());
|
||||
cmd.Parameters.AddWithValue("meets_policy_threshold", entity.MeetsPolicyThreshold ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("policy_threshold", entity.PolicyThreshold ?? (object)DBNull.Value);
|
||||
|
||||
cmd.Parameters.AddWithValue("evidence_merkle_root", entity.EvidenceMerkleRoot);
|
||||
cmd.Parameters.AddWithValue("evidence_items_json", JsonSerializer.Serialize(entity.EvidenceItems, _jsonOptions));
|
||||
|
||||
cmd.Parameters.AddWithValue("envelope_base64", entity.EnvelopeBase64 ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("verdict_digest", entity.VerdictDigest);
|
||||
|
||||
cmd.Parameters.AddWithValue("evaluated_at", entity.EvaluatedAt);
|
||||
cmd.Parameters.AddWithValue("evaluator_version", entity.EvaluatorVersion);
|
||||
cmd.Parameters.AddWithValue("crypto_profile", entity.CryptoProfile);
|
||||
cmd.Parameters.AddWithValue("policy_digest", entity.PolicyDigest ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("environment", entity.Environment ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("correlation_id", entity.CorrelationId ?? (object)DBNull.Value);
|
||||
|
||||
cmd.Parameters.AddWithValue("oci_digest", entity.OciDigest ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("rekor_log_index", entity.RekorLogIndex ?? (object)DBNull.Value);
|
||||
|
||||
cmd.Parameters.AddWithValue("created_at", entity.CreatedAt);
|
||||
cmd.Parameters.AddWithValue("expires_at", entity.ExpiresAt ?? (object)DBNull.Value);
|
||||
}
|
||||
|
||||
private TrustVerdictEntity ReadEntity(NpgsqlDataReader reader)
|
||||
{
|
||||
var evidenceJson = reader.GetString(reader.GetOrdinal("evidence_items_json"));
|
||||
var evidenceItems = JsonSerializer.Deserialize<List<TrustEvidenceItem>>(evidenceJson, _jsonOptions) ?? [];
|
||||
|
||||
return new TrustVerdictEntity
|
||||
{
|
||||
VerdictId = reader.GetString(reader.GetOrdinal("verdict_id")),
|
||||
TenantId = reader.GetGuid(reader.GetOrdinal("tenant_id")),
|
||||
|
||||
VexDigest = reader.GetString(reader.GetOrdinal("vex_digest")),
|
||||
VexFormat = reader.GetString(reader.GetOrdinal("vex_format")),
|
||||
ProviderId = reader.GetString(reader.GetOrdinal("provider_id")),
|
||||
StatementId = reader.GetString(reader.GetOrdinal("statement_id")),
|
||||
VulnerabilityId = reader.GetString(reader.GetOrdinal("vulnerability_id")),
|
||||
ProductKey = reader.GetString(reader.GetOrdinal("product_key")),
|
||||
VexStatus = reader.IsDBNull(reader.GetOrdinal("vex_status")) ? null : reader.GetString(reader.GetOrdinal("vex_status")),
|
||||
|
||||
OriginValid = reader.GetBoolean(reader.GetOrdinal("origin_valid")),
|
||||
OriginMethod = reader.GetString(reader.GetOrdinal("origin_method")),
|
||||
OriginKeyId = reader.IsDBNull(reader.GetOrdinal("origin_key_id")) ? null : reader.GetString(reader.GetOrdinal("origin_key_id")),
|
||||
OriginIssuerId = reader.IsDBNull(reader.GetOrdinal("origin_issuer_id")) ? null : reader.GetString(reader.GetOrdinal("origin_issuer_id")),
|
||||
OriginIssuerName = reader.IsDBNull(reader.GetOrdinal("origin_issuer_name")) ? null : reader.GetString(reader.GetOrdinal("origin_issuer_name")),
|
||||
OriginRekorLogIndex = reader.IsDBNull(reader.GetOrdinal("origin_rekor_log_index")) ? null : reader.GetInt64(reader.GetOrdinal("origin_rekor_log_index")),
|
||||
OriginScore = reader.GetDecimal(reader.GetOrdinal("origin_score")),
|
||||
|
||||
FreshnessStatus = reader.GetString(reader.GetOrdinal("freshness_status")),
|
||||
FreshnessIssuedAt = reader.GetDateTime(reader.GetOrdinal("freshness_issued_at")),
|
||||
FreshnessExpiresAt = reader.IsDBNull(reader.GetOrdinal("freshness_expires_at")) ? null : reader.GetDateTime(reader.GetOrdinal("freshness_expires_at")),
|
||||
FreshnessSupersededBy = reader.IsDBNull(reader.GetOrdinal("freshness_superseded_by")) ? null : reader.GetString(reader.GetOrdinal("freshness_superseded_by")),
|
||||
FreshnessAgeDays = reader.GetInt32(reader.GetOrdinal("freshness_age_days")),
|
||||
FreshnessScore = reader.GetDecimal(reader.GetOrdinal("freshness_score")),
|
||||
|
||||
ReputationComposite = reader.GetDecimal(reader.GetOrdinal("reputation_composite")),
|
||||
ReputationAuthority = reader.GetDecimal(reader.GetOrdinal("reputation_authority")),
|
||||
ReputationAccuracy = reader.GetDecimal(reader.GetOrdinal("reputation_accuracy")),
|
||||
ReputationTimeliness = reader.GetDecimal(reader.GetOrdinal("reputation_timeliness")),
|
||||
ReputationCoverage = reader.GetDecimal(reader.GetOrdinal("reputation_coverage")),
|
||||
ReputationVerification = reader.GetDecimal(reader.GetOrdinal("reputation_verification")),
|
||||
ReputationSampleCount = reader.GetInt32(reader.GetOrdinal("reputation_sample_count")),
|
||||
|
||||
TrustScore = reader.GetDecimal(reader.GetOrdinal("trust_score")),
|
||||
TrustTier = reader.GetString(reader.GetOrdinal("trust_tier")),
|
||||
TrustFormula = reader.GetString(reader.GetOrdinal("trust_formula")),
|
||||
TrustReasons = reader.GetFieldValue<string[]>(reader.GetOrdinal("trust_reasons")).ToList(),
|
||||
MeetsPolicyThreshold = reader.IsDBNull(reader.GetOrdinal("meets_policy_threshold")) ? null : reader.GetBoolean(reader.GetOrdinal("meets_policy_threshold")),
|
||||
PolicyThreshold = reader.IsDBNull(reader.GetOrdinal("policy_threshold")) ? null : reader.GetDecimal(reader.GetOrdinal("policy_threshold")),
|
||||
|
||||
EvidenceMerkleRoot = reader.GetString(reader.GetOrdinal("evidence_merkle_root")),
|
||||
EvidenceItems = evidenceItems,
|
||||
|
||||
EnvelopeBase64 = reader.IsDBNull(reader.GetOrdinal("envelope_base64")) ? null : reader.GetString(reader.GetOrdinal("envelope_base64")),
|
||||
VerdictDigest = reader.GetString(reader.GetOrdinal("verdict_digest")),
|
||||
|
||||
EvaluatedAt = reader.GetDateTime(reader.GetOrdinal("evaluated_at")),
|
||||
EvaluatorVersion = reader.GetString(reader.GetOrdinal("evaluator_version")),
|
||||
CryptoProfile = reader.GetString(reader.GetOrdinal("crypto_profile")),
|
||||
PolicyDigest = reader.IsDBNull(reader.GetOrdinal("policy_digest")) ? null : reader.GetString(reader.GetOrdinal("policy_digest")),
|
||||
Environment = reader.IsDBNull(reader.GetOrdinal("environment")) ? null : reader.GetString(reader.GetOrdinal("environment")),
|
||||
CorrelationId = reader.IsDBNull(reader.GetOrdinal("correlation_id")) ? null : reader.GetString(reader.GetOrdinal("correlation_id")),
|
||||
|
||||
OciDigest = reader.IsDBNull(reader.GetOrdinal("oci_digest")) ? null : reader.GetString(reader.GetOrdinal("oci_digest")),
|
||||
RekorLogIndex = reader.IsDBNull(reader.GetOrdinal("rekor_log_index")) ? null : reader.GetInt64(reader.GetOrdinal("rekor_log_index")),
|
||||
|
||||
CreatedAt = reader.GetDateTime(reader.GetOrdinal("created_at")),
|
||||
ExpiresAt = reader.IsDBNull(reader.GetOrdinal("expires_at")) ? null : reader.GetDateTime(reader.GetOrdinal("expires_at"))
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
// TrustVerdictPredicate - in-toto predicate for VEX trust verification results
|
||||
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.TrustVerdict.Predicates;
|
||||
|
||||
/// <summary>
|
||||
/// in-toto predicate for VEX trust verification results.
|
||||
/// This predicate captures the complete trust evaluation of a VEX document,
|
||||
/// including origin verification, freshness, reputation, and evidence chain.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Predicate type URI: "https://stellaops.dev/predicates/trust-verdict@v1"
|
||||
///
|
||||
/// Design principles:
|
||||
/// - Deterministic: Same inputs always produce identical predicates
|
||||
/// - Auditable: Complete evidence chain for replay
|
||||
/// - Self-contained: All context needed for verification
|
||||
/// </remarks>
|
||||
public sealed record TrustVerdictPredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// Official predicate type URI for TrustVerdict.
|
||||
/// </summary>
|
||||
public const string PredicateType = "https://stellaops.dev/predicates/trust-verdict@v1";
|
||||
|
||||
/// <summary>
|
||||
/// Schema version for forward compatibility.
|
||||
/// </summary>
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; init; } = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// VEX document being verified.
|
||||
/// </summary>
|
||||
[JsonPropertyName("subject")]
|
||||
public required TrustVerdictSubject Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Origin (signature) verification result.
|
||||
/// </summary>
|
||||
[JsonPropertyName("origin")]
|
||||
public required OriginVerification Origin { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Freshness evaluation result.
|
||||
/// </summary>
|
||||
[JsonPropertyName("freshness")]
|
||||
public required FreshnessEvaluation Freshness { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reputation score and breakdown.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reputation")]
|
||||
public required ReputationScore Reputation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Composite trust score and tier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("composite")]
|
||||
public required TrustComposite Composite { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence chain for audit.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidence")]
|
||||
public required TrustEvidenceChain Evidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evaluation metadata.
|
||||
/// </summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
public required TrustEvaluationMetadata Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject of the trust verdict - the VEX document being evaluated.
|
||||
/// </summary>
|
||||
public sealed record TrustVerdictSubject
|
||||
{
|
||||
/// <summary>
|
||||
/// Content-addressable digest of the VEX document (sha256:...).
|
||||
/// </summary>
|
||||
[JsonPropertyName("vexDigest")]
|
||||
public required string VexDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Format of the VEX document (openvex, csaf, cyclonedx).
|
||||
/// </summary>
|
||||
[JsonPropertyName("vexFormat")]
|
||||
public required string VexFormat { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Provider/issuer identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("providerId")]
|
||||
public required string ProviderId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Statement identifier within the VEX document.
|
||||
/// </summary>
|
||||
[JsonPropertyName("statementId")]
|
||||
public required string StatementId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE or vulnerability identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Product/component key (PURL or similar).
|
||||
/// </summary>
|
||||
[JsonPropertyName("productKey")]
|
||||
public required string ProductKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX status being asserted (not_affected, fixed, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("vexStatus")]
|
||||
public string? VexStatus { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of origin/signature verification.
|
||||
/// </summary>
|
||||
public sealed record OriginVerification
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the signature was successfully verified.
|
||||
/// </summary>
|
||||
[JsonPropertyName("valid")]
|
||||
public required bool Valid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verification method used (dsse, cosign, pgp, x509, keyless).
|
||||
/// </summary>
|
||||
[JsonPropertyName("method")]
|
||||
public required string Method { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key identifier used for verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("keyId")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Issuer display name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("issuerName")]
|
||||
public string? IssuerName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Issuer canonical identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("issuerId")]
|
||||
public string? IssuerId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Certificate subject (for X.509/keyless).
|
||||
/// </summary>
|
||||
[JsonPropertyName("certSubject")]
|
||||
public string? CertSubject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Certificate fingerprint (for X.509/keyless).
|
||||
/// </summary>
|
||||
[JsonPropertyName("certFingerprint")]
|
||||
public string? CertFingerprint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// OIDC issuer for keyless signing.
|
||||
/// </summary>
|
||||
[JsonPropertyName("oidcIssuer")]
|
||||
public string? OidcIssuer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log index if transparency was verified.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rekorLogIndex")]
|
||||
public long? RekorLogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rekorLogId")]
|
||||
public string? RekorLogId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for verification failure (if valid=false).
|
||||
/// </summary>
|
||||
[JsonPropertyName("failureReason")]
|
||||
public string? FailureReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Origin verification score (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("score")]
|
||||
public decimal Score { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Freshness evaluation result.
|
||||
/// </summary>
|
||||
public sealed record FreshnessEvaluation
|
||||
{
|
||||
/// <summary>
|
||||
/// Freshness status (fresh, stale, superseded, expired).
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the VEX statement was issued.
|
||||
/// </summary>
|
||||
[JsonPropertyName("issuedAt")]
|
||||
public required DateTimeOffset IssuedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the VEX statement expires (if any).
|
||||
/// </summary>
|
||||
[JsonPropertyName("expiresAt")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Identifier of superseding VEX (if superseded).
|
||||
/// </summary>
|
||||
[JsonPropertyName("supersededBy")]
|
||||
public string? SupersededBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Age in days at evaluation time.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ageInDays")]
|
||||
public int AgeInDays { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Freshness score (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("score")]
|
||||
public required decimal Score { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reputation score breakdown.
|
||||
/// </summary>
|
||||
public sealed record ReputationScore
|
||||
{
|
||||
/// <summary>
|
||||
/// Composite reputation score (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("composite")]
|
||||
public required decimal Composite { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Authority factor (issuer trust level).
|
||||
/// </summary>
|
||||
[JsonPropertyName("authority")]
|
||||
public required decimal Authority { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Accuracy factor (historical correctness).
|
||||
/// </summary>
|
||||
[JsonPropertyName("accuracy")]
|
||||
public required decimal Accuracy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timeliness factor (response speed to vulnerabilities).
|
||||
/// </summary>
|
||||
[JsonPropertyName("timeliness")]
|
||||
public required decimal Timeliness { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Coverage factor (product/ecosystem coverage).
|
||||
/// </summary>
|
||||
[JsonPropertyName("coverage")]
|
||||
public required decimal Coverage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verification factor (signing practices).
|
||||
/// </summary>
|
||||
[JsonPropertyName("verification")]
|
||||
public required decimal Verification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the reputation was computed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("computedAt")]
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of historical samples used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sampleCount")]
|
||||
public int SampleCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composite trust score and classification.
|
||||
/// </summary>
|
||||
public sealed record TrustComposite
|
||||
{
|
||||
/// <summary>
|
||||
/// Final trust score (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("score")]
|
||||
public required decimal Score { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trust tier classification (VeryHigh, High, Medium, Low, VeryLow).
|
||||
/// </summary>
|
||||
[JsonPropertyName("tier")]
|
||||
public required string Tier { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable reasons contributing to the score.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reasons")]
|
||||
public required IReadOnlyList<string> Reasons { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Formula used for computation (for transparency).
|
||||
/// </summary>
|
||||
[JsonPropertyName("formula")]
|
||||
public required string Formula { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the score meets the policy threshold.
|
||||
/// </summary>
|
||||
[JsonPropertyName("meetsPolicyThreshold")]
|
||||
public bool MeetsPolicyThreshold { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy threshold applied.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyThreshold")]
|
||||
public decimal? PolicyThreshold { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence chain for audit and replay.
|
||||
/// </summary>
|
||||
public sealed record TrustEvidenceChain
|
||||
{
|
||||
/// <summary>
|
||||
/// Merkle root hash of the evidence items.
|
||||
/// </summary>
|
||||
[JsonPropertyName("merkleRoot")]
|
||||
public required string MerkleRoot { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Individual evidence items.
|
||||
/// </summary>
|
||||
[JsonPropertyName("items")]
|
||||
public required IReadOnlyList<TrustEvidenceItem> Items { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single evidence item in the chain.
|
||||
/// </summary>
|
||||
public sealed record TrustEvidenceItem
|
||||
{
|
||||
/// <summary>
|
||||
/// Type of evidence (signature, certificate, rekor_entry, issuer_profile, vex_document).
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressable digest of the evidence.
|
||||
/// </summary>
|
||||
[JsonPropertyName("digest")]
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URI to retrieve the evidence (if available).
|
||||
/// </summary>
|
||||
[JsonPropertyName("uri")]
|
||||
public string? Uri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the evidence was collected.
|
||||
/// </summary>
|
||||
[JsonPropertyName("collectedAt")]
|
||||
public DateTimeOffset? CollectedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata about the trust evaluation.
|
||||
/// </summary>
|
||||
public sealed record TrustEvaluationMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// When the evaluation was performed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evaluatedAt")]
|
||||
public required DateTimeOffset EvaluatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the evaluator component.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evaluatorVersion")]
|
||||
public required string EvaluatorVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Crypto profile used (world, fips, gost, sm, eidas).
|
||||
/// </summary>
|
||||
[JsonPropertyName("cryptoProfile")]
|
||||
public required string CryptoProfile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tenantId")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the policy bundle applied.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyDigest")]
|
||||
public string? PolicyDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Environment context (production, staging, development).
|
||||
/// </summary>
|
||||
[JsonPropertyName("environment")]
|
||||
public string? Environment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Correlation ID for tracing.
|
||||
/// </summary>
|
||||
[JsonPropertyName("correlationId")]
|
||||
public string? CorrelationId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Well-known evidence types.
|
||||
/// </summary>
|
||||
public static class TrustEvidenceTypes
|
||||
{
|
||||
public const string VexDocument = "vex_document";
|
||||
public const string Signature = "signature";
|
||||
public const string Certificate = "certificate";
|
||||
public const string RekorEntry = "rekor_entry";
|
||||
public const string IssuerProfile = "issuer_profile";
|
||||
public const string IssuerKey = "issuer_key";
|
||||
public const string PolicyBundle = "policy_bundle";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Well-known trust tiers.
|
||||
/// </summary>
|
||||
public static class TrustTiers
|
||||
{
|
||||
public const string VeryHigh = "VeryHigh";
|
||||
public const string High = "High";
|
||||
public const string Medium = "Medium";
|
||||
public const string Low = "Low";
|
||||
public const string VeryLow = "VeryLow";
|
||||
|
||||
public static string FromScore(decimal score) => score switch
|
||||
{
|
||||
>= 0.9m => VeryHigh,
|
||||
>= 0.7m => High,
|
||||
>= 0.5m => Medium,
|
||||
>= 0.3m => Low,
|
||||
_ => VeryLow
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Well-known freshness statuses.
|
||||
/// </summary>
|
||||
public static class FreshnessStatuses
|
||||
{
|
||||
public const string Fresh = "fresh";
|
||||
public const string Stale = "stale";
|
||||
public const string Superseded = "superseded";
|
||||
public const string Expired = "expired";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Well-known verification methods.
|
||||
/// </summary>
|
||||
public static class VerificationMethods
|
||||
{
|
||||
public const string Dsse = "dsse";
|
||||
public const string DsseKeyless = "dsse_keyless";
|
||||
public const string Cosign = "cosign";
|
||||
public const string CosignKeyless = "cosign_keyless";
|
||||
public const string Pgp = "pgp";
|
||||
public const string X509 = "x509";
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
// TrustVerdictService - Service for generating signed TrustVerdict attestations
|
||||
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.StandardPredicates;
|
||||
using StellaOps.Attestor.TrustVerdict.Predicates;
|
||||
|
||||
namespace StellaOps.Attestor.TrustVerdict.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for generating and verifying signed TrustVerdict attestations.
|
||||
/// </summary>
|
||||
public interface ITrustVerdictService
|
||||
{
|
||||
/// <summary>
|
||||
/// Generate a signed TrustVerdict for a VEX document.
|
||||
/// </summary>
|
||||
/// <param name="request">The verdict generation request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The verdict result with signed envelope.</returns>
|
||||
Task<TrustVerdictResult> GenerateVerdictAsync(
|
||||
TrustVerdictRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Batch generation for performance.
|
||||
/// </summary>
|
||||
/// <param name="requests">Multiple verdict requests.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Results for each request.</returns>
|
||||
Task<IReadOnlyList<TrustVerdictResult>> GenerateBatchAsync(
|
||||
IEnumerable<TrustVerdictRequest> requests,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Compute deterministic verdict digest without signing.
|
||||
/// Used for cache lookups.
|
||||
/// </summary>
|
||||
string ComputeVerdictDigest(TrustVerdictPredicate predicate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for generating a TrustVerdict.
|
||||
/// </summary>
|
||||
public sealed record TrustVerdictRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// VEX document digest (sha256:...).
|
||||
/// </summary>
|
||||
public required string VexDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX document format (openvex, csaf, cyclonedx).
|
||||
/// </summary>
|
||||
public required string VexFormat { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Provider/issuer identifier.
|
||||
/// </summary>
|
||||
public required string ProviderId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Statement identifier.
|
||||
/// </summary>
|
||||
public required string StatementId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability identifier.
|
||||
/// </summary>
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Product key (PURL or similar).
|
||||
/// </summary>
|
||||
public required string ProductKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX status (not_affected, fixed, etc.).
|
||||
/// </summary>
|
||||
public string? VexStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Origin verification result.
|
||||
/// </summary>
|
||||
public required TrustVerdictOriginInput Origin { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Freshness evaluation input.
|
||||
/// </summary>
|
||||
public required TrustVerdictFreshnessInput Freshness { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reputation score input.
|
||||
/// </summary>
|
||||
public required TrustVerdictReputationInput Reputation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence items collected.
|
||||
/// </summary>
|
||||
public IReadOnlyList<TrustVerdictEvidenceInput> EvidenceItems { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Options for verdict generation.
|
||||
/// </summary>
|
||||
public required TrustVerdictOptions Options { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Origin verification input.
|
||||
/// </summary>
|
||||
public sealed record TrustVerdictOriginInput
|
||||
{
|
||||
public required bool Valid { get; init; }
|
||||
public required string Method { get; init; }
|
||||
public string? KeyId { get; init; }
|
||||
public string? IssuerName { get; init; }
|
||||
public string? IssuerId { get; init; }
|
||||
public string? CertSubject { get; init; }
|
||||
public string? CertFingerprint { get; init; }
|
||||
public string? OidcIssuer { get; init; }
|
||||
public long? RekorLogIndex { get; init; }
|
||||
public string? RekorLogId { get; init; }
|
||||
public string? FailureReason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Freshness evaluation input.
|
||||
/// </summary>
|
||||
public sealed record TrustVerdictFreshnessInput
|
||||
{
|
||||
public required string Status { get; init; }
|
||||
public required DateTimeOffset IssuedAt { get; init; }
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
public string? SupersededBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reputation score input.
|
||||
/// </summary>
|
||||
public sealed record TrustVerdictReputationInput
|
||||
{
|
||||
public required decimal Authority { get; init; }
|
||||
public required decimal Accuracy { get; init; }
|
||||
public required decimal Timeliness { get; init; }
|
||||
public required decimal Coverage { get; init; }
|
||||
public required decimal Verification { get; init; }
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
public int SampleCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence item input.
|
||||
/// </summary>
|
||||
public sealed record TrustVerdictEvidenceInput
|
||||
{
|
||||
public required string Type { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
public string? Uri { get; init; }
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for verdict generation.
|
||||
/// </summary>
|
||||
public sealed record TrustVerdictOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Crypto profile (world, fips, gost, sm, eidas).
|
||||
/// </summary>
|
||||
public required string CryptoProfile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Environment (production, staging, development).
|
||||
/// </summary>
|
||||
public string? Environment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy digest applied.
|
||||
/// </summary>
|
||||
public string? PolicyDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy threshold for this context.
|
||||
/// </summary>
|
||||
public decimal? PolicyThreshold { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Correlation ID for tracing.
|
||||
/// </summary>
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to attach to OCI registry.
|
||||
/// </summary>
|
||||
public bool AttachToOci { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// OCI reference for attachment.
|
||||
/// </summary>
|
||||
public string? OciReference { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to publish to Rekor.
|
||||
/// </summary>
|
||||
public bool PublishToRekor { get; init; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of verdict generation.
|
||||
/// </summary>
|
||||
public sealed record TrustVerdictResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether generation succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The generated predicate.
|
||||
/// </summary>
|
||||
public TrustVerdictPredicate? Predicate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic digest of the verdict.
|
||||
/// </summary>
|
||||
public string? VerdictDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signed DSSE envelope (base64 encoded).
|
||||
/// </summary>
|
||||
public string? EnvelopeBase64 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// OCI digest if attached.
|
||||
/// </summary>
|
||||
public string? OciDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log index if published.
|
||||
/// </summary>
|
||||
public long? RekorLogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Processing duration.
|
||||
/// </summary>
|
||||
public TimeSpan Duration { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of ITrustVerdictService.
|
||||
/// </summary>
|
||||
public sealed class TrustVerdictService : ITrustVerdictService
|
||||
{
|
||||
private readonly IOptionsMonitor<TrustVerdictServiceOptions> _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<TrustVerdictService> _logger;
|
||||
|
||||
// Standard formula for trust composite calculation
|
||||
private const string DefaultFormula = "0.50*Origin + 0.30*Freshness + 0.20*Reputation";
|
||||
|
||||
public TrustVerdictService(
|
||||
IOptionsMonitor<TrustVerdictServiceOptions> options,
|
||||
ILogger<TrustVerdictService> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TrustVerdictResult> GenerateVerdictAsync(
|
||||
TrustVerdictRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var startTime = _timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Build predicate
|
||||
var predicate = BuildPredicate(request, startTime);
|
||||
|
||||
// 2. Compute deterministic verdict digest
|
||||
var verdictDigest = ComputeVerdictDigest(predicate);
|
||||
|
||||
// Note: Actual DSSE signing would happen here via IDsseSigner
|
||||
// For this implementation, we return the predicate ready for signing
|
||||
|
||||
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||
|
||||
_logger.LogDebug(
|
||||
"Generated TrustVerdict for {VexDigest} with score {Score} in {Duration}ms",
|
||||
request.VexDigest,
|
||||
predicate.Composite.Score,
|
||||
duration.TotalMilliseconds);
|
||||
|
||||
return Task.FromResult(new TrustVerdictResult
|
||||
{
|
||||
Success = true,
|
||||
Predicate = predicate,
|
||||
VerdictDigest = verdictDigest,
|
||||
Duration = duration
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to generate TrustVerdict for {VexDigest}", request.VexDigest);
|
||||
|
||||
return Task.FromResult(new TrustVerdictResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = ex.Message,
|
||||
Duration = _timeProvider.GetUtcNow() - startTime
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TrustVerdictResult>> GenerateBatchAsync(
|
||||
IEnumerable<TrustVerdictRequest> requests,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var results = new List<TrustVerdictResult>();
|
||||
|
||||
foreach (var request in requests)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var result = await GenerateVerdictAsync(request, ct);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ComputeVerdictDigest(TrustVerdictPredicate predicate)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(predicate);
|
||||
|
||||
// Use canonical JSON serialization for determinism
|
||||
var canonical = JsonCanonicalizer.Canonicalize(predicate);
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical));
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
private TrustVerdictPredicate BuildPredicate(
|
||||
TrustVerdictRequest request,
|
||||
DateTimeOffset evaluatedAt)
|
||||
{
|
||||
var options = _options.CurrentValue;
|
||||
|
||||
// Build subject
|
||||
var subject = new TrustVerdictSubject
|
||||
{
|
||||
VexDigest = request.VexDigest,
|
||||
VexFormat = request.VexFormat,
|
||||
ProviderId = request.ProviderId,
|
||||
StatementId = request.StatementId,
|
||||
VulnerabilityId = request.VulnerabilityId,
|
||||
ProductKey = request.ProductKey,
|
||||
VexStatus = request.VexStatus
|
||||
};
|
||||
|
||||
// Build origin verification
|
||||
var originScore = request.Origin.Valid ? 1.0m : 0.0m;
|
||||
var origin = new OriginVerification
|
||||
{
|
||||
Valid = request.Origin.Valid,
|
||||
Method = request.Origin.Method,
|
||||
KeyId = request.Origin.KeyId,
|
||||
IssuerName = request.Origin.IssuerName,
|
||||
IssuerId = request.Origin.IssuerId,
|
||||
CertSubject = request.Origin.CertSubject,
|
||||
CertFingerprint = request.Origin.CertFingerprint,
|
||||
OidcIssuer = request.Origin.OidcIssuer,
|
||||
RekorLogIndex = request.Origin.RekorLogIndex,
|
||||
RekorLogId = request.Origin.RekorLogId,
|
||||
FailureReason = request.Origin.FailureReason,
|
||||
Score = originScore
|
||||
};
|
||||
|
||||
// Build freshness evaluation
|
||||
var ageInDays = (int)(evaluatedAt - request.Freshness.IssuedAt).TotalDays;
|
||||
var freshnessScore = ComputeFreshnessScore(request.Freshness.Status, ageInDays);
|
||||
var freshness = new FreshnessEvaluation
|
||||
{
|
||||
Status = request.Freshness.Status,
|
||||
IssuedAt = request.Freshness.IssuedAt,
|
||||
ExpiresAt = request.Freshness.ExpiresAt,
|
||||
SupersededBy = request.Freshness.SupersededBy,
|
||||
AgeInDays = ageInDays,
|
||||
Score = freshnessScore
|
||||
};
|
||||
|
||||
// Build reputation score
|
||||
var reputationComposite = ComputeReputationComposite(request.Reputation);
|
||||
var reputation = new ReputationScore
|
||||
{
|
||||
Composite = reputationComposite,
|
||||
Authority = request.Reputation.Authority,
|
||||
Accuracy = request.Reputation.Accuracy,
|
||||
Timeliness = request.Reputation.Timeliness,
|
||||
Coverage = request.Reputation.Coverage,
|
||||
Verification = request.Reputation.Verification,
|
||||
ComputedAt = request.Reputation.ComputedAt,
|
||||
SampleCount = request.Reputation.SampleCount
|
||||
};
|
||||
|
||||
// Compute composite trust score
|
||||
var compositeScore = ComputeCompositeScore(originScore, freshnessScore, reputationComposite);
|
||||
var meetsPolicyThreshold = request.Options.PolicyThreshold.HasValue
|
||||
&& compositeScore >= request.Options.PolicyThreshold.Value;
|
||||
|
||||
var reasons = BuildReasons(origin, freshness, reputation, compositeScore);
|
||||
|
||||
var composite = new TrustComposite
|
||||
{
|
||||
Score = compositeScore,
|
||||
Tier = TrustTiers.FromScore(compositeScore),
|
||||
Reasons = reasons,
|
||||
Formula = DefaultFormula,
|
||||
MeetsPolicyThreshold = meetsPolicyThreshold,
|
||||
PolicyThreshold = request.Options.PolicyThreshold
|
||||
};
|
||||
|
||||
// Build evidence chain
|
||||
var evidenceItems = request.EvidenceItems
|
||||
.OrderBy(e => e.Digest, StringComparer.Ordinal)
|
||||
.Select(e => new TrustEvidenceItem
|
||||
{
|
||||
Type = e.Type,
|
||||
Digest = e.Digest,
|
||||
Uri = e.Uri,
|
||||
Description = e.Description,
|
||||
CollectedAt = evaluatedAt
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var merkleRoot = ComputeMerkleRoot(evidenceItems);
|
||||
|
||||
var evidence = new TrustEvidenceChain
|
||||
{
|
||||
MerkleRoot = merkleRoot,
|
||||
Items = evidenceItems
|
||||
};
|
||||
|
||||
// Build metadata
|
||||
var metadata = new TrustEvaluationMetadata
|
||||
{
|
||||
EvaluatedAt = evaluatedAt,
|
||||
EvaluatorVersion = options.EvaluatorVersion,
|
||||
CryptoProfile = request.Options.CryptoProfile,
|
||||
TenantId = request.Options.TenantId,
|
||||
PolicyDigest = request.Options.PolicyDigest,
|
||||
Environment = request.Options.Environment,
|
||||
CorrelationId = request.Options.CorrelationId
|
||||
};
|
||||
|
||||
return new TrustVerdictPredicate
|
||||
{
|
||||
SchemaVersion = "1.0.0",
|
||||
Subject = subject,
|
||||
Origin = origin,
|
||||
Freshness = freshness,
|
||||
Reputation = reputation,
|
||||
Composite = composite,
|
||||
Evidence = evidence,
|
||||
Metadata = metadata
|
||||
};
|
||||
}
|
||||
|
||||
private static decimal ComputeFreshnessScore(string status, int ageInDays)
|
||||
{
|
||||
// Base score from status
|
||||
var baseScore = status.ToLowerInvariant() switch
|
||||
{
|
||||
FreshnessStatuses.Fresh => 1.0m,
|
||||
FreshnessStatuses.Stale => 0.6m,
|
||||
FreshnessStatuses.Superseded => 0.3m,
|
||||
FreshnessStatuses.Expired => 0.1m,
|
||||
_ => 0.5m
|
||||
};
|
||||
|
||||
// Decay based on age (90-day half-life)
|
||||
if (ageInDays > 0)
|
||||
{
|
||||
var decay = (decimal)Math.Exp(-ageInDays / 90.0);
|
||||
baseScore = Math.Max(0.1m, baseScore * decay);
|
||||
}
|
||||
|
||||
return Math.Round(baseScore, 3);
|
||||
}
|
||||
|
||||
private static decimal ComputeReputationComposite(TrustVerdictReputationInput input)
|
||||
{
|
||||
// Weighted average of reputation factors
|
||||
var composite =
|
||||
input.Authority * 0.25m +
|
||||
input.Accuracy * 0.30m +
|
||||
input.Timeliness * 0.15m +
|
||||
input.Coverage * 0.15m +
|
||||
input.Verification * 0.15m;
|
||||
|
||||
return Math.Clamp(Math.Round(composite, 3), 0m, 1m);
|
||||
}
|
||||
|
||||
private static decimal ComputeCompositeScore(
|
||||
decimal originScore,
|
||||
decimal freshnessScore,
|
||||
decimal reputationScore)
|
||||
{
|
||||
// Formula: 0.50*Origin + 0.30*Freshness + 0.20*Reputation
|
||||
var composite =
|
||||
originScore * 0.50m +
|
||||
freshnessScore * 0.30m +
|
||||
reputationScore * 0.20m;
|
||||
|
||||
return Math.Clamp(Math.Round(composite, 3), 0m, 1m);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildReasons(
|
||||
OriginVerification origin,
|
||||
FreshnessEvaluation freshness,
|
||||
ReputationScore reputation,
|
||||
decimal compositeScore)
|
||||
{
|
||||
var reasons = new List<string>();
|
||||
|
||||
// Origin reason
|
||||
if (origin.Valid)
|
||||
{
|
||||
reasons.Add($"Signature verified via {origin.Method}");
|
||||
if (origin.RekorLogIndex.HasValue)
|
||||
{
|
||||
reasons.Add($"Logged in transparency log (Rekor #{origin.RekorLogIndex})");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
reasons.Add($"Signature not verified: {origin.FailureReason ?? "unknown"}");
|
||||
}
|
||||
|
||||
// Freshness reason
|
||||
reasons.Add($"VEX freshness: {freshness.Status} ({freshness.AgeInDays} days old)");
|
||||
|
||||
// Reputation reason
|
||||
reasons.Add($"Issuer reputation: {reputation.Composite:P0} ({reputation.SampleCount} samples)");
|
||||
|
||||
// Composite summary
|
||||
var tier = TrustTiers.FromScore(compositeScore);
|
||||
reasons.Add($"Overall trust: {tier} ({compositeScore:P0})");
|
||||
|
||||
return reasons;
|
||||
}
|
||||
|
||||
private static string ComputeMerkleRoot(IReadOnlyList<TrustEvidenceItem> items)
|
||||
{
|
||||
if (items.Count == 0)
|
||||
{
|
||||
return "sha256:" + Convert.ToHexStringLower(SHA256.HashData([]));
|
||||
}
|
||||
|
||||
// Get leaf hashes
|
||||
var hashes = items
|
||||
.Select(i => SHA256.HashData(Encoding.UTF8.GetBytes(i.Digest)))
|
||||
.ToList();
|
||||
|
||||
// Build tree bottom-up
|
||||
while (hashes.Count > 1)
|
||||
{
|
||||
var newLevel = new List<byte[]>();
|
||||
|
||||
for (var i = 0; i < hashes.Count; i += 2)
|
||||
{
|
||||
if (i + 1 < hashes.Count)
|
||||
{
|
||||
// Combine two nodes
|
||||
var combined = new byte[hashes[i].Length + hashes[i + 1].Length];
|
||||
hashes[i].CopyTo(combined, 0);
|
||||
hashes[i + 1].CopyTo(combined, hashes[i].Length);
|
||||
newLevel.Add(SHA256.HashData(combined));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Odd node, promote as-is
|
||||
newLevel.Add(hashes[i]);
|
||||
}
|
||||
}
|
||||
|
||||
hashes = newLevel;
|
||||
}
|
||||
|
||||
return $"sha256:{Convert.ToHexStringLower(hashes[0])}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for TrustVerdictService.
|
||||
/// </summary>
|
||||
public sealed class TrustVerdictServiceOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section key.
|
||||
/// </summary>
|
||||
public const string SectionKey = "TrustVerdict";
|
||||
|
||||
/// <summary>
|
||||
/// Evaluator version string.
|
||||
/// </summary>
|
||||
public string EvaluatorVersion { get; set; } = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Default TTL for cached verdicts.
|
||||
/// </summary>
|
||||
public TimeSpan CacheTtl { get; set; } = TimeSpan.FromHours(1);
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable Rekor publishing by default.
|
||||
/// </summary>
|
||||
public bool DefaultRekorPublish { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable OCI attachment by default.
|
||||
/// </summary>
|
||||
public bool DefaultOciAttach { get; set; } = false;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>StellaOps.Attestor.TrustVerdict</RootNamespace>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Description>TrustVerdict attestation library for signed VEX trust evaluations</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Attestor.StandardPredicates\StellaOps.Attestor.StandardPredicates.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,298 @@
|
||||
// TrustVerdictMetrics - OpenTelemetry metrics for TrustVerdict attestations
|
||||
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace StellaOps.Attestor.TrustVerdict.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// OpenTelemetry metrics for TrustVerdict operations.
|
||||
/// </summary>
|
||||
public sealed class TrustVerdictMetrics : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Meter name for TrustVerdict metrics.
|
||||
/// </summary>
|
||||
public const string MeterName = "StellaOps.TrustVerdict";
|
||||
|
||||
/// <summary>
|
||||
/// Activity source name for TrustVerdict tracing.
|
||||
/// </summary>
|
||||
public const string ActivitySourceName = "StellaOps.TrustVerdict";
|
||||
|
||||
private readonly Meter _meter;
|
||||
|
||||
// Counters
|
||||
private readonly Counter<long> _verdictsGenerated;
|
||||
private readonly Counter<long> _verdictsVerified;
|
||||
private readonly Counter<long> _verdictsFailed;
|
||||
private readonly Counter<long> _cacheHits;
|
||||
private readonly Counter<long> _cacheMisses;
|
||||
private readonly Counter<long> _rekorPublications;
|
||||
private readonly Counter<long> _ociAttachments;
|
||||
|
||||
// Histograms
|
||||
private readonly Histogram<double> _verdictGenerationDuration;
|
||||
private readonly Histogram<double> _verdictVerificationDuration;
|
||||
private readonly Histogram<double> _trustScore;
|
||||
private readonly Histogram<int> _evidenceItemCount;
|
||||
private readonly Histogram<double> _merkleTreeBuildDuration;
|
||||
|
||||
// Gauges (via observable)
|
||||
private readonly ObservableGauge<long> _cacheEntries;
|
||||
private long _currentCacheEntries;
|
||||
|
||||
/// <summary>
|
||||
/// Activity source for distributed tracing.
|
||||
/// </summary>
|
||||
public static readonly ActivitySource ActivitySource = new(ActivitySourceName);
|
||||
|
||||
public TrustVerdictMetrics(IMeterFactory? meterFactory = null)
|
||||
{
|
||||
_meter = meterFactory?.Create(MeterName) ?? new Meter(MeterName);
|
||||
|
||||
// Counters
|
||||
_verdictsGenerated = _meter.CreateCounter<long>(
|
||||
"stellaops.trustverdicts.generated.total",
|
||||
unit: "{verdict}",
|
||||
description: "Total number of TrustVerdicts generated");
|
||||
|
||||
_verdictsVerified = _meter.CreateCounter<long>(
|
||||
"stellaops.trustverdicts.verified.total",
|
||||
unit: "{verdict}",
|
||||
description: "Total number of TrustVerdicts verified");
|
||||
|
||||
_verdictsFailed = _meter.CreateCounter<long>(
|
||||
"stellaops.trustverdicts.failed.total",
|
||||
unit: "{verdict}",
|
||||
description: "Total number of TrustVerdict generation failures");
|
||||
|
||||
_cacheHits = _meter.CreateCounter<long>(
|
||||
"stellaops.trustverdicts.cache.hits.total",
|
||||
unit: "{hit}",
|
||||
description: "Total number of cache hits");
|
||||
|
||||
_cacheMisses = _meter.CreateCounter<long>(
|
||||
"stellaops.trustverdicts.cache.misses.total",
|
||||
unit: "{miss}",
|
||||
description: "Total number of cache misses");
|
||||
|
||||
_rekorPublications = _meter.CreateCounter<long>(
|
||||
"stellaops.trustverdicts.rekor.publications.total",
|
||||
unit: "{publication}",
|
||||
description: "Total number of verdicts published to Rekor");
|
||||
|
||||
_ociAttachments = _meter.CreateCounter<long>(
|
||||
"stellaops.trustverdicts.oci.attachments.total",
|
||||
unit: "{attachment}",
|
||||
description: "Total number of verdicts attached to OCI artifacts");
|
||||
|
||||
// Histograms
|
||||
_verdictGenerationDuration = _meter.CreateHistogram<double>(
|
||||
"stellaops.trustverdicts.generation.duration",
|
||||
unit: "ms",
|
||||
description: "Duration of TrustVerdict generation");
|
||||
|
||||
_verdictVerificationDuration = _meter.CreateHistogram<double>(
|
||||
"stellaops.trustverdicts.verification.duration",
|
||||
unit: "ms",
|
||||
description: "Duration of TrustVerdict verification");
|
||||
|
||||
_trustScore = _meter.CreateHistogram<double>(
|
||||
"stellaops.trustverdicts.trust_score",
|
||||
unit: "1",
|
||||
description: "Distribution of computed trust scores");
|
||||
|
||||
_evidenceItemCount = _meter.CreateHistogram<int>(
|
||||
"stellaops.trustverdicts.evidence_items",
|
||||
unit: "{item}",
|
||||
description: "Number of evidence items per verdict");
|
||||
|
||||
_merkleTreeBuildDuration = _meter.CreateHistogram<double>(
|
||||
"stellaops.trustverdicts.merkle_tree.build.duration",
|
||||
unit: "ms",
|
||||
description: "Duration of Merkle tree construction");
|
||||
|
||||
// Observable gauge for cache entries
|
||||
_cacheEntries = _meter.CreateObservableGauge(
|
||||
"stellaops.trustverdicts.cache.entries",
|
||||
() => _currentCacheEntries,
|
||||
unit: "{entry}",
|
||||
description: "Current number of cached verdicts");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record a verdict generation.
|
||||
/// </summary>
|
||||
public void RecordVerdictGenerated(
|
||||
string tenantId,
|
||||
string tier,
|
||||
decimal trustScore,
|
||||
int evidenceCount,
|
||||
TimeSpan duration,
|
||||
bool success)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "tenant_id", tenantId },
|
||||
{ "trust_tier", tier },
|
||||
{ "success", success.ToString().ToLowerInvariant() }
|
||||
};
|
||||
|
||||
if (success)
|
||||
{
|
||||
_verdictsGenerated.Add(1, tags);
|
||||
_trustScore.Record((double)trustScore, tags);
|
||||
_evidenceItemCount.Record(evidenceCount, tags);
|
||||
}
|
||||
else
|
||||
{
|
||||
_verdictsFailed.Add(1, tags);
|
||||
}
|
||||
|
||||
_verdictGenerationDuration.Record(duration.TotalMilliseconds, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record a verdict verification.
|
||||
/// </summary>
|
||||
public void RecordVerdictVerified(
|
||||
string tenantId,
|
||||
bool valid,
|
||||
TimeSpan duration)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "tenant_id", tenantId },
|
||||
{ "valid", valid.ToString().ToLowerInvariant() }
|
||||
};
|
||||
|
||||
_verdictsVerified.Add(1, tags);
|
||||
_verdictVerificationDuration.Record(duration.TotalMilliseconds, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record a cache hit.
|
||||
/// </summary>
|
||||
public void RecordCacheHit(string tenantId)
|
||||
{
|
||||
_cacheHits.Add(1, new TagList { { "tenant_id", tenantId } });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record a cache miss.
|
||||
/// </summary>
|
||||
public void RecordCacheMiss(string tenantId)
|
||||
{
|
||||
_cacheMisses.Add(1, new TagList { { "tenant_id", tenantId } });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record a Rekor publication.
|
||||
/// </summary>
|
||||
public void RecordRekorPublication(string tenantId, bool success)
|
||||
{
|
||||
_rekorPublications.Add(1, new TagList
|
||||
{
|
||||
{ "tenant_id", tenantId },
|
||||
{ "success", success.ToString().ToLowerInvariant() }
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record an OCI attachment.
|
||||
/// </summary>
|
||||
public void RecordOciAttachment(string tenantId, bool success)
|
||||
{
|
||||
_ociAttachments.Add(1, new TagList
|
||||
{
|
||||
{ "tenant_id", tenantId },
|
||||
{ "success", success.ToString().ToLowerInvariant() }
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record Merkle tree build duration.
|
||||
/// </summary>
|
||||
public void RecordMerkleTreeBuild(int leafCount, TimeSpan duration)
|
||||
{
|
||||
_merkleTreeBuildDuration.Record(duration.TotalMilliseconds, new TagList
|
||||
{
|
||||
{ "leaf_count_bucket", GetLeafCountBucket(leafCount) }
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update the cache entry count gauge.
|
||||
/// </summary>
|
||||
public void SetCacheEntryCount(long count)
|
||||
{
|
||||
_currentCacheEntries = count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start an activity for verdict generation.
|
||||
/// </summary>
|
||||
public static Activity? StartGenerationActivity(string vexDigest, string tenantId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("TrustVerdict.Generate");
|
||||
activity?.SetTag("vex.digest", vexDigest);
|
||||
activity?.SetTag("tenant.id", tenantId);
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start an activity for verdict verification.
|
||||
/// </summary>
|
||||
public static Activity? StartVerificationActivity(string verdictDigest, string tenantId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("TrustVerdict.Verify");
|
||||
activity?.SetTag("verdict.digest", verdictDigest);
|
||||
activity?.SetTag("tenant.id", tenantId);
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start an activity for cache lookup.
|
||||
/// </summary>
|
||||
public static Activity? StartCacheLookupActivity(string key)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("TrustVerdict.CacheLookup");
|
||||
activity?.SetTag("cache.key", key);
|
||||
return activity;
|
||||
}
|
||||
|
||||
private static string GetLeafCountBucket(int count) => count switch
|
||||
{
|
||||
0 => "0",
|
||||
<= 5 => "1-5",
|
||||
<= 10 => "6-10",
|
||||
<= 20 => "11-20",
|
||||
<= 50 => "21-50",
|
||||
_ => "50+"
|
||||
};
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_meter.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for adding TrustVerdict metrics.
|
||||
/// </summary>
|
||||
public static class TrustVerdictMetricsExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Add TrustVerdict OpenTelemetry metrics.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddTrustVerdictMetrics(
|
||||
this IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton<TrustVerdictMetrics>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// TrustVerdictServiceCollectionExtensions - DI registration for TrustVerdict services
|
||||
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Attestor.TrustVerdict.Caching;
|
||||
using StellaOps.Attestor.TrustVerdict.Evidence;
|
||||
using StellaOps.Attestor.TrustVerdict.Services;
|
||||
|
||||
namespace StellaOps.Attestor.TrustVerdict;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering TrustVerdict services.
|
||||
/// </summary>
|
||||
public static class TrustVerdictServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Add TrustVerdict attestation services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configuration">Configuration for binding options.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddTrustVerdictServices(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
// Bind configuration
|
||||
services.Configure<TrustVerdictServiceOptions>(
|
||||
configuration.GetSection(TrustVerdictServiceOptions.SectionKey));
|
||||
|
||||
services.Configure<TrustVerdictCacheOptions>(
|
||||
configuration.GetSection(TrustVerdictCacheOptions.SectionKey));
|
||||
|
||||
// Register core services
|
||||
services.TryAddSingleton<ITrustVerdictService, TrustVerdictService>();
|
||||
services.TryAddSingleton<ITrustEvidenceMerkleBuilder, TrustEvidenceMerkleBuilder>();
|
||||
|
||||
// Register cache based on configuration
|
||||
var cacheOptions = configuration
|
||||
.GetSection(TrustVerdictCacheOptions.SectionKey)
|
||||
.Get<TrustVerdictCacheOptions>() ?? new TrustVerdictCacheOptions();
|
||||
|
||||
if (cacheOptions.UseValkey)
|
||||
{
|
||||
services.TryAddSingleton<ITrustVerdictCache, ValkeyTrustVerdictCache>();
|
||||
}
|
||||
else
|
||||
{
|
||||
services.TryAddSingleton<ITrustVerdictCache, InMemoryTrustVerdictCache>();
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add TrustVerdict services with custom configuration.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configureService">Action to configure service options.</param>
|
||||
/// <param name="configureCache">Action to configure cache options.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddTrustVerdictServices(
|
||||
this IServiceCollection services,
|
||||
Action<TrustVerdictServiceOptions>? configureService = null,
|
||||
Action<TrustVerdictCacheOptions>? configureCache = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
// Configure options
|
||||
if (configureService != null)
|
||||
{
|
||||
services.Configure(configureService);
|
||||
}
|
||||
|
||||
if (configureCache != null)
|
||||
{
|
||||
services.Configure(configureCache);
|
||||
}
|
||||
|
||||
// Register core services
|
||||
services.TryAddSingleton<ITrustVerdictService, TrustVerdictService>();
|
||||
services.TryAddSingleton<ITrustEvidenceMerkleBuilder, TrustEvidenceMerkleBuilder>();
|
||||
services.TryAddSingleton<ITrustVerdictCache, InMemoryTrustVerdictCache>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add Valkey-backed TrustVerdict cache.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="connectionString">Valkey connection string.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddValkeyTrustVerdictCache(
|
||||
this IServiceCollection services,
|
||||
string connectionString)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
|
||||
|
||||
services.Configure<TrustVerdictCacheOptions>(opts =>
|
||||
{
|
||||
opts.UseValkey = true;
|
||||
opts.ConnectionString = connectionString;
|
||||
});
|
||||
|
||||
// Replace any existing cache registration
|
||||
services.RemoveAll<ITrustVerdictCache>();
|
||||
services.AddSingleton<ITrustVerdictCache, ValkeyTrustVerdictCache>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add in-memory TrustVerdict cache (for development/testing).
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="maxEntries">Maximum cache entries.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddInMemoryTrustVerdictCache(
|
||||
this IServiceCollection services,
|
||||
int maxEntries = 10_000)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.Configure<TrustVerdictCacheOptions>(opts =>
|
||||
{
|
||||
opts.UseValkey = false;
|
||||
opts.MaxEntries = maxEntries;
|
||||
});
|
||||
|
||||
// Replace any existing cache registration
|
||||
services.RemoveAll<ITrustVerdictCache>();
|
||||
services.AddSingleton<ITrustVerdictCache, InMemoryTrustVerdictCache>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -12,24 +12,10 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.GraphRoot\StellaOps.Attestor.GraphRoot.csproj" />
|
||||
@@ -37,5 +23,4 @@
|
||||
<ProjectReference Include="..\..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="../../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -1,4 +1,4 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// -----------------------------------------------------------------------------
|
||||
// SigstoreBundleVerifierTests.cs
|
||||
// Sprint: SPRINT_8200_0001_0005 - Sigstore Bundle Implementation
|
||||
// Tasks: BUNDLE-8200-020, BUNDLE-8200-021 - Bundle verification tests
|
||||
@@ -328,7 +328,6 @@ public class SigstoreBundleVerifierTests
|
||||
DateTimeOffset.UtcNow.AddDays(-1),
|
||||
DateTimeOffset.UtcNow.AddYears(1));
|
||||
|
||||
using StellaOps.TestKit;
|
||||
return cert.Export(System.Security.Cryptography.X509Certificates.X509ContentType.Cert);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,19 +8,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="8.4.0" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Bundle\StellaOps.Attestor.Bundle.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -1,4 +1,4 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// -----------------------------------------------------------------------------
|
||||
// BundleWorkflowIntegrationTests.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0023 - Integration test: Full bundle workflow
|
||||
@@ -22,7 +22,7 @@ namespace StellaOps.Attestor.Bundling.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the full bundle creation workflow:
|
||||
/// Create → Store → Retrieve → Verify
|
||||
/// Create → Store → Retrieve → Verify
|
||||
/// </summary>
|
||||
public class BundleWorkflowIntegrationTests
|
||||
{
|
||||
@@ -406,7 +406,6 @@ public class BundleWorkflowIntegrationTests
|
||||
}
|
||||
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
using StellaOps.TestKit;
|
||||
var combined = string.Join("|", attestations.Select(a => a.EntryId));
|
||||
var hash = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(combined));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
|
||||
@@ -10,23 +10,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="7.0.0" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Bundling\StellaOps.Attestor.Bundling.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -0,0 +1,131 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// © StellaOps Contributors. See LICENSE and NOTICE.md in the repository root.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using DotNet.Testcontainers.Builders;
|
||||
using DotNet.Testcontainers.Containers;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.Oci.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Oci.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for OCI attestation attachment using Testcontainers registry.
|
||||
/// Sprint: SPRINT_20251228_002_BE_oci_attestation_attach (T7)
|
||||
/// </summary>
|
||||
public sealed class OciAttestationAttacherIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private IContainer _registry = null!;
|
||||
private string _registryHost = null!;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_registry = new ContainerBuilder()
|
||||
.WithImage("registry:2")
|
||||
.WithPortBinding(5000, true)
|
||||
.WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => r.ForPath("/v2/").ForPort(5000)))
|
||||
.Build();
|
||||
|
||||
await _registry.StartAsync();
|
||||
_registryHost = _registry.Hostname + ":" + _registry.GetMappedPublicPort(5000);
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _registry.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact(Skip = "Requires registry push/pull implementation - placeholder for integration test")]
|
||||
public async Task AttachAsync_WithValidEnvelope_AttachesToRegistry()
|
||||
{
|
||||
// Arrange
|
||||
var imageRef = new OciReference
|
||||
{
|
||||
Registry = _registryHost,
|
||||
Repository = "test/app",
|
||||
Digest = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
};
|
||||
|
||||
// TODO: Create mock DsseEnvelope when types are accessible
|
||||
// var envelope = CreateTestEnvelope("test-payload");
|
||||
|
||||
var options = new AttachmentOptions
|
||||
{
|
||||
MediaType = MediaTypes.DsseEnvelope,
|
||||
ReplaceExisting = false
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
// Would use actual IOciAttestationAttacher implementation
|
||||
// var result = await attacher.AttachAsync(imageRef, envelope, options);
|
||||
// result.Should().NotBeNull();
|
||||
// result.AttestationDigest.Should().StartWith("sha256:");
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact(Skip = "Requires registry push/pull implementation - placeholder for integration test")]
|
||||
public async Task ListAsync_WithAttachedAttestations_ReturnsAllAttestations()
|
||||
{
|
||||
// Arrange
|
||||
var imageRef = new OciReference
|
||||
{
|
||||
Registry = _registryHost,
|
||||
Repository = "test/app",
|
||||
Digest = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
// Would list attestations attached to the image
|
||||
// var attestations = await attacher.ListAsync(imageRef);
|
||||
// attestations.Should().NotBeNull();
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact(Skip = "Requires registry push/pull implementation - placeholder for integration test")]
|
||||
public async Task FetchAsync_WithSpecificPredicateType_ReturnsMatchingEnvelope()
|
||||
{
|
||||
// Arrange
|
||||
var imageRef = new OciReference
|
||||
{
|
||||
Registry = _registryHost,
|
||||
Repository = "test/app",
|
||||
Digest = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
};
|
||||
|
||||
var predicateType = "stellaops.io/predicates/scan-result@v1";
|
||||
|
||||
// Act & Assert
|
||||
// Would fetch specific attestation by predicate type
|
||||
// var envelope = await attacher.FetchAsync(imageRef, predicateType);
|
||||
// envelope.Should().NotBeNull();
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact(Skip = "Requires registry push/pull implementation - placeholder for integration test")]
|
||||
public async Task RemoveAsync_WithExistingAttestation_RemovesFromRegistry()
|
||||
{
|
||||
// Arrange
|
||||
var imageRef = new OciReference
|
||||
{
|
||||
Registry = _registryHost,
|
||||
Repository = "test/app",
|
||||
Digest = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
};
|
||||
|
||||
var attestationDigest = "sha256:attestation-digest-placeholder";
|
||||
|
||||
// Act & Assert
|
||||
// Would remove attestation from registry
|
||||
// var result = await attacher.RemoveAsync(imageRef, attestationDigest);
|
||||
// result.Should().BeTrue();
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// © StellaOps Contributors. See LICENSE and NOTICE.md in the repository root.
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.Oci.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Oci.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="OciReference"/> parsing and construction.
|
||||
/// Sprint: SPRINT_20251228_002_BE_oci_attestation_attach (T7)
|
||||
/// </summary>
|
||||
public sealed class OciReferenceTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("docker.io/library/nginx@sha256:abc123", "docker.io", "library/nginx", "sha256:abc123")]
|
||||
[InlineData("ghcr.io/stellaops/scanner@sha256:def456", "ghcr.io", "stellaops/scanner", "sha256:def456")]
|
||||
[InlineData("registry.example.com:5000/app/web@sha256:789abc", "registry.example.com:5000", "app/web", "sha256:789abc")]
|
||||
[InlineData("localhost:5000/test@sha256:xyz789", "localhost:5000", "test", "sha256:xyz789")]
|
||||
public void Parse_WithValidDigestReference_ReturnsCorrectComponents(
|
||||
string reference,
|
||||
string expectedRegistry,
|
||||
string expectedRepository,
|
||||
string expectedDigest)
|
||||
{
|
||||
// Act
|
||||
var result = OciReference.Parse(reference);
|
||||
|
||||
// Assert
|
||||
result.Registry.Should().Be(expectedRegistry);
|
||||
result.Repository.Should().Be(expectedRepository);
|
||||
result.Digest.Should().Be(expectedDigest);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("nginx:latest", "docker.io", "library/nginx", "latest")]
|
||||
[InlineData("docker.io/nginx:v1.0", "docker.io", "library/nginx", "v1.0")]
|
||||
[InlineData("ghcr.io/stellaops/scanner:main", "ghcr.io", "stellaops/scanner", "main")]
|
||||
public void Parse_WithTagReference_ReturnsCorrectComponentsWithTag(
|
||||
string reference,
|
||||
string expectedRegistry,
|
||||
string expectedRepository,
|
||||
string expectedTag)
|
||||
{
|
||||
// Act
|
||||
var result = OciReference.Parse(reference);
|
||||
|
||||
// Assert
|
||||
result.Registry.Should().Be(expectedRegistry);
|
||||
result.Repository.Should().Be(expectedRepository);
|
||||
result.Tag.Should().Be(expectedTag);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData(null)]
|
||||
public void Parse_WithEmptyOrNullReference_ThrowsArgumentException(string? reference)
|
||||
{
|
||||
// Act
|
||||
var act = () => OciReference.Parse(reference!);
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToReferenceString_WithDigest_ReturnsDigestFormat()
|
||||
{
|
||||
// Arrange
|
||||
var reference = new OciReference
|
||||
{
|
||||
Registry = "ghcr.io",
|
||||
Repository = "stellaops/scanner",
|
||||
Digest = "sha256:abc123def456"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = reference.FullReference;
|
||||
|
||||
// Assert
|
||||
result.Should().Be("ghcr.io/stellaops/scanner@sha256:abc123def456");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToReferenceString_WithTag_ReturnsTagFormat()
|
||||
{
|
||||
// Arrange
|
||||
var reference = new OciReference
|
||||
{
|
||||
Registry = "ghcr.io",
|
||||
Repository = "stellaops/scanner",
|
||||
Digest = string.Empty,
|
||||
Tag = "v1.0.0"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = reference.FullReference;
|
||||
|
||||
// Assert
|
||||
result.Should().Be("ghcr.io/stellaops/scanner:v1.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToReferenceString_WithDigestAndTag_PrefersDigest()
|
||||
{
|
||||
// Arrange
|
||||
var reference = new OciReference
|
||||
{
|
||||
Registry = "ghcr.io",
|
||||
Repository = "stellaops/scanner",
|
||||
Digest = "sha256:abc123def456",
|
||||
Tag = "v1.0.0"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = reference.FullReference;
|
||||
|
||||
// Assert
|
||||
result.Should().Be("ghcr.io/stellaops/scanner@sha256:abc123def456");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MediaTypes_ContainsExpectedValues()
|
||||
{
|
||||
// Assert standard media types are defined
|
||||
MediaTypes.DsseEnvelope.Should().Be("application/vnd.dsse.envelope.v1+json");
|
||||
MediaTypes.InTotoBundle.Should().Be("application/vnd.in-toto+json");
|
||||
MediaTypes.OciManifest.Should().Be("application/vnd.oci.image.manifest.v1+json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnnotationKeys_ContainsExpectedValues()
|
||||
{
|
||||
// Assert standard annotation keys are defined
|
||||
AnnotationKeys.Created.Should().Be("org.opencontainers.image.created");
|
||||
AnnotationKeys.PredicateType.Should().Be("dev.stellaops/predicate-type");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// © StellaOps Contributors. See LICENSE and NOTICE.md in the repository root.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Attestor.Oci.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Oci.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="OrasAttestationAttacher"/>.
|
||||
/// Sprint: SPRINT_20251228_002_BE_oci_attestation_attach (T7)
|
||||
/// </summary>
|
||||
public sealed class OrasAttestationAttacherTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_WithNullLogger_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act
|
||||
var act = () => new OrasAttestationAttacher(
|
||||
logger: null!,
|
||||
registryClient: Mock.Of<IOciRegistryClient>());
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<ArgumentNullException>()
|
||||
.WithParameterName("logger");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithNullRegistryClient_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act
|
||||
var act = () => new OrasAttestationAttacher(
|
||||
logger: NullLogger<OrasAttestationAttacher>.Instance,
|
||||
registryClient: null!);
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<ArgumentNullException>()
|
||||
.WithParameterName("registryClient");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttachAsync_WithNullImageRef_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var attacher = CreateAttacher();
|
||||
|
||||
// Act
|
||||
var act = () => attacher.AttachAsync(
|
||||
imageRef: null!,
|
||||
attestation: CreateMockEnvelope(),
|
||||
options: new AttachmentOptions());
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<ArgumentNullException>()
|
||||
.WithParameterName("imageRef");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttachAsync_WithNullAttestation_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var attacher = CreateAttacher();
|
||||
var imageRef = CreateValidImageRef();
|
||||
|
||||
// Act
|
||||
var act = () => attacher.AttachAsync(
|
||||
imageRef: imageRef,
|
||||
attestation: null!,
|
||||
options: new AttachmentOptions());
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<ArgumentNullException>()
|
||||
.WithParameterName("attestation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttachAsync_WithNullOptions_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var attacher = CreateAttacher();
|
||||
var imageRef = CreateValidImageRef();
|
||||
var envelope = CreateMockEnvelope();
|
||||
|
||||
// Act
|
||||
var act = () => attacher.AttachAsync(
|
||||
imageRef: imageRef,
|
||||
attestation: envelope,
|
||||
options: null!);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<ArgumentNullException>()
|
||||
.WithParameterName("options");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_WithNullImageRef_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var attacher = CreateAttacher();
|
||||
|
||||
// Act
|
||||
var act = () => attacher.ListAsync(imageRef: null!);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<ArgumentNullException>()
|
||||
.WithParameterName("imageRef");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_WithNullImageRef_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var attacher = CreateAttacher();
|
||||
|
||||
// Act
|
||||
var act = () => attacher.FetchAsync(
|
||||
imageRef: null!,
|
||||
predicateType: "test");
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<ArgumentNullException>()
|
||||
.WithParameterName("imageRef");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task FetchAsync_WithEmptyPredicateType_ThrowsArgumentException(string? predicateType)
|
||||
{
|
||||
// Arrange
|
||||
var attacher = CreateAttacher();
|
||||
var imageRef = CreateValidImageRef();
|
||||
|
||||
// Act
|
||||
var act = () => attacher.FetchAsync(
|
||||
imageRef: imageRef,
|
||||
predicateType: predicateType!);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<ArgumentException>()
|
||||
.WithParameterName("predicateType");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveAsync_WithNullImageRef_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var attacher = CreateAttacher();
|
||||
|
||||
// Act
|
||||
var act = () => attacher.RemoveAsync(
|
||||
imageRef: null!,
|
||||
attestationDigest: "sha256:test");
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<ArgumentNullException>()
|
||||
.WithParameterName("imageRef");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task RemoveAsync_WithEmptyDigest_ThrowsArgumentException(string? digest)
|
||||
{
|
||||
// Arrange
|
||||
var attacher = CreateAttacher();
|
||||
var imageRef = CreateValidImageRef();
|
||||
|
||||
// Act
|
||||
var act = () => attacher.RemoveAsync(
|
||||
imageRef: imageRef,
|
||||
attestationDigest: digest!);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<ArgumentException>()
|
||||
.WithParameterName("attestationDigest");
|
||||
}
|
||||
|
||||
private static OrasAttestationAttacher CreateAttacher()
|
||||
{
|
||||
var mockRegistryClient = new Mock<IOciRegistryClient>();
|
||||
|
||||
return new OrasAttestationAttacher(
|
||||
mockRegistryClient.Object,
|
||||
NullLogger<OrasAttestationAttacher>.Instance);
|
||||
}
|
||||
|
||||
private static OciReference CreateValidImageRef()
|
||||
{
|
||||
return new OciReference
|
||||
{
|
||||
Registry = "ghcr.io",
|
||||
Repository = "stellaops/scanner",
|
||||
Digest = "sha256:abc123def456789"
|
||||
};
|
||||
}
|
||||
|
||||
private static StellaOps.Attestor.Envelope.DsseEnvelope CreateMockEnvelope()
|
||||
{
|
||||
return new StellaOps.Attestor.Envelope.DsseEnvelope(
|
||||
payloadType: "application/vnd.in-toto+json",
|
||||
payload: System.Text.Encoding.UTF8.GetBytes("{}"),
|
||||
signatures: []);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<!-- Sprint: SPRINT_20251228_002_BE_oci_attestation_attach (T7) -->
|
||||
<RootNamespace>StellaOps.Attestor.Oci.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.runner.visualstudio" >
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="Testcontainers" />
|
||||
<PackageReference Include="coverlet.collector" >
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Oci\StellaOps.Attestor.Oci.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,4 +1,4 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// -----------------------------------------------------------------------------
|
||||
// FileSystemRootStoreTests.cs
|
||||
// Sprint: SPRINT_20251226_003_ATTESTOR_offline_verification
|
||||
// Task: 0023 - Unit tests for FileSystemRootStore
|
||||
@@ -350,7 +350,6 @@ public class FileSystemRootStoreTests : IDisposable
|
||||
private static X509Certificate2 CreateTestCertificate(string subject)
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
using StellaOps.TestKit;
|
||||
var request = new CertificateRequest(
|
||||
subject,
|
||||
rsa,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// -----------------------------------------------------------------------------
|
||||
// OfflineCertChainValidatorTests.cs
|
||||
// Sprint: SPRINT_20251226_003_ATTESTOR_offline_verification
|
||||
// Task: 0022 - Unit tests for certificate chain validation
|
||||
@@ -349,7 +349,6 @@ public class OfflineCertChainValidatorTests
|
||||
private static X509Certificate2 CreateFutureCertificate(string subject)
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
using StellaOps.TestKit;
|
||||
var request = new CertificateRequest(
|
||||
subject,
|
||||
rsa,
|
||||
|
||||
@@ -10,23 +10,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="7.0.0" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Offline\StellaOps.Attestor.Offline.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user