507 lines
19 KiB
C#
507 lines
19 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using StellaOps.TaskRunner.Core.Attestation;
|
|
using StellaOps.TaskRunner.Core.Events;
|
|
using StellaOps.TaskRunner.Core.Evidence;
|
|
|
|
using StellaOps.TestKit;
|
|
namespace StellaOps.TaskRunner.Tests;
|
|
|
|
public sealed class PackRunAttestationTests
|
|
{
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task GenerateAsync_CreatesAttestationWithSubjects()
|
|
{
|
|
var store = new InMemoryPackRunAttestationStore();
|
|
var signer = new StubPackRunAttestationSigner();
|
|
var service = new PackRunAttestationService(
|
|
store,
|
|
NullLogger<PackRunAttestationService>.Instance,
|
|
signer);
|
|
|
|
var subjects = new List<PackRunAttestationSubject>
|
|
{
|
|
new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc123" }),
|
|
new("artifact/sbom.json", new Dictionary<string, string> { ["sha256"] = "def456" })
|
|
};
|
|
|
|
var request = new PackRunAttestationRequest(
|
|
RunId: "run-001",
|
|
TenantId: "tenant-1",
|
|
PlanHash: "sha256:plan123",
|
|
Subjects: subjects,
|
|
EvidenceSnapshotId: Guid.NewGuid(),
|
|
StartedAt: DateTimeOffset.UtcNow.AddMinutes(-5),
|
|
CompletedAt: DateTimeOffset.UtcNow,
|
|
BuilderId: null,
|
|
ExternalParameters: null,
|
|
ResolvedDependencies: null,
|
|
Metadata: null);
|
|
|
|
var result = await service.GenerateAsync(request, TestContext.Current.CancellationToken);
|
|
|
|
Assert.True(result.Success);
|
|
Assert.NotNull(result.Attestation);
|
|
Assert.Equal(PackRunAttestationStatus.Signed, result.Attestation.Status);
|
|
Assert.Equal(2, result.Attestation.Subjects.Count);
|
|
Assert.NotNull(result.Attestation.Envelope);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task GenerateAsync_WithoutSigner_CreatesPendingAttestation()
|
|
{
|
|
var store = new InMemoryPackRunAttestationStore();
|
|
var service = new PackRunAttestationService(
|
|
store,
|
|
NullLogger<PackRunAttestationService>.Instance);
|
|
|
|
var subjects = new List<PackRunAttestationSubject>
|
|
{
|
|
new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc123" })
|
|
};
|
|
|
|
var request = new PackRunAttestationRequest(
|
|
RunId: "run-002",
|
|
TenantId: "tenant-1",
|
|
PlanHash: "sha256:plan123",
|
|
Subjects: subjects,
|
|
EvidenceSnapshotId: null,
|
|
StartedAt: DateTimeOffset.UtcNow,
|
|
CompletedAt: null,
|
|
BuilderId: null,
|
|
ExternalParameters: null,
|
|
ResolvedDependencies: null,
|
|
Metadata: null);
|
|
|
|
var result = await service.GenerateAsync(request, TestContext.Current.CancellationToken);
|
|
|
|
Assert.True(result.Success);
|
|
Assert.NotNull(result.Attestation);
|
|
Assert.Equal(PackRunAttestationStatus.Pending, result.Attestation.Status);
|
|
Assert.Null(result.Attestation.Envelope);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task GenerateAsync_EmitsTimelineEvent()
|
|
{
|
|
var store = new InMemoryPackRunAttestationStore();
|
|
var signer = new StubPackRunAttestationSigner();
|
|
var timelineSink = new InMemoryPackRunTimelineEventSink();
|
|
var emitter = new PackRunTimelineEventEmitter(
|
|
timelineSink,
|
|
TimeProvider.System,
|
|
NullLogger<PackRunTimelineEventEmitter>.Instance);
|
|
var service = new PackRunAttestationService(
|
|
store,
|
|
NullLogger<PackRunAttestationService>.Instance,
|
|
signer,
|
|
emitter);
|
|
|
|
var request = new PackRunAttestationRequest(
|
|
RunId: "run-003",
|
|
TenantId: "tenant-1",
|
|
PlanHash: "sha256:plan123",
|
|
Subjects: [new("artifact/test.json", new Dictionary<string, string> { ["sha256"] = "abc" })],
|
|
EvidenceSnapshotId: null,
|
|
StartedAt: DateTimeOffset.UtcNow,
|
|
CompletedAt: DateTimeOffset.UtcNow,
|
|
BuilderId: null,
|
|
ExternalParameters: null,
|
|
ResolvedDependencies: null,
|
|
Metadata: null);
|
|
|
|
await service.GenerateAsync(request, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Equal(1, timelineSink.Count);
|
|
var evt = timelineSink.GetEvents()[0];
|
|
Assert.Equal(PackRunAttestationEventTypes.AttestationCreated, evt.EventType);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task VerifyAsync_ValidatesSubjectsMatch()
|
|
{
|
|
var store = new InMemoryPackRunAttestationStore();
|
|
var signer = new StubPackRunAttestationSigner();
|
|
var service = new PackRunAttestationService(
|
|
store,
|
|
NullLogger<PackRunAttestationService>.Instance,
|
|
signer);
|
|
|
|
var subjects = new List<PackRunAttestationSubject>
|
|
{
|
|
new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc123" })
|
|
};
|
|
|
|
var request = new PackRunAttestationRequest(
|
|
RunId: "run-004",
|
|
TenantId: "tenant-1",
|
|
PlanHash: "sha256:plan123",
|
|
Subjects: subjects,
|
|
EvidenceSnapshotId: null,
|
|
StartedAt: DateTimeOffset.UtcNow,
|
|
CompletedAt: DateTimeOffset.UtcNow,
|
|
BuilderId: null,
|
|
ExternalParameters: null,
|
|
ResolvedDependencies: null,
|
|
Metadata: null);
|
|
|
|
var genResult = await service.GenerateAsync(request, TestContext.Current.CancellationToken);
|
|
Assert.NotNull(genResult.Attestation);
|
|
|
|
var verifyResult = await service.VerifyAsync(
|
|
new PackRunAttestationVerificationRequest(
|
|
AttestationId: genResult.Attestation.AttestationId,
|
|
ExpectedSubjects: subjects,
|
|
VerifySignature: true,
|
|
VerifySubjects: true,
|
|
CheckRevocation: true),
|
|
TestContext.Current.CancellationToken);
|
|
|
|
Assert.True(verifyResult.Valid);
|
|
Assert.Equal(PackRunSignatureVerificationStatus.Valid, verifyResult.SignatureStatus);
|
|
Assert.Equal(PackRunSubjectVerificationStatus.Match, verifyResult.SubjectStatus);
|
|
Assert.Equal(PackRunRevocationStatus.NotRevoked, verifyResult.RevocationStatus);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task VerifyAsync_DetectsMismatchedSubjects()
|
|
{
|
|
var store = new InMemoryPackRunAttestationStore();
|
|
var signer = new StubPackRunAttestationSigner();
|
|
var service = new PackRunAttestationService(
|
|
store,
|
|
NullLogger<PackRunAttestationService>.Instance,
|
|
signer);
|
|
|
|
var subjects = new List<PackRunAttestationSubject>
|
|
{
|
|
new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc123" })
|
|
};
|
|
|
|
var request = new PackRunAttestationRequest(
|
|
RunId: "run-005",
|
|
TenantId: "tenant-1",
|
|
PlanHash: "sha256:plan123",
|
|
Subjects: subjects,
|
|
EvidenceSnapshotId: null,
|
|
StartedAt: DateTimeOffset.UtcNow,
|
|
CompletedAt: DateTimeOffset.UtcNow,
|
|
BuilderId: null,
|
|
ExternalParameters: null,
|
|
ResolvedDependencies: null,
|
|
Metadata: null);
|
|
|
|
var genResult = await service.GenerateAsync(request, TestContext.Current.CancellationToken);
|
|
Assert.NotNull(genResult.Attestation);
|
|
|
|
// Verify with different expected subjects
|
|
var differentSubjects = new List<PackRunAttestationSubject>
|
|
{
|
|
new("artifact/different.tar.gz", new Dictionary<string, string> { ["sha256"] = "xyz789" })
|
|
};
|
|
|
|
var verifyResult = await service.VerifyAsync(
|
|
new PackRunAttestationVerificationRequest(
|
|
AttestationId: genResult.Attestation.AttestationId,
|
|
ExpectedSubjects: differentSubjects,
|
|
VerifySignature: false,
|
|
VerifySubjects: true,
|
|
CheckRevocation: false),
|
|
TestContext.Current.CancellationToken);
|
|
|
|
Assert.False(verifyResult.Valid);
|
|
Assert.Equal(PackRunSubjectVerificationStatus.Missing, verifyResult.SubjectStatus);
|
|
Assert.NotNull(verifyResult.Errors);
|
|
Assert.Contains(verifyResult.Errors, e => e.Contains("Missing subjects"));
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task VerifyAsync_DetectsRevokedAttestation()
|
|
{
|
|
var store = new InMemoryPackRunAttestationStore();
|
|
var signer = new StubPackRunAttestationSigner();
|
|
var service = new PackRunAttestationService(
|
|
store,
|
|
NullLogger<PackRunAttestationService>.Instance,
|
|
signer);
|
|
|
|
var subjects = new List<PackRunAttestationSubject>
|
|
{
|
|
new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc123" })
|
|
};
|
|
|
|
var request = new PackRunAttestationRequest(
|
|
RunId: "run-006",
|
|
TenantId: "tenant-1",
|
|
PlanHash: "sha256:plan123",
|
|
Subjects: subjects,
|
|
EvidenceSnapshotId: null,
|
|
StartedAt: DateTimeOffset.UtcNow,
|
|
CompletedAt: DateTimeOffset.UtcNow,
|
|
BuilderId: null,
|
|
ExternalParameters: null,
|
|
ResolvedDependencies: null,
|
|
Metadata: null);
|
|
|
|
var genResult = await service.GenerateAsync(request, TestContext.Current.CancellationToken);
|
|
Assert.NotNull(genResult.Attestation);
|
|
|
|
// Revoke the attestation
|
|
await store.UpdateStatusAsync(
|
|
genResult.Attestation.AttestationId,
|
|
PackRunAttestationStatus.Revoked,
|
|
"Compromised key",
|
|
TestContext.Current.CancellationToken);
|
|
|
|
var verifyResult = await service.VerifyAsync(
|
|
new PackRunAttestationVerificationRequest(
|
|
AttestationId: genResult.Attestation.AttestationId,
|
|
ExpectedSubjects: null,
|
|
VerifySignature: false,
|
|
VerifySubjects: false,
|
|
CheckRevocation: true),
|
|
TestContext.Current.CancellationToken);
|
|
|
|
Assert.False(verifyResult.Valid);
|
|
Assert.Equal(PackRunRevocationStatus.Revoked, verifyResult.RevocationStatus);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task VerifyAsync_ReturnsErrorForNonExistentAttestation()
|
|
{
|
|
var store = new InMemoryPackRunAttestationStore();
|
|
var service = new PackRunAttestationService(
|
|
store,
|
|
NullLogger<PackRunAttestationService>.Instance);
|
|
|
|
var verifyResult = await service.VerifyAsync(
|
|
new PackRunAttestationVerificationRequest(
|
|
AttestationId: Guid.NewGuid(),
|
|
ExpectedSubjects: null,
|
|
VerifySignature: false,
|
|
VerifySubjects: false,
|
|
CheckRevocation: false),
|
|
TestContext.Current.CancellationToken);
|
|
|
|
Assert.False(verifyResult.Valid);
|
|
Assert.NotNull(verifyResult.Errors);
|
|
Assert.Contains(verifyResult.Errors, e => e.Contains("not found"));
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task ListByRunAsync_ReturnsAttestationsForRun()
|
|
{
|
|
var store = new InMemoryPackRunAttestationStore();
|
|
var signer = new StubPackRunAttestationSigner();
|
|
var service = new PackRunAttestationService(
|
|
store,
|
|
NullLogger<PackRunAttestationService>.Instance,
|
|
signer);
|
|
|
|
// Create two attestations for the same run
|
|
for (var i = 0; i < 2; i++)
|
|
{
|
|
var request = new PackRunAttestationRequest(
|
|
RunId: "run-007",
|
|
TenantId: "tenant-1",
|
|
PlanHash: "sha256:plan123",
|
|
Subjects: [new($"artifact/output{i}.tar.gz", new Dictionary<string, string> { ["sha256"] = $"hash{i}" })],
|
|
EvidenceSnapshotId: null,
|
|
StartedAt: DateTimeOffset.UtcNow,
|
|
CompletedAt: DateTimeOffset.UtcNow,
|
|
BuilderId: null,
|
|
ExternalParameters: null,
|
|
ResolvedDependencies: null,
|
|
Metadata: null);
|
|
|
|
await service.GenerateAsync(request, TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
var attestations = await service.ListByRunAsync("tenant-1", "run-007", TestContext.Current.CancellationToken);
|
|
|
|
Assert.Equal(2, attestations.Count);
|
|
Assert.All(attestations, a => Assert.Equal("run-007", a.RunId));
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task GetEnvelopeAsync_ReturnsEnvelopeForSignedAttestation()
|
|
{
|
|
var store = new InMemoryPackRunAttestationStore();
|
|
var signer = new StubPackRunAttestationSigner();
|
|
var service = new PackRunAttestationService(
|
|
store,
|
|
NullLogger<PackRunAttestationService>.Instance,
|
|
signer);
|
|
|
|
var request = new PackRunAttestationRequest(
|
|
RunId: "run-008",
|
|
TenantId: "tenant-1",
|
|
PlanHash: "sha256:plan123",
|
|
Subjects: [new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc123" })],
|
|
EvidenceSnapshotId: null,
|
|
StartedAt: DateTimeOffset.UtcNow,
|
|
CompletedAt: DateTimeOffset.UtcNow,
|
|
BuilderId: null,
|
|
ExternalParameters: null,
|
|
ResolvedDependencies: null,
|
|
Metadata: null);
|
|
|
|
var genResult = await service.GenerateAsync(request, TestContext.Current.CancellationToken);
|
|
Assert.NotNull(genResult.Attestation);
|
|
|
|
var envelope = await service.GetEnvelopeAsync(genResult.Attestation.AttestationId, TestContext.Current.CancellationToken);
|
|
|
|
Assert.NotNull(envelope);
|
|
Assert.Equal(PackRunDsseEnvelope.InTotoPayloadType, envelope.PayloadType);
|
|
Assert.Single(envelope.Signatures);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public void PackRunAttestationSubject_FromArtifact_ParsesSha256Prefix()
|
|
{
|
|
var artifact = new PackRunArtifactReference(
|
|
Name: "output.tar.gz",
|
|
Sha256: "sha256:abcdef123456",
|
|
SizeBytes: 1024,
|
|
MediaType: "application/gzip");
|
|
|
|
var subject = PackRunAttestationSubject.FromArtifact(artifact);
|
|
|
|
Assert.Equal("output.tar.gz", subject.Name);
|
|
Assert.Equal("abcdef123456", subject.Digest["sha256"]);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public void PackRunAttestation_ComputeStatementDigest_IsDeterministic()
|
|
{
|
|
var subjects = new List<PackRunAttestationSubject>
|
|
{
|
|
new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc123" })
|
|
};
|
|
|
|
var attestation = new PackRunAttestation(
|
|
AttestationId: Guid.NewGuid(),
|
|
TenantId: "tenant-1",
|
|
RunId: "run-001",
|
|
PlanHash: "sha256:plan123",
|
|
CreatedAt: DateTimeOffset.Parse("2025-12-06T00:00:00Z"),
|
|
Subjects: subjects,
|
|
PredicateType: PredicateTypes.PackRunProvenance,
|
|
PredicateJson: "{\"test\":true}",
|
|
Envelope: null,
|
|
Status: PackRunAttestationStatus.Pending,
|
|
Error: null,
|
|
EvidenceSnapshotId: null,
|
|
Metadata: null);
|
|
|
|
var digest1 = attestation.ComputeStatementDigest();
|
|
var digest2 = attestation.ComputeStatementDigest();
|
|
|
|
Assert.Equal(digest1, digest2);
|
|
Assert.StartsWith("sha256:", digest1);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public void PackRunDsseEnvelope_ComputeDigest_IsDeterministic()
|
|
{
|
|
var envelope = new PackRunDsseEnvelope(
|
|
PayloadType: PackRunDsseEnvelope.InTotoPayloadType,
|
|
Payload: Convert.ToBase64String([1, 2, 3]),
|
|
Signatures: [new PackRunDsseSignature("key-001", "sig123")]);
|
|
|
|
var digest1 = envelope.ComputeDigest();
|
|
var digest2 = envelope.ComputeDigest();
|
|
|
|
Assert.Equal(digest1, digest2);
|
|
Assert.StartsWith("sha256:", digest1);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task GenerateAsync_WithExternalParameters_IncludesInPredicate()
|
|
{
|
|
var store = new InMemoryPackRunAttestationStore();
|
|
var signer = new StubPackRunAttestationSigner();
|
|
var service = new PackRunAttestationService(
|
|
store,
|
|
NullLogger<PackRunAttestationService>.Instance,
|
|
signer);
|
|
|
|
var externalParams = new Dictionary<string, object>
|
|
{
|
|
["manifestUrl"] = "https://registry.example.com/pack/v1",
|
|
["version"] = "1.0.0"
|
|
};
|
|
|
|
var request = new PackRunAttestationRequest(
|
|
RunId: "run-009",
|
|
TenantId: "tenant-1",
|
|
PlanHash: "sha256:plan123",
|
|
Subjects: [new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc" })],
|
|
EvidenceSnapshotId: null,
|
|
StartedAt: DateTimeOffset.UtcNow,
|
|
CompletedAt: DateTimeOffset.UtcNow,
|
|
BuilderId: "https://stellaops.io/task-runner/custom",
|
|
ExternalParameters: externalParams,
|
|
ResolvedDependencies: null,
|
|
Metadata: null);
|
|
|
|
var result = await service.GenerateAsync(request, TestContext.Current.CancellationToken);
|
|
|
|
Assert.True(result.Success);
|
|
Assert.NotNull(result.Attestation);
|
|
Assert.Contains("manifestUrl", result.Attestation.PredicateJson);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task GenerateAsync_WithResolvedDependencies_IncludesInPredicate()
|
|
{
|
|
var store = new InMemoryPackRunAttestationStore();
|
|
var signer = new StubPackRunAttestationSigner();
|
|
var service = new PackRunAttestationService(
|
|
store,
|
|
NullLogger<PackRunAttestationService>.Instance,
|
|
signer);
|
|
|
|
var dependencies = new List<PackRunDependency>
|
|
{
|
|
new("https://registry.example.com/tool/scanner:v1",
|
|
new Dictionary<string, string> { ["sha256"] = "scanner123" },
|
|
"scanner",
|
|
"application/vnd.oci.image.index.v1+json")
|
|
};
|
|
|
|
var request = new PackRunAttestationRequest(
|
|
RunId: "run-010",
|
|
TenantId: "tenant-1",
|
|
PlanHash: "sha256:plan123",
|
|
Subjects: [new("artifact/output.tar.gz", new Dictionary<string, string> { ["sha256"] = "abc" })],
|
|
EvidenceSnapshotId: null,
|
|
StartedAt: DateTimeOffset.UtcNow,
|
|
CompletedAt: DateTimeOffset.UtcNow,
|
|
BuilderId: null,
|
|
ExternalParameters: null,
|
|
ResolvedDependencies: dependencies,
|
|
Metadata: null);
|
|
|
|
var result = await service.GenerateAsync(request, TestContext.Current.CancellationToken);
|
|
|
|
Assert.True(result.Success);
|
|
Assert.NotNull(result.Attestation);
|
|
Assert.Contains("resolvedDependencies", result.Attestation.PredicateJson);
|
|
Assert.Contains("scanner", result.Attestation.PredicateJson);
|
|
}
|
|
}
|