Add determinism tests for verdict artifact generation and update SHA256 sums script
- Implemented comprehensive tests for verdict artifact generation to ensure deterministic outputs across various scenarios, including identical inputs, parallel execution, and change ordering. - Created helper methods for generating sample verdict inputs and computing canonical hashes. - Added tests to validate the stability of canonical hashes, proof spine ordering, and summary statistics. - Introduced a new PowerShell script to update SHA256 sums for files, ensuring accurate hash generation and file integrity checks.
This commit is contained in:
@@ -29,6 +29,7 @@ using StellaOps.Attestor.Core.Bulk;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Https;
|
||||
using Serilog.Context;
|
||||
using StellaOps.Cryptography.DependencyInjection;
|
||||
using StellaOps.Router.AspNet;
|
||||
|
||||
const string ConfigurationSection = "attestor";
|
||||
|
||||
@@ -326,6 +327,13 @@ builder.WebHost.ConfigureKestrel(kestrel =>
|
||||
});
|
||||
});
|
||||
|
||||
// Stella Router integration
|
||||
var routerOptions = builder.Configuration.GetSection("Attestor:Router").Get<StellaRouterOptionsBase>();
|
||||
builder.Services.TryAddStellaRouter(
|
||||
serviceName: "attestor",
|
||||
version: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0",
|
||||
routerOptions: routerOptions);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseSerilogRequestLogging();
|
||||
@@ -359,6 +367,7 @@ app.UseRateLimiter();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.TryUseStellaRouter(routerOptions);
|
||||
|
||||
app.MapHealthChecks("/health/ready");
|
||||
app.MapHealthChecks("/health/live");
|
||||
@@ -608,6 +617,9 @@ app.MapGet("/api/v1/rekor/verify:bulk/{jobId}", async (
|
||||
return Results.Ok(BulkVerificationContracts.MapJob(job));
|
||||
}).RequireAuthorization("attestor:write");
|
||||
|
||||
// Refresh Router endpoint cache
|
||||
app.TryRefreshStellaRouterEndpoints(routerOptions);
|
||||
|
||||
app.Run();
|
||||
|
||||
static async Task<IResult> GetAttestationDetailResultAsync(
|
||||
|
||||
@@ -27,5 +27,6 @@
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DsseEnvelopeDeterminismTests.cs
|
||||
// Sprint: SPRINT_5100_0009_0007_attestor_tests
|
||||
// Tasks: ATTESTOR-5100-001, ATTESTOR-5100-002
|
||||
// Description: Model L0 tests for DSSE envelope generation and verification
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Attestor.ProofChain.Builders;
|
||||
using StellaOps.Attestor.ProofChain.Statements;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Envelope;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for DSSE envelope generation and verification.
|
||||
/// Implements Model L0 test requirements:
|
||||
/// - ATTESTOR-5100-001: DSSE envelope generation tests
|
||||
/// - ATTESTOR-5100-002: DSSE envelope verification tests
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Trait("Category", "Determinism")]
|
||||
[Trait("Category", "DsseEnvelope")]
|
||||
public sealed class DsseEnvelopeDeterminismTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTime = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
// ATTESTOR-5100-001: DSSE envelope generation tests
|
||||
|
||||
[Fact]
|
||||
public void DsseEnvelope_Generation_CreatesValidStructure()
|
||||
{
|
||||
// Arrange
|
||||
var payload = Encoding.UTF8.GetBytes("""{"test":"payload"}""");
|
||||
var signature = DsseSignature.FromBytes(new byte[] { 0x01, 0x02, 0x03 }, "test-key-id");
|
||||
|
||||
// Act
|
||||
var envelope = new DsseEnvelope(
|
||||
payloadType: "application/vnd.in-toto+json",
|
||||
payload: payload,
|
||||
signatures: new[] { signature });
|
||||
|
||||
// Assert
|
||||
envelope.PayloadType.Should().Be("application/vnd.in-toto+json");
|
||||
envelope.Payload.Length.Should().Be(payload.Length);
|
||||
envelope.Signatures.Should().HaveCount(1);
|
||||
envelope.Signatures[0].KeyId.Should().Be("test-key-id");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DsseEnvelope_Generation_RequiresAtLeastOneSignature()
|
||||
{
|
||||
// Arrange
|
||||
var payload = Encoding.UTF8.GetBytes("test");
|
||||
|
||||
// Act
|
||||
var act = () => new DsseEnvelope(
|
||||
payloadType: "application/vnd.in-toto+json",
|
||||
payload: payload,
|
||||
signatures: Array.Empty<DsseSignature>());
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithMessage("*At least one signature*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DsseEnvelope_Generation_RequiresPayloadType()
|
||||
{
|
||||
// Arrange
|
||||
var payload = Encoding.UTF8.GetBytes("test");
|
||||
var signature = DsseSignature.FromBytes(new byte[] { 0x01 }, "key");
|
||||
|
||||
// Act
|
||||
var act = () => new DsseEnvelope(
|
||||
payloadType: "",
|
||||
payload: payload,
|
||||
signatures: new[] { signature });
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithMessage("*payloadType*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DsseEnvelope_Generation_NormalizesSignatureOrder()
|
||||
{
|
||||
// Arrange
|
||||
var payload = Encoding.UTF8.GetBytes("test");
|
||||
var sig1 = DsseSignature.FromBytes(new byte[] { 0x01 }, "z-key");
|
||||
var sig2 = DsseSignature.FromBytes(new byte[] { 0x02 }, "a-key");
|
||||
var sig3 = DsseSignature.FromBytes(new byte[] { 0x03 }, null);
|
||||
|
||||
// Act
|
||||
var envelope = new DsseEnvelope(
|
||||
payloadType: "application/vnd.in-toto+json",
|
||||
payload: payload,
|
||||
signatures: new[] { sig1, sig2, sig3 });
|
||||
|
||||
// Assert - null comes first, then alphabetical
|
||||
envelope.Signatures[0].KeyId.Should().BeNull();
|
||||
envelope.Signatures[1].KeyId.Should().Be("a-key");
|
||||
envelope.Signatures[2].KeyId.Should().Be("z-key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DsseEnvelope_Generation_DifferentSignatureOrder_ProducesSameEnvelope()
|
||||
{
|
||||
// Arrange
|
||||
var payload = Encoding.UTF8.GetBytes("test");
|
||||
var sig1 = DsseSignature.FromBytes(new byte[] { 0x01 }, "key-a");
|
||||
var sig2 = DsseSignature.FromBytes(new byte[] { 0x02 }, "key-b");
|
||||
|
||||
// Act - create envelopes with different signature order
|
||||
var envelope1 = new DsseEnvelope("application/vnd.in-toto+json", payload, new[] { sig1, sig2 });
|
||||
var envelope2 = new DsseEnvelope("application/vnd.in-toto+json", payload, new[] { sig2, sig1 });
|
||||
|
||||
// Assert - signatures should be normalized to same order
|
||||
envelope1.Signatures[0].KeyId.Should().Be(envelope2.Signatures[0].KeyId);
|
||||
envelope1.Signatures[1].KeyId.Should().Be(envelope2.Signatures[1].KeyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DsseEnvelope_Generation_PreservesPayloadBytes()
|
||||
{
|
||||
// Arrange
|
||||
var originalPayload = Encoding.UTF8.GetBytes("""{"_type":"https://in-toto.io/Statement/v1","subject":[]}""");
|
||||
var signature = DsseSignature.FromBytes(new byte[] { 0xAB, 0xCD }, "key");
|
||||
|
||||
// Act
|
||||
var envelope = new DsseEnvelope("application/vnd.in-toto+json", originalPayload, new[] { signature });
|
||||
|
||||
// Assert
|
||||
envelope.Payload.ToArray().Should().BeEquivalentTo(originalPayload);
|
||||
}
|
||||
|
||||
// ATTESTOR-5100-002: DSSE envelope verification tests
|
||||
|
||||
[Fact]
|
||||
public void DsseEnvelope_Verification_ValidEnvelope_HasCorrectPayloadType()
|
||||
{
|
||||
// Arrange
|
||||
var payload = CreateInTotoPayload();
|
||||
var signature = DsseSignature.FromBytes(new byte[] { 0x01, 0x02, 0x03 }, "valid-key");
|
||||
var envelope = new DsseEnvelope("application/vnd.in-toto+json", payload, new[] { signature });
|
||||
|
||||
// Act & Assert
|
||||
envelope.PayloadType.Should().Be("application/vnd.in-toto+json");
|
||||
envelope.Signatures.Should().NotBeEmpty();
|
||||
envelope.Signatures[0].Signature.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DsseEnvelope_Verification_SignatureIsBase64Encoded()
|
||||
{
|
||||
// Arrange
|
||||
var payload = CreateInTotoPayload();
|
||||
var signatureBytes = new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05 };
|
||||
var signature = DsseSignature.FromBytes(signatureBytes, "key");
|
||||
var envelope = new DsseEnvelope("application/vnd.in-toto+json", payload, new[] { signature });
|
||||
|
||||
// Act
|
||||
var sigBase64 = envelope.Signatures[0].Signature;
|
||||
|
||||
// Assert - should be valid base64
|
||||
var decoded = Convert.FromBase64String(sigBase64);
|
||||
decoded.Should().BeEquivalentTo(signatureBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DsseEnvelope_Verification_PayloadCanBeDeserialized()
|
||||
{
|
||||
// Arrange
|
||||
var statement = CreateEvidenceStatement();
|
||||
var payloadBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(statement));
|
||||
var signature = DsseSignature.FromBytes(new byte[] { 0x01 }, "key");
|
||||
var envelope = new DsseEnvelope("application/vnd.in-toto+json", payloadBytes, new[] { signature });
|
||||
|
||||
// Act
|
||||
var deserializedPayload = JsonSerializer.Deserialize<EvidenceStatement>(envelope.Payload.Span);
|
||||
|
||||
// Assert
|
||||
deserializedPayload.Should().NotBeNull();
|
||||
deserializedPayload!.Type.Should().Be("https://in-toto.io/Statement/v1");
|
||||
deserializedPayload.PredicateType.Should().Be("evidence.stella/v1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DsseEnvelope_Verification_MultipleSignatures_AllPreserved()
|
||||
{
|
||||
// Arrange
|
||||
var payload = CreateInTotoPayload();
|
||||
var signatures = new[]
|
||||
{
|
||||
DsseSignature.FromBytes(new byte[] { 0x01 }, "key-1"),
|
||||
DsseSignature.FromBytes(new byte[] { 0x02 }, "key-2"),
|
||||
DsseSignature.FromBytes(new byte[] { 0x03 }, "key-3")
|
||||
};
|
||||
var envelope = new DsseEnvelope("application/vnd.in-toto+json", payload, signatures);
|
||||
|
||||
// Act & Assert
|
||||
envelope.Signatures.Should().HaveCount(3);
|
||||
envelope.Signatures.Select(s => s.KeyId).Should().Contain(new[] { "key-1", "key-2", "key-3" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DsseEnvelope_Verification_DetachedPayloadReference_Preserved()
|
||||
{
|
||||
// Arrange
|
||||
var payload = CreateInTotoPayload();
|
||||
var signature = DsseSignature.FromBytes(new byte[] { 0x01 }, "key");
|
||||
var detachedRef = new DsseDetachedPayloadReference(
|
||||
Uri: "oci://registry.example.com/sbom@sha256:abc123",
|
||||
Digest: "sha256:abc123def456",
|
||||
Size: 1024);
|
||||
|
||||
var envelope = new DsseEnvelope(
|
||||
"application/vnd.in-toto+json",
|
||||
payload,
|
||||
new[] { signature },
|
||||
detachedPayload: detachedRef);
|
||||
|
||||
// Act & Assert
|
||||
envelope.DetachedPayload.Should().NotBeNull();
|
||||
envelope.DetachedPayload!.Uri.Should().Be("oci://registry.example.com/sbom@sha256:abc123");
|
||||
envelope.DetachedPayload.Digest.Should().Be("sha256:abc123def456");
|
||||
envelope.DetachedPayload.Size.Should().Be(1024);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DsseEnvelope_DeterministicSerialization_SameInputs_ProduceSameOutput()
|
||||
{
|
||||
// Arrange
|
||||
var payload = CreateInTotoPayload();
|
||||
var signature = DsseSignature.FromBytes(new byte[] { 0x01, 0x02, 0x03 }, "deterministic-key");
|
||||
|
||||
// Act - create same envelope multiple times
|
||||
var envelopes = Enumerable.Range(0, 10)
|
||||
.Select(_ => new DsseEnvelope("application/vnd.in-toto+json", payload, new[] { signature }))
|
||||
.ToList();
|
||||
|
||||
// Assert - all envelopes should have identical structure
|
||||
var firstPayload = envelopes[0].Payload.ToArray();
|
||||
var firstSig = envelopes[0].Signatures[0].Signature;
|
||||
|
||||
foreach (var envelope in envelopes.Skip(1))
|
||||
{
|
||||
envelope.Payload.ToArray().Should().BeEquivalentTo(firstPayload);
|
||||
envelope.Signatures[0].Signature.Should().Be(firstSig);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private static byte[] CreateInTotoPayload()
|
||||
{
|
||||
var statement = new
|
||||
{
|
||||
_type = "https://in-toto.io/Statement/v1",
|
||||
predicateType = "test/v1",
|
||||
subject = new[]
|
||||
{
|
||||
new { name = "test-artifact", digest = new { sha256 = new string('a', 64) } }
|
||||
},
|
||||
predicate = new { test = "value" }
|
||||
};
|
||||
return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(statement));
|
||||
}
|
||||
|
||||
private static EvidenceStatement CreateEvidenceStatement()
|
||||
{
|
||||
return new EvidenceStatement
|
||||
{
|
||||
Subject = new[]
|
||||
{
|
||||
new Subject
|
||||
{
|
||||
Name = "test-image",
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = new string('a', 64) }
|
||||
}
|
||||
},
|
||||
Predicate = new EvidencePayload
|
||||
{
|
||||
Source = "trivy",
|
||||
SourceVersion = "0.50.0",
|
||||
CollectionTime = FixedTime,
|
||||
SbomEntryId = "sha256:sbom-entry",
|
||||
VulnerabilityId = "CVE-2025-0001",
|
||||
RawFinding = new { severity = "high" },
|
||||
EvidenceId = $"sha256:{new string('b', 64)}"
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// InTotoStatementSnapshotTests.cs
|
||||
// Sprint: SPRINT_5100_0009_0007_attestor_tests
|
||||
// Tasks: ATTESTOR-5100-003, ATTESTOR-5100-004, ATTESTOR-5100-005
|
||||
// Description: Model L0 snapshot tests for in-toto statement types
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.ProofChain.Builders;
|
||||
using StellaOps.Attestor.ProofChain.Statements;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Statements;
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot tests for in-toto statement types.
|
||||
/// Implements Model L0 test requirements:
|
||||
/// - ATTESTOR-5100-003: SLSA provenance v1.0 canonical JSON snapshot tests
|
||||
/// - ATTESTOR-5100-004: VEX attestation canonical JSON snapshot tests
|
||||
/// - ATTESTOR-5100-005: SBOM attestation canonical JSON snapshot tests
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Trait("Category", "Snapshot")]
|
||||
[Trait("Category", "InTotoStatement")]
|
||||
public sealed class InTotoStatementSnapshotTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTime = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
// ATTESTOR-5100-003: in-toto statement tests (base structure)
|
||||
|
||||
[Fact]
|
||||
public void InTotoStatement_HasCorrectTypeField()
|
||||
{
|
||||
// Arrange
|
||||
var statement = CreateEvidenceStatement();
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(statement, JsonOptions);
|
||||
var node = JsonNode.Parse(json);
|
||||
|
||||
// Assert
|
||||
node!["_type"]!.GetValue<string>().Should().Be("https://in-toto.io/Statement/v1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InTotoStatement_Subject_HasRequiredFields()
|
||||
{
|
||||
// Arrange
|
||||
var statement = CreateEvidenceStatement();
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(statement, JsonOptions);
|
||||
var node = JsonNode.Parse(json);
|
||||
var subject = node!["subject"]!.AsArray()[0];
|
||||
|
||||
// Assert
|
||||
subject!["name"].Should().NotBeNull();
|
||||
subject["digest"].Should().NotBeNull();
|
||||
subject["digest"]!["sha256"].Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InTotoStatement_Subject_DigestIsLowercase()
|
||||
{
|
||||
// Arrange
|
||||
var statement = CreateEvidenceStatement();
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(statement, JsonOptions);
|
||||
var node = JsonNode.Parse(json);
|
||||
var digest = node!["subject"]![0]!["digest"]!["sha256"]!.GetValue<string>();
|
||||
|
||||
// Assert
|
||||
digest.Should().MatchRegex("^[a-f0-9]{64}$", "digest should be lowercase hex");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InTotoStatement_PredicateType_IsPresent()
|
||||
{
|
||||
// Arrange
|
||||
var statement = CreateEvidenceStatement();
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(statement, JsonOptions);
|
||||
var node = JsonNode.Parse(json);
|
||||
|
||||
// Assert
|
||||
node!["predicateType"]!.GetValue<string>().Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InTotoStatement_Serialization_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var statement = CreateEvidenceStatement();
|
||||
|
||||
// Act - serialize multiple times
|
||||
var serializations = Enumerable.Range(0, 10)
|
||||
.Select(_ => JsonSerializer.Serialize(statement, JsonOptions))
|
||||
.ToList();
|
||||
|
||||
// Assert - all should be identical
|
||||
serializations.Distinct().Should().HaveCount(1);
|
||||
}
|
||||
|
||||
// ATTESTOR-5100-004: VEX attestation canonical JSON tests
|
||||
|
||||
[Fact]
|
||||
public void VexVerdictStatement_HasCorrectPredicateType()
|
||||
{
|
||||
// Arrange
|
||||
var statement = CreateVexVerdictStatement();
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(statement, JsonOptions);
|
||||
var node = JsonNode.Parse(json);
|
||||
|
||||
// Assert
|
||||
node!["predicateType"]!.GetValue<string>().Should().Be("cdx-vex.stella/v1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexVerdictStatement_HasRequiredPredicateFields()
|
||||
{
|
||||
// Arrange
|
||||
var statement = CreateVexVerdictStatement();
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(statement, JsonOptions);
|
||||
var node = JsonNode.Parse(json);
|
||||
var predicate = node!["predicate"];
|
||||
|
||||
// Assert
|
||||
predicate!["sbomEntryId"].Should().NotBeNull();
|
||||
predicate["vulnerabilityId"].Should().NotBeNull();
|
||||
predicate["status"].Should().NotBeNull();
|
||||
predicate["justification"].Should().NotBeNull();
|
||||
predicate["policyVersion"].Should().NotBeNull();
|
||||
predicate["reasoningId"].Should().NotBeNull();
|
||||
predicate["vexVerdictId"].Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexVerdictStatement_Status_IsValidVexStatus()
|
||||
{
|
||||
// Arrange
|
||||
var validStatuses = new[] { "not_affected", "affected", "fixed", "under_investigation" };
|
||||
|
||||
// Act & Assert
|
||||
foreach (var status in validStatuses)
|
||||
{
|
||||
var statement = CreateVexVerdictStatement(status);
|
||||
var json = JsonSerializer.Serialize(statement, JsonOptions);
|
||||
var node = JsonNode.Parse(json);
|
||||
|
||||
node!["predicate"]!["status"]!.GetValue<string>().Should().Be(status);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexVerdictStatement_VexVerdictId_HasCorrectFormat()
|
||||
{
|
||||
// Arrange
|
||||
var statement = CreateVexVerdictStatement();
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(statement, JsonOptions);
|
||||
var node = JsonNode.Parse(json);
|
||||
var verdictId = node!["predicate"]!["vexVerdictId"]!.GetValue<string>();
|
||||
|
||||
// Assert
|
||||
verdictId.Should().StartWith("sha256:");
|
||||
verdictId.Should().HaveLength(71, "sha256: prefix (7) + 64 hex chars = 71");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexVerdictStatement_Serialization_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var statement = CreateVexVerdictStatement();
|
||||
|
||||
// Act
|
||||
var serializations = Enumerable.Range(0, 10)
|
||||
.Select(_ => JsonSerializer.Serialize(statement, JsonOptions))
|
||||
.ToList();
|
||||
|
||||
// Assert
|
||||
serializations.Distinct().Should().HaveCount(1);
|
||||
}
|
||||
|
||||
// ATTESTOR-5100-005: SBOM attestation canonical JSON tests
|
||||
|
||||
[Fact]
|
||||
public void SbomLinkageStatement_HasCorrectPredicateType()
|
||||
{
|
||||
// Arrange
|
||||
var statement = CreateSbomLinkageStatement();
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(statement, JsonOptions);
|
||||
var node = JsonNode.Parse(json);
|
||||
|
||||
// Assert
|
||||
node!["predicateType"]!.GetValue<string>()
|
||||
.Should().Be("https://stella-ops.org/predicates/sbom-linkage/v1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SbomLinkageStatement_Sbom_HasRequiredFields()
|
||||
{
|
||||
// Arrange
|
||||
var statement = CreateSbomLinkageStatement();
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(statement, JsonOptions);
|
||||
var node = JsonNode.Parse(json);
|
||||
var sbom = node!["predicate"]!["sbom"];
|
||||
|
||||
// Assert
|
||||
sbom!["id"].Should().NotBeNull();
|
||||
sbom["format"].Should().NotBeNull();
|
||||
sbom["specVersion"].Should().NotBeNull();
|
||||
sbom["mediaType"].Should().NotBeNull();
|
||||
sbom["sha256"].Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SbomLinkageStatement_CycloneDX16_HasCorrectMediaType()
|
||||
{
|
||||
// Arrange
|
||||
var statement = CreateSbomLinkageStatement(format: "cyclonedx", specVersion: "1.6");
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(statement, JsonOptions);
|
||||
var node = JsonNode.Parse(json);
|
||||
var mediaType = node!["predicate"]!["sbom"]!["mediaType"]!.GetValue<string>();
|
||||
|
||||
// Assert
|
||||
mediaType.Should().Be("application/vnd.cyclonedx+json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SbomLinkageStatement_SPDX301_HasCorrectMediaType()
|
||||
{
|
||||
// Arrange
|
||||
var statement = CreateSbomLinkageStatement(format: "spdx", specVersion: "3.0.1");
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(statement, JsonOptions);
|
||||
var node = JsonNode.Parse(json);
|
||||
var mediaType = node!["predicate"]!["sbom"]!["mediaType"]!.GetValue<string>();
|
||||
|
||||
// Assert
|
||||
mediaType.Should().Be("application/spdx+json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SbomLinkageStatement_Generator_HasRequiredFields()
|
||||
{
|
||||
// Arrange
|
||||
var statement = CreateSbomLinkageStatement();
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(statement, JsonOptions);
|
||||
var node = JsonNode.Parse(json);
|
||||
var generator = node!["predicate"]!["generator"];
|
||||
|
||||
// Assert
|
||||
generator!["name"].Should().NotBeNull();
|
||||
generator["version"].Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SbomLinkageStatement_GeneratedAt_IsIso8601()
|
||||
{
|
||||
// Arrange
|
||||
var statement = CreateSbomLinkageStatement();
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(statement, JsonOptions);
|
||||
var node = JsonNode.Parse(json);
|
||||
var generatedAt = node!["predicate"]!["generatedAt"]!.GetValue<string>();
|
||||
|
||||
// Assert - should parse as valid ISO 8601
|
||||
DateTimeOffset.TryParse(generatedAt, out _).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SbomLinkageStatement_MultipleSubjects_AllPreserved()
|
||||
{
|
||||
// Arrange
|
||||
var subjects = new[]
|
||||
{
|
||||
new Subject { Name = "image:demo", Digest = new Dictionary<string, string> { ["sha256"] = new string('a', 64) } },
|
||||
new Subject { Name = "pkg:npm/lodash@4.17.21", Digest = new Dictionary<string, string> { ["sha256"] = new string('b', 64) } },
|
||||
new Subject { Name = "pkg:maven/org.apache/log4j@2.17.1", Digest = new Dictionary<string, string> { ["sha256"] = new string('c', 64) } }
|
||||
};
|
||||
var statement = CreateSbomLinkageStatement(subjects: subjects);
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(statement, JsonOptions);
|
||||
var node = JsonNode.Parse(json);
|
||||
var subjectArray = node!["subject"]!.AsArray();
|
||||
|
||||
// Assert
|
||||
subjectArray.Should().HaveCount(3);
|
||||
subjectArray[0]!["name"]!.GetValue<string>().Should().Be("image:demo");
|
||||
subjectArray[1]!["name"]!.GetValue<string>().Should().Be("pkg:npm/lodash@4.17.21");
|
||||
subjectArray[2]!["name"]!.GetValue<string>().Should().Be("pkg:maven/org.apache/log4j@2.17.1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SbomLinkageStatement_Serialization_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var statement = CreateSbomLinkageStatement();
|
||||
|
||||
// Act
|
||||
var serializations = Enumerable.Range(0, 10)
|
||||
.Select(_ => JsonSerializer.Serialize(statement, JsonOptions))
|
||||
.ToList();
|
||||
|
||||
// Assert
|
||||
serializations.Distinct().Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SbomLinkageStatement_Tags_OptionalButPreserved()
|
||||
{
|
||||
// Arrange
|
||||
var statement = CreateSbomLinkageStatement(tags: new Dictionary<string, string>
|
||||
{
|
||||
["env"] = "production",
|
||||
["team"] = "security"
|
||||
});
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(statement, JsonOptions);
|
||||
var node = JsonNode.Parse(json);
|
||||
var tags = node!["predicate"]!["tags"];
|
||||
|
||||
// Assert
|
||||
tags.Should().NotBeNull();
|
||||
tags!["env"]!.GetValue<string>().Should().Be("production");
|
||||
tags["team"]!.GetValue<string>().Should().Be("security");
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private static EvidenceStatement CreateEvidenceStatement()
|
||||
{
|
||||
return new EvidenceStatement
|
||||
{
|
||||
Subject = new[]
|
||||
{
|
||||
new Subject
|
||||
{
|
||||
Name = "test-artifact",
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = new string('a', 64) }
|
||||
}
|
||||
},
|
||||
Predicate = new EvidencePayload
|
||||
{
|
||||
Source = "trivy",
|
||||
SourceVersion = "0.50.0",
|
||||
CollectionTime = FixedTime,
|
||||
SbomEntryId = "sha256:sbom-entry",
|
||||
VulnerabilityId = "CVE-2025-0001",
|
||||
RawFinding = new { severity = "high" },
|
||||
EvidenceId = $"sha256:{new string('b', 64)}"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static VexVerdictStatement CreateVexVerdictStatement(string status = "not_affected")
|
||||
{
|
||||
return new VexVerdictStatement
|
||||
{
|
||||
Subject = new[]
|
||||
{
|
||||
new Subject
|
||||
{
|
||||
Name = "pkg:npm/lodash@4.17.21",
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = new string('a', 64) }
|
||||
}
|
||||
},
|
||||
Predicate = new VexVerdictPayload
|
||||
{
|
||||
SbomEntryId = "sha256:sbom:pkg:npm/lodash@4.17.21",
|
||||
VulnerabilityId = "CVE-2025-0001",
|
||||
Status = status,
|
||||
Justification = "vulnerable_code_not_in_execute_path",
|
||||
PolicyVersion = "v1.0.0",
|
||||
ReasoningId = $"sha256:{new string('c', 64)}",
|
||||
VexVerdictId = $"sha256:{new string('d', 64)}"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static SbomLinkageStatement CreateSbomLinkageStatement(
|
||||
string format = "cyclonedx",
|
||||
string specVersion = "1.6",
|
||||
Subject[]? subjects = null,
|
||||
Dictionary<string, string>? tags = null)
|
||||
{
|
||||
var mediaType = format.ToLowerInvariant() switch
|
||||
{
|
||||
"cyclonedx" => "application/vnd.cyclonedx+json",
|
||||
"spdx" => "application/spdx+json",
|
||||
_ => "application/json"
|
||||
};
|
||||
|
||||
return new SbomLinkageStatement
|
||||
{
|
||||
Subject = subjects ?? new[]
|
||||
{
|
||||
new Subject
|
||||
{
|
||||
Name = "image:demo",
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = new string('a', 64) }
|
||||
}
|
||||
},
|
||||
Predicate = new SbomLinkagePayload
|
||||
{
|
||||
Sbom = new SbomDescriptor
|
||||
{
|
||||
Id = "sbom-001",
|
||||
Format = format,
|
||||
SpecVersion = specVersion,
|
||||
MediaType = mediaType,
|
||||
Sha256 = new string('e', 64),
|
||||
Location = "oci://registry.example.com/sbom@sha256:abc123"
|
||||
},
|
||||
Generator = new GeneratorDescriptor
|
||||
{
|
||||
Name = "stellaops-sbomgen",
|
||||
Version = "1.0.0"
|
||||
},
|
||||
GeneratedAt = FixedTime,
|
||||
Tags = tags
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user