save progress

This commit is contained in:
StellaOps Bot
2026-01-03 11:02:24 +02:00
parent ca578801fd
commit 83c37243e0
446 changed files with 22798 additions and 4031 deletions

View File

@@ -0,0 +1,96 @@
using System.Text;
using System.Text.Json;
using System.Linq;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.Core.Delta;
using StellaOps.Attestor.Core.Signing;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Signer.Core.Predicates;
using Xunit;
namespace StellaOps.Attestor.Core.Tests.Delta;
public sealed class DeltaAttestationServiceTests
{
[Fact]
public async Task CreateVexDeltaAttestationAsync_UsesDeterministicDigestOrdering()
{
var signingService = new RecordingSigningService();
var options = Microsoft.Extensions.Options.Options.Create(new DeltaAttestationOptions { DefaultKeyId = "key-1" });
var service = new DeltaAttestationService(signingService, options, NullLogger<DeltaAttestationService>.Instance);
var request = new VexDeltaAttestationRequest
{
FromDigest = "sha256:from",
ToDigest = "sha256:to",
TenantId = "tenant-a",
UseTransparencyLog = false,
Annotations = new Dictionary<string, string>
{
["b"] = "2",
["a"] = "1"
},
Delta = new VexDeltaPredicate
{
FromDigest = "sha256:from",
ToDigest = "sha256:to",
ComputedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
TenantId = "tenant-a",
Summary = new VexDeltaSummary
{
StatusChangeCount = 0,
NewVulnCount = 0,
ResolvedVulnCount = 0,
CriticalNew = 0,
HighNew = 0
}
}
};
await service.CreateVexDeltaAttestationAsync(request, CancellationToken.None);
var payloadJson = Encoding.UTF8.GetString(Convert.FromBase64String(signingService.LastRequest!.PayloadBase64));
using var document = JsonDocument.Parse(payloadJson);
var digest = document.RootElement.GetProperty("subject")[0].GetProperty("digest");
var keys = digest.EnumerateObject().Select(entry => entry.Name).ToArray();
Assert.Equal(new[] { "annotation:a", "annotation:b", "sha256_from", "sha256_to" }, keys);
Assert.Equal("none", signingService.LastRequest!.LogPreference);
}
private sealed class RecordingSigningService : IAttestationSigningService
{
public AttestationSignRequest? LastRequest { get; private set; }
public Task<AttestationSignResult> SignAsync(
AttestationSignRequest request,
SubmissionContext context,
CancellationToken cancellationToken = default)
{
LastRequest = request;
return Task.FromResult(new AttestationSignResult
{
Bundle = new AttestorSubmissionRequest.SubmissionBundle
{
Dsse = new AttestorSubmissionRequest.DsseEnvelope
{
PayloadType = request.PayloadType,
PayloadBase64 = request.PayloadBase64,
Signatures = new List<AttestorSubmissionRequest.DsseSignature>
{
new() { Signature = "c2ln" }
}
}
},
Meta = new AttestorSubmissionRequest.SubmissionMeta
{
Artifact = request.Artifact,
LogPreference = request.LogPreference,
Archive = request.Archive
}
});
}
}
}

View File

@@ -0,0 +1,160 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
using FluentAssertions;
using StellaOps.Attestor.Core.InToto;
using Xunit;
namespace StellaOps.Attestor.Core.Tests.InToto;
[Trait("Category", "Unit")]
public sealed class ArtifactDigestsTests
{
[Fact]
public void Compute_FromBytes_ProducesSha256()
{
// Arrange
var data = "Hello, World!"u8.ToArray();
// Act
var digests = ArtifactDigests.Compute(data, includeSha512: false, includeSha1: false);
// Assert
digests.Sha256.Should().NotBeNullOrEmpty();
digests.Sha256.Should().Be("dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986f");
digests.Sha512.Should().BeNull();
digests.Sha1.Should().BeNull();
digests.HasDigest.Should().BeTrue();
}
[Fact]
public void Compute_WithSha512_ProducesBothHashes()
{
// Arrange
var data = "Hello, World!"u8.ToArray();
// Act
var digests = ArtifactDigests.Compute(data, includeSha512: true, includeSha1: false);
// Assert
digests.Sha256.Should().NotBeNullOrEmpty();
digests.Sha512.Should().NotBeNullOrEmpty();
digests.Sha1.Should().BeNull();
}
[Fact]
public void Compute_WithAllHashes_ProducesThreeHashes()
{
// Arrange
var data = "Hello, World!"u8.ToArray();
// Act
var digests = ArtifactDigests.Compute(data, includeSha512: true, includeSha1: true);
// Assert
digests.Sha256.Should().NotBeNullOrEmpty();
digests.Sha512.Should().NotBeNullOrEmpty();
digests.Sha1.Should().NotBeNullOrEmpty();
}
[Fact]
public void ToDictionary_ReturnsPresentDigestsOnly()
{
// Arrange
var digests = new ArtifactDigests
{
Sha256 = "abc123",
Sha512 = null,
Sha1 = "def456"
};
// Act
var dict = digests.ToDictionary();
// Assert
dict.Should().HaveCount(2);
dict.Should().ContainKey("sha256").WhoseValue.Should().Be("abc123");
dict.Should().ContainKey("sha1").WhoseValue.Should().Be("def456");
dict.Should().NotContainKey("sha512");
}
[Fact]
public void FromDictionary_ParsesAllAlgorithms()
{
// Arrange
var dict = new Dictionary<string, string>
{
["sha256"] = "abc",
["sha512"] = "def",
["sha1"] = "ghi"
};
// Act
var digests = ArtifactDigests.FromDictionary(dict);
// Assert
digests.Sha256.Should().Be("abc");
digests.Sha512.Should().Be("def");
digests.Sha1.Should().Be("ghi");
}
[Fact]
public void GetPrimaryDigest_PrefersSha256()
{
// Arrange
var digests = new ArtifactDigests
{
Sha256 = "sha256value",
Sha512 = "sha512value"
};
// Act & Assert
digests.GetPrimaryDigest().Should().Be("sha256value");
digests.GetPrimaryAlgorithm().Should().Be("sha256");
}
[Fact]
public void GetPrimaryDigest_FallsBackToSha512()
{
// Arrange
var digests = new ArtifactDigests
{
Sha512 = "sha512value"
};
// Act & Assert
digests.GetPrimaryDigest().Should().Be("sha512value");
digests.GetPrimaryAlgorithm().Should().Be("sha512");
}
[Fact]
public void HasDigest_ReturnsFalse_WhenEmpty()
{
// Arrange
var digests = new ArtifactDigests();
// Act & Assert
digests.HasDigest.Should().BeFalse();
}
[Fact]
public async Task ComputeFromFileAsync_ComputesCorrectDigest()
{
// Arrange
var tempFile = Path.GetTempFileName();
try
{
await File.WriteAllTextAsync(tempFile, "Test content", TestContext.Current.CancellationToken);
// Act
var digests = await ArtifactDigests.ComputeFromFileAsync(tempFile, cancellationToken: TestContext.Current.CancellationToken);
// Assert
digests.Sha256.Should().NotBeNullOrEmpty();
digests.Sha512.Should().NotBeNullOrEmpty();
}
finally
{
File.Delete(tempFile);
}
}
}

View File

@@ -0,0 +1,152 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
using System.Text.Json;
using FluentAssertions;
using StellaOps.Attestor.Core.InToto;
using Xunit;
namespace StellaOps.Attestor.Core.Tests.InToto;
[Trait("Category", "Unit")]
public sealed class InTotoLinkTests
{
[Fact]
public void ToJson_ProducesValidJsonStructure()
{
// Arrange
var link = new LinkBuilder("build")
.WithCommand("make", "all")
.AddMaterial("file://src/main.c", new ArtifactDigests { Sha256 = "abc123" })
.AddProduct("file://bin/app", new ArtifactDigests { Sha256 = "def456" })
.WithReturnValue(0)
.Build();
// Act
var json = link.ToJson();
// Assert
json.Should().NotBeNullOrEmpty();
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
root.GetProperty("_type").GetString().Should().Be(InTotoLink.StatementType);
root.GetProperty("predicateType").GetString().Should().Be(InTotoLink.PredicateTypeUri);
root.GetProperty("subject").GetArrayLength().Should().Be(1);
root.GetProperty("predicate").GetProperty("name").GetString().Should().Be("build");
}
[Fact]
public void FromJson_DeserializesCorrectly()
{
// Arrange
var originalLink = new LinkBuilder("test-step")
.WithCommand("echo", "hello")
.AddMaterial("file://input.txt", new ArtifactDigests { Sha256 = "in123" })
.AddProduct("file://output.txt", new ArtifactDigests { Sha256 = "out456" })
.WithEnvironment("CI", "true")
.WithReturnValue(0)
.WithStdout("hello")
.Build();
var json = originalLink.ToJson();
// Act
var deserializedLink = InTotoLink.FromJson(json);
// Assert
deserializedLink.Should().NotBeNull();
deserializedLink!.Predicate.Name.Should().Be("test-step");
deserializedLink.Predicate.Command.Should().Equal("echo", "hello");
deserializedLink.Predicate.Materials.Should().HaveCount(1);
deserializedLink.Predicate.Products.Should().HaveCount(1);
deserializedLink.Predicate.Environment.Should().ContainKey("CI");
deserializedLink.Predicate.ByProducts.ReturnValue.Should().Be(0);
}
[Fact]
public void ToStatement_ConvertsToGenericStatement()
{
// Arrange
var link = new LinkBuilder("step")
.AddProduct("file://out", new ArtifactDigests { Sha256 = "abc" })
.Build();
// Act
var statement = link.ToStatement();
// Assert
statement.Type.Should().Be(InTotoLink.StatementType);
statement.PredicateType.Should().Be(InTotoLink.PredicateTypeUri);
statement.Subject.Should().ContainSingle();
}
[Fact]
public void GetPayloadBytes_ReturnsUtf8Bytes()
{
// Arrange
var link = new LinkBuilder("step").Build();
// Act
var bytes = link.GetPayloadBytes();
// Assert
bytes.Should().NotBeEmpty();
var json = System.Text.Encoding.UTF8.GetString(bytes);
json.Should().Contain("\"_type\"");
}
[Fact]
public void Subjects_ArePopulatedFromProducts()
{
// Arrange & Act
var link = new LinkBuilder("build")
.AddProduct("file://artifact1.dll", new ArtifactDigests { Sha256 = "abc" })
.AddProduct("file://artifact2.dll", new ArtifactDigests { Sha256 = "def", Sha512 = "ghi" })
.Build();
// Assert
link.Subjects.Should().HaveCount(2);
link.Subjects[0].Name.Should().Be("file://artifact1.dll");
link.Subjects[0].Digest.Sha256.Should().Be("abc");
link.Subjects[1].Digest.Sha512.Should().Be("ghi");
}
[Fact]
public void FromJson_WithInvalidJson_ThrowsException()
{
// Arrange
var invalidJson = "not valid json";
// Act
var act = () => InTotoLink.FromJson(invalidJson);
// Assert
act.Should().Throw<JsonException>();
}
[Fact]
public void Predicate_ContainsAllComponents()
{
// Arrange & Act
var link = new LinkBuilder("complete-step")
.WithCommand("cmd", "arg1", "arg2")
.AddMaterial("file://m1", new ArtifactDigests { Sha256 = "m1hash" })
.AddProduct("file://p1", new ArtifactDigests { Sha256 = "p1hash" })
.WithEnvironment("VAR1", "val1")
.WithReturnValue(0)
.WithStdout("output")
.WithStderr("warnings")
.Build();
// Assert
link.Predicate.Name.Should().Be("complete-step");
link.Predicate.Command.Should().Equal("cmd", "arg1", "arg2");
link.Predicate.Materials.Should().ContainSingle();
link.Predicate.Products.Should().ContainSingle();
link.Predicate.Environment.Should().ContainKey("VAR1");
link.Predicate.ByProducts.ReturnValue.Should().Be(0);
link.Predicate.ByProducts.Stdout.Should().Be("output");
link.Predicate.ByProducts.Stderr.Should().Be("warnings");
}
}

View File

@@ -0,0 +1,276 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Attestor.Core.InToto;
using StellaOps.Attestor.Core.InToto.Layout;
using Xunit;
namespace StellaOps.Attestor.Core.Tests.InToto;
[Trait("Category", "Unit")]
public sealed class LayoutVerifierTests
{
private readonly FakeTimeProvider _timeProvider;
private readonly LayoutVerifier _verifier;
public LayoutVerifierTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 12, 0, 0, TimeSpan.Zero));
_verifier = new LayoutVerifier(NullLogger<LayoutVerifier>.Instance, _timeProvider);
}
private static InTotoLayout CreateSimpleLayout(params string[] stepNames)
{
var steps = stepNames.Select(name => new LayoutStep
{
Name = name,
MaterialRules = [],
ProductRules = [],
Threshold = 1,
AuthorizedKeyIds = ["functionary-key-1"]
}).ToImmutableArray();
return new InTotoLayout
{
Id = "test-layout",
Name = "Test Layout",
Steps = steps,
Keys =
[
new LayoutKey
{
KeyId = "functionary-key-1",
PublicKeyPem = "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----",
KeyType = "ecdsa-sha2-nistp256"
}
]
};
}
private static SignedLink CreateSignedLink(string stepName, string keyId, bool verified = true)
{
var link = new LinkBuilder(stepName).Build();
var payloadBytes = link.GetPayloadBytes();
// Signature must be valid base64
var testSignature = Convert.ToBase64String(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 });
var envelope = new global::StellaOps.Attestor.Envelope.DsseEnvelope(
"application/vnd.in-toto+json",
new ReadOnlyMemory<byte>(payloadBytes),
[new global::StellaOps.Attestor.Envelope.DsseSignature(testSignature, keyId)]);
return new SignedLink
{
Link = link,
Envelope = envelope,
SignerKeyId = keyId,
SignatureVerified = verified
};
}
[Fact]
public async Task VerifyAsync_WithAllStepsPresent_Succeeds()
{
// Arrange
var layout = CreateSimpleLayout("step-1", "step-2");
var links = new List<SignedLink>
{
CreateSignedLink("step-1", "functionary-key-1"),
CreateSignedLink("step-2", "functionary-key-1")
};
// Act
var result = await _verifier.VerifyAsync(layout, links, TestContext.Current.CancellationToken);
// Assert
result.Success.Should().BeTrue();
result.Violations.Should().BeEmpty();
}
[Fact]
public async Task VerifyAsync_WithMissingStep_ReturnsViolation()
{
// Arrange
var layout = CreateSimpleLayout("step-1", "step-2");
var links = new List<SignedLink>
{
CreateSignedLink("step-1", "functionary-key-1")
// step-2 is missing
};
// Act
var result = await _verifier.VerifyAsync(layout, links, TestContext.Current.CancellationToken);
// Assert
result.Success.Should().BeFalse();
result.Violations.Should().Contain(v =>
v.Type == LayoutViolationType.MissingStep &&
v.StepName == "step-2");
}
[Fact]
public async Task VerifyAsync_WithUnauthorizedFunctionary_ReturnsViolation()
{
// Arrange
var layout = CreateSimpleLayout("step-1");
var links = new List<SignedLink>
{
CreateSignedLink("step-1", "unauthorized-key")
};
// Act
var result = await _verifier.VerifyAsync(layout, links, TestContext.Current.CancellationToken);
// Assert
result.Success.Should().BeFalse();
result.Violations.Should().Contain(v =>
v.Type == LayoutViolationType.UnauthorizedFunctionary);
}
[Fact]
public async Task VerifyAsync_WithInvalidSignature_ReturnsViolation()
{
// Arrange
var layout = CreateSimpleLayout("step-1");
var signedLink = CreateSignedLink("step-1", "functionary-key-1", verified: false);
var links = new List<SignedLink> { signedLink };
// Act
var result = await _verifier.VerifyAsync(layout, links, TestContext.Current.CancellationToken);
// Assert
result.Success.Should().BeFalse();
result.Violations.Should().Contain(v =>
v.Type == LayoutViolationType.InvalidSignature);
}
[Fact]
public async Task VerifyAsync_WithInsufficientThreshold_ReturnsViolation()
{
// Arrange
var layout = new InTotoLayout
{
Id = "test-layout",
Name = "Test Layout",
Steps =
[
new LayoutStep
{
Name = "critical-step",
MaterialRules = [],
ProductRules = [],
Threshold = 2, // Requires 2 signatures
AuthorizedKeyIds = ["key-1", "key-2", "key-3"]
}
],
Keys =
[
new LayoutKey { KeyId = "key-1", PublicKeyPem = "test", KeyType = "ed25519" },
new LayoutKey { KeyId = "key-2", PublicKeyPem = "test", KeyType = "ed25519" },
new LayoutKey { KeyId = "key-3", PublicKeyPem = "test", KeyType = "ed25519" }
]
};
// Only 1 signature provided
var links = new List<SignedLink>
{
CreateSignedLink("critical-step", "key-1")
};
// Act
var result = await _verifier.VerifyAsync(layout, links, TestContext.Current.CancellationToken);
// Assert
result.Success.Should().BeFalse();
result.Violations.Should().Contain(v =>
v.Type == LayoutViolationType.ThresholdNotMet &&
v.Message.Contains("2"));
}
[Fact]
public async Task VerifyAsync_WithMetThreshold_Succeeds()
{
// Arrange
var layout = new InTotoLayout
{
Id = "test-layout",
Name = "Test Layout",
Steps =
[
new LayoutStep
{
Name = "critical-step",
MaterialRules = [],
ProductRules = [],
Threshold = 2,
AuthorizedKeyIds = ["key-1", "key-2"]
}
],
Keys =
[
new LayoutKey { KeyId = "key-1", PublicKeyPem = "test", KeyType = "ed25519" },
new LayoutKey { KeyId = "key-2", PublicKeyPem = "test", KeyType = "ed25519" }
]
};
var links = new List<SignedLink>
{
CreateSignedLink("critical-step", "key-1"),
CreateSignedLink("critical-step", "key-2")
};
// Act
var result = await _verifier.VerifyAsync(layout, links, TestContext.Current.CancellationToken);
// Assert
result.Success.Should().BeTrue();
}
[Fact]
public async Task VerifyAsync_ReturnsAllViolations()
{
// Arrange
var layout = CreateSimpleLayout("step-1", "step-2", "step-3");
var links = new List<SignedLink>
{
CreateSignedLink("step-1", "unauthorized-key"),
// step-2 missing
// step-3 missing
};
// Act
var result = await _verifier.VerifyAsync(layout, links, TestContext.Current.CancellationToken);
// Assert
result.Success.Should().BeFalse();
result.Violations.Should().HaveCountGreaterThanOrEqualTo(2);
}
[Fact]
public async Task VerifyAsync_WithExpiredLayout_ReturnsViolation()
{
// Arrange - layout expired yesterday
var layout = new InTotoLayout
{
Id = "test-layout",
Name = "Test Layout",
Steps = [new LayoutStep { Name = "step-1", AuthorizedKeyIds = ["key-1"] }],
Keys = [new LayoutKey { KeyId = "key-1", PublicKeyPem = "test" }],
Expires = _timeProvider.GetUtcNow().AddDays(-1)
};
var links = new List<SignedLink>
{
CreateSignedLink("step-1", "key-1")
};
// Act
var result = await _verifier.VerifyAsync(layout, links, TestContext.Current.CancellationToken);
// Assert
result.Success.Should().BeFalse();
result.Violations.Should().Contain(v =>
v.Type == LayoutViolationType.LayoutExpired);
}
}

View File

@@ -0,0 +1,176 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
using FluentAssertions;
using StellaOps.Attestor.Core.InToto;
using Xunit;
namespace StellaOps.Attestor.Core.Tests.InToto;
[Trait("Category", "Unit")]
public sealed class LinkBuilderTests
{
[Fact]
public void Build_CreatesValidLink_WithBasicProperties()
{
// Arrange & Act
var link = new LinkBuilder("test-step")
.WithCommand("echo", "hello")
.Build();
// Assert
link.Predicate.Name.Should().Be("test-step");
link.Predicate.Command.Should().Equal("echo", "hello");
}
[Fact]
public void AddMaterial_AddsToMaterialsList()
{
// Arrange & Act
var link = new LinkBuilder("step")
.AddMaterial("file://input.txt", new ArtifactDigests { Sha256 = "abc123" })
.AddMaterial("file://config.json", new ArtifactDigests { Sha256 = "def456" })
.Build();
// Assert
link.Predicate.Materials.Should().HaveCount(2);
link.Predicate.Materials[0].Uri.Should().Be("file://input.txt");
link.Predicate.Materials[1].Uri.Should().Be("file://config.json");
}
[Fact]
public void AddProduct_AddsToProductsList_AndSubjects()
{
// Arrange & Act
var link = new LinkBuilder("step")
.AddProduct("file://output.dll", new ArtifactDigests { Sha256 = "out123" })
.Build();
// Assert
link.Predicate.Products.Should().ContainSingle();
link.Predicate.Products[0].Digest.Sha256.Should().Be("out123");
link.Subjects.Should().ContainSingle();
link.Subjects[0].Name.Should().Be("file://output.dll");
}
[Fact]
public void WithEnvironment_SetsEnvironmentVariables()
{
// Arrange & Act
var link = new LinkBuilder("step")
.WithEnvironment("CI", "true")
.WithEnvironment("BUILD_ID", "42")
.Build();
// Assert
link.Predicate.Environment.Should().HaveCount(2);
link.Predicate.Environment["CI"].Should().Be("true");
link.Predicate.Environment["BUILD_ID"].Should().Be("42");
}
[Fact]
public void WithReturnValue_SetsReturnValueInByProducts()
{
// Arrange & Act
var link = new LinkBuilder("step")
.WithReturnValue(0)
.Build();
// Assert
link.Predicate.ByProducts.ReturnValue.Should().Be(0);
}
[Fact]
public void WithStdout_SetsStdoutInByProducts()
{
// Arrange & Act
var link = new LinkBuilder("step")
.WithStdout("Build succeeded.")
.Build();
// Assert
link.Predicate.ByProducts.Stdout.Should().Be("Build succeeded.");
}
[Fact]
public void WithStderr_SetsStderrInByProducts()
{
// Arrange & Act
var link = new LinkBuilder("step")
.WithStderr("Warning: deprecated API")
.Build();
// Assert
link.Predicate.ByProducts.Stderr.Should().Be("Warning: deprecated API");
}
[Fact]
public void Build_WithNoName_ThrowsArgumentException()
{
// Arrange & Act
var act = () => new LinkBuilder("").Build();
// Assert
act.Should().Throw<ArgumentException>()
.WithMessage("*empty string*whitespace*");
}
[Fact]
public void Build_WithWhitespaceName_ThrowsArgumentException()
{
// Arrange & Act
var act = () => new LinkBuilder(" ").Build();
// Assert
act.Should().Throw<ArgumentException>()
.WithMessage("*empty string*whitespace*");
}
[Fact]
public void ChainingMethods_Works()
{
// Arrange & Act
var link = new LinkBuilder("full-step")
.WithCommand("make", "all")
.AddMaterial("file://src/main.c", new ArtifactDigests { Sha256 = "src123" })
.AddProduct("file://bin/app", new ArtifactDigests { Sha256 = "bin456" })
.WithEnvironment("CC", "gcc")
.WithReturnValue(0)
.WithStdout("Compiling...")
.Build();
// Assert
link.Predicate.Name.Should().Be("full-step");
link.Predicate.Command.Should().Equal("make", "all");
link.Predicate.Materials.Should().ContainSingle();
link.Predicate.Products.Should().ContainSingle();
link.Predicate.Environment.Should().ContainKey("CC");
link.Predicate.ByProducts.ReturnValue.Should().Be(0);
link.Predicate.ByProducts.Stdout.Should().Be("Compiling...");
}
[Fact]
public void AddMaterial_WithSha256String_Works()
{
// Arrange & Act
var link = new LinkBuilder("step")
.AddMaterial("file://./src/file.cs", "abc123def456")
.Build();
// Assert
link.Predicate.Materials.Should().ContainSingle();
link.Predicate.Materials[0].Digest.Sha256.Should().Be("abc123def456");
}
[Fact]
public void AddProduct_WithSha256String_Works()
{
// Arrange & Act
var link = new LinkBuilder("step")
.AddProduct("file://./bin/output", "fedcba987654")
.Build();
// Assert
link.Predicate.Products.Should().ContainSingle();
link.Predicate.Products[0].Digest.Sha256.Should().Be("fedcba987654");
}
}

View File

@@ -0,0 +1,160 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Attestor.Core.InToto;
using Xunit;
using Opts = Microsoft.Extensions.Options.Options;
namespace StellaOps.Attestor.Core.Tests.InToto;
[Trait("Category", "Unit")]
public sealed class LinkRecorderTests
{
private readonly FakeTimeProvider _timeProvider;
private readonly LinkRecorder _recorder;
public LinkRecorderTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 12, 0, 0, TimeSpan.Zero));
_recorder = new LinkRecorder(
NullLogger<LinkRecorder>.Instance,
Opts.Create(new LinkRecorderOptions()),
_timeProvider);
}
[Fact]
public async Task RecordStepAsync_WithAction_CreatesLinkWithCorrectName()
{
// Arrange
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
var materialFile = Path.Combine(tempDir, "material.txt");
var productFile = Path.Combine(tempDir, "product.txt");
await File.WriteAllTextAsync(materialFile, "input", TestContext.Current.CancellationToken);
// Simulate an action that creates the product
var action = async () =>
{
await File.WriteAllTextAsync(productFile, "output");
return 0;
};
// Act
var link = await _recorder.RecordStepAsync(
"build-step",
action,
[MaterialSpec.WithLocalPath($"file://{materialFile}", materialFile)],
[ProductSpec.WithLocalPath($"file://{productFile}", productFile)],
TestContext.Current.CancellationToken);
// Assert
link.Predicate.Name.Should().Be("build-step");
link.Predicate.Materials.Should().HaveCount(1);
link.Predicate.Products.Should().HaveCount(1);
link.Predicate.ByProducts.ReturnValue.Should().Be(0);
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
[Fact]
public async Task RecordStepAsync_ComputesDigestsForMaterials()
{
// Arrange
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
var materialFile = Path.Combine(tempDir, "material.txt");
await File.WriteAllTextAsync(materialFile, "test content", TestContext.Current.CancellationToken);
// Act
var link = await _recorder.RecordStepAsync(
"test-step",
() => Task.FromResult(0),
[MaterialSpec.WithLocalPath($"file://{materialFile}", materialFile)],
[],
TestContext.Current.CancellationToken);
// Assert
var material = link.Predicate.Materials.Should().ContainSingle().Subject;
material.Digest.Sha256.Should().NotBeNullOrEmpty();
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
[Fact]
public async Task RecordExternalStepAsync_CreatesLinkFromPrecomputedDigests()
{
// Arrange
var materials = new[]
{
MaterialSpec.WithDigest("https://example.com/artifact.tar.gz", new ArtifactDigests { Sha256 = "abc123" })
};
var products = new[]
{
ProductSpec.WithDigest("file://output.bin", new ArtifactDigests { Sha256 = "def456" })
};
// Act
var link = await _recorder.RecordExternalStepAsync(
"external-step",
["external-tool", "run"],
returnValue: 0,
materials,
products,
TestContext.Current.CancellationToken);
// Assert
link.Predicate.Name.Should().Be("external-step");
link.Predicate.Materials.Should().HaveCount(1);
link.Predicate.Materials[0].Uri.Should().Be("https://example.com/artifact.tar.gz");
link.Predicate.Products.Should().HaveCount(1);
link.Predicate.Products[0].Digest.Sha256.Should().Be("def456");
}
[Fact]
public async Task RecordStepAsync_GeneratesSubjects_FromProducts()
{
// Arrange
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
var productFile = Path.Combine(tempDir, "output.dll");
// Action creates the product
var action = async () =>
{
await File.WriteAllTextAsync(productFile, "binary content");
return 0;
};
// Act
var link = await _recorder.RecordStepAsync(
"compile",
action,
[],
[ProductSpec.WithLocalPath($"file://{productFile}", productFile)],
TestContext.Current.CancellationToken);
// Assert
link.Subjects.Should().NotBeEmpty();
link.Subjects[0].Digest.Sha256.Should().NotBeNullOrEmpty();
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
}

View File

@@ -0,0 +1,138 @@
using System.Text;
using System.Text.Json;
using System.IO;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Attestor;
using StellaOps.Cryptography;
using Xunit;
namespace StellaOps.Attestor.Core.Tests.PoE;
public sealed class PoEArtifactGeneratorTests
{
[Fact]
public async Task EmitPoEAsync_RespectsPrettifyOption()
{
var generator = new PoEArtifactGenerator(
new StubDsseSigningService(),
NullLogger<PoEArtifactGenerator>.Instance,
new FixedCryptoHash());
var poeBytes = await generator.EmitPoEAsync(
CreateSubgraph(),
CreateMetadata(),
graphHash: "blake3:graph",
imageDigest: "sha256:image",
evidenceRefs: null,
options: new PoEEmissionOptions(PrettifyJson: false));
var json = Encoding.UTF8.GetString(poeBytes);
Assert.DoesNotContain('\n', json);
}
[Fact]
public async Task EmitPoEAsync_IncludesEvidenceRefsWhenEnabled()
{
var generator = new PoEArtifactGenerator(
new StubDsseSigningService(),
NullLogger<PoEArtifactGenerator>.Instance,
new FixedCryptoHash());
var evidenceRefs = new PoEEvidenceRefs(
SbomRef: "cas://sbom/sha256:abc",
VexClaimUri: "cas://vex/sha256:def",
RuntimeFactsUri: "cas://runtime/sha256:ghi");
var poeBytes = await generator.EmitPoEAsync(
CreateSubgraph(),
CreateMetadata(),
graphHash: "blake3:graph",
imageDigest: "sha256:image",
evidenceRefs: evidenceRefs,
options: new PoEEmissionOptions(
IncludeSbomRef: true,
IncludeVexClaimUri: true,
IncludeRuntimeFactsUri: true,
PrettifyJson: false));
using var document = JsonDocument.Parse(poeBytes);
var evidence = document.RootElement.GetProperty("evidence");
Assert.Equal("blake3:graph", evidence.GetProperty("graphHash").GetString());
Assert.Equal("cas://sbom/sha256:abc", evidence.GetProperty("sbomRef").GetString());
Assert.Equal("cas://vex/sha256:def", evidence.GetProperty("vexClaimUri").GetString());
Assert.Equal("cas://runtime/sha256:ghi", evidence.GetProperty("runtimeFactsUri").GetString());
}
[Fact]
public void ComputePoEHash_UsesCryptoHashPrefix()
{
var generator = new PoEArtifactGenerator(
new StubDsseSigningService(),
NullLogger<PoEArtifactGenerator>.Instance,
new FixedCryptoHash());
var hash = generator.ComputePoEHash(Encoding.UTF8.GetBytes("{}"));
Assert.Equal("blake3:fixed", hash);
}
private static PoESubgraph CreateSubgraph()
{
return new PoESubgraph(
BuildId: "build-1",
ComponentRef: "pkg:maven/log4j@2.14.1",
VulnId: "CVE-2021-44228",
Nodes: new[]
{
new FunctionId("sha256:mod", "main", "0x1", null, null)
},
Edges: Array.Empty<Edge>(),
EntryRefs: new[] { "main" },
SinkRefs: new[] { "main" },
PolicyDigest: "sha256:policy",
ToolchainDigest: "sha256:toolchain");
}
private static ProofMetadata CreateMetadata()
{
return new ProofMetadata(
GeneratedAt: new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc),
Analyzer: new AnalyzerInfo(
Name: "scanner",
Version: "1.0.0",
ToolchainDigest: "sha256:toolchain"),
Policy: new PolicyInfo(
PolicyId: "policy-1",
PolicyDigest: "sha256:policy",
EvaluatedAt: new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)),
ReproSteps: Array.Empty<string>());
}
private sealed class StubDsseSigningService : IDsseSigningService
{
public Task<byte[]> SignAsync(byte[] payload, string payloadType, string signingKeyId, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<bool> VerifyAsync(byte[] dsseEnvelope, IReadOnlyList<string> trustedKeyIds, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
private sealed class FixedCryptoHash : ICryptoHash
{
public byte[] ComputeHash(ReadOnlySpan<byte> data, string? algorithmId = null) => throw new NotSupportedException();
public string ComputeHashHex(ReadOnlySpan<byte> data, string? algorithmId = null) => throw new NotSupportedException();
public string ComputeHashBase64(ReadOnlySpan<byte> data, string? algorithmId = null) => throw new NotSupportedException();
public ValueTask<byte[]> ComputeHashAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public ValueTask<string> ComputeHashHexAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public byte[] ComputeHashForPurpose(ReadOnlySpan<byte> data, string purpose) => throw new NotSupportedException();
public string ComputeHashHexForPurpose(ReadOnlySpan<byte> data, string purpose) => throw new NotSupportedException();
public string ComputeHashBase64ForPurpose(ReadOnlySpan<byte> data, string purpose) => throw new NotSupportedException();
public ValueTask<byte[]> ComputeHashForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public ValueTask<string> ComputeHashHexForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public string GetAlgorithmForPurpose(string purpose) => throw new NotSupportedException();
public string GetHashPrefix(string purpose) => "blake3:";
public string ComputePrefixedHashForPurpose(ReadOnlySpan<byte> data, string purpose) => "blake3:fixed";
}
}

View File

@@ -0,0 +1,41 @@
using StellaOps.Attestor.Serialization;
using Xunit;
namespace StellaOps.Attestor.Core.Tests.Serialization;
public sealed class CanonicalJsonSerializerTests
{
[Fact]
public void SerializeToString_SortsDictionaryKeys()
{
var value = new Dictionary<string, int>
{
["b"] = 2,
["a"] = 1
};
var json = CanonicalJsonSerializer.SerializeToString(value, prettify: false);
var indexA = json.IndexOf("\"a\"", StringComparison.Ordinal);
var indexB = json.IndexOf("\"b\"", StringComparison.Ordinal);
Assert.True(indexA >= 0);
Assert.True(indexB >= 0);
Assert.True(indexA < indexB);
}
[Fact]
public void SerializeToString_RespectsPrettifyOption()
{
var value = new Dictionary<string, int>
{
["a"] = 1
};
var pretty = CanonicalJsonSerializer.SerializeToString(value, prettify: true);
var minified = CanonicalJsonSerializer.SerializeToString(value, prettify: false);
Assert.Contains('\n', pretty);
Assert.DoesNotContain('\n', minified);
}
}

View File

@@ -0,0 +1,24 @@
using System.Text;
using System.Linq;
using StellaOps.Attestor.Core.Signing;
using Xunit;
namespace StellaOps.Attestor.Core.Tests.Signing;
public sealed class DssePreAuthenticationEncodingTests
{
[Fact]
public void Compute_UsesAsciiLengthEncoding()
{
var payloadType = "application/test";
var payload = Encoding.UTF8.GetBytes("{\"a\":1}");
var expectedPrefix = Encoding.ASCII.GetBytes(
$"DSSEv1 {payloadType.Length} {payloadType} {payload.Length} ");
var expected = expectedPrefix.Concat(payload).ToArray();
var result = DssePreAuthenticationEncoding.Compute(payloadType, payload);
Assert.Equal(expected, result);
}
}

View File

@@ -0,0 +1,85 @@
using System.Security.Cryptography;
using System.Text;
using StellaOps.Attestor.Core.Submission;
using Xunit;
namespace StellaOps.Attestor.Core.Tests.Submission;
public sealed class AttestorSubmissionValidatorTests
{
[Theory]
[InlineData("delta-attestation")]
[InlineData("poe")]
public async Task ValidateAsync_AllowsDeltaAndPoeKinds(string kind)
{
var canonical = Encoding.UTF8.GetBytes("canonical");
var canonicalHash = SHA256.HashData(canonical);
var canonicalHex = Convert.ToHexString(canonicalHash).ToLowerInvariant();
var validator = new AttestorSubmissionValidator(new FixedCanonicalizer(canonical));
var request = CreateRequest(kind, canonicalHex);
var result = await validator.ValidateAsync(request, CancellationToken.None);
Assert.NotNull(result);
}
[Fact]
public async Task ValidateAsync_AllowsNoneLogPreference()
{
var canonical = Encoding.UTF8.GetBytes("canonical");
var canonicalHash = SHA256.HashData(canonical);
var canonicalHex = Convert.ToHexString(canonicalHash).ToLowerInvariant();
var validator = new AttestorSubmissionValidator(new FixedCanonicalizer(canonical));
var request = CreateRequest("sbom", canonicalHex);
request.Meta.LogPreference = "none";
var result = await validator.ValidateAsync(request, CancellationToken.None);
Assert.NotNull(result);
}
private static AttestorSubmissionRequest CreateRequest(string kind, string bundleSha256)
{
return new AttestorSubmissionRequest
{
Bundle = new AttestorSubmissionRequest.SubmissionBundle
{
Mode = "keyless",
Dsse = new AttestorSubmissionRequest.DsseEnvelope
{
PayloadType = "application/vnd.in-toto+json",
PayloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("{}")),
Signatures = new List<AttestorSubmissionRequest.DsseSignature>
{
new() { Signature = "c2ln" }
}
}
},
Meta = new AttestorSubmissionRequest.SubmissionMeta
{
BundleSha256 = bundleSha256,
LogPreference = "primary",
Artifact = new AttestorSubmissionRequest.ArtifactInfo
{
Sha256 = new string('a', 64),
Kind = kind
}
}
};
}
private sealed class FixedCanonicalizer : IDsseCanonicalizer
{
private readonly byte[] _canonical;
public FixedCanonicalizer(byte[] canonical)
{
_canonical = canonical;
}
public Task<byte[]> CanonicalizeAsync(AttestorSubmissionRequest request, CancellationToken cancellationToken = default)
=> Task.FromResult(_canonical);
}
}

View File

@@ -15,169 +15,12 @@ public sealed class PredicateSchemaValidatorTests
}
[Fact]
public void Validate_ValidSbomPredicate_ReturnsValid()
public void Validate_MissingSbomSchema_ReturnsSkip()
{
var json = """
{
"format": "spdx-3.0.1",
"digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"componentCount": 42,
"uri": "https://example.com/sbom.json",
"tooling": "syft",
"createdAt": "2025-12-22T00:00:00Z"
}
""";
var predicate = JsonDocument.Parse(json).RootElement;
var result = _validator.Validate("stella.ops/sbom@v1", predicate);
Assert.True(result.IsValid);
Assert.Null(result.ErrorMessage);
}
[Fact]
public void Validate_ValidVexPredicate_ReturnsValid()
{
var json = """
{
"format": "openvex",
"statements": [
{
"vulnerability": "CVE-2024-12345",
"status": "not_affected",
"justification": "Component not used",
"products": ["pkg:npm/lodash@4.17.21"]
}
],
"digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"author": "security@example.com",
"timestamp": "2025-12-22T00:00:00Z"
}
""";
var predicate = JsonDocument.Parse(json).RootElement;
var result = _validator.Validate("stella.ops/vex@v1", predicate);
Assert.True(result.IsValid);
}
[Fact]
public void Validate_ValidReachabilityPredicate_ReturnsValid()
{
var json = """
{
"result": "unreachable",
"confidence": 0.95,
"graphDigest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"paths": [],
"entrypoints": [
{
"type": "http",
"route": "/api/users",
"auth": "required"
}
],
"computedAt": "2025-12-22T00:00:00Z",
"expiresAt": "2025-12-29T00:00:00Z"
}
""";
var predicate = JsonDocument.Parse(json).RootElement;
var result = _validator.Validate("stella.ops/reachability@v1", predicate);
Assert.True(result.IsValid);
}
[Fact]
public void Validate_ValidPolicyDecisionPredicate_ReturnsValid()
{
var json = """
{
"finding_id": "CVE-2024-12345@pkg:npm/lodash@4.17.20",
"cve": "CVE-2024-12345",
"component_purl": "pkg:npm/lodash@4.17.20",
"decision": "Block",
"reasoning": {
"rules_evaluated": 5,
"rules_matched": ["high-severity", "reachable"],
"final_score": 85.5,
"risk_multiplier": 1.2,
"reachability_state": "reachable",
"vex_status": "affected",
"summary": "High severity vulnerability is reachable"
},
"evidence_refs": [
"sha256:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"
],
"evaluated_at": "2025-12-22T00:00:00Z",
"expires_at": "2025-12-23T00:00:00Z",
"policy_version": "1.0.0",
"policy_hash": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
}
""";
var predicate = JsonDocument.Parse(json).RootElement;
var result = _validator.Validate("stella.ops/policy-decision@v1", predicate);
Assert.True(result.IsValid);
}
[Fact]
public void Validate_ValidHumanApprovalPredicate_ReturnsValid()
{
var json = """
{
"schema": "human-approval-v1",
"approval_id": "approval-123",
"finding_id": "CVE-2024-12345",
"decision": "AcceptRisk",
"approver": {
"user_id": "alice@example.com",
"display_name": "Alice Smith",
"role": "Security Engineer"
},
"justification": "Risk accepted for legacy system scheduled for decommission in 30 days",
"approved_at": "2025-12-22T00:00:00Z",
"expires_at": "2026-01-22T00:00:00Z"
}
""";
var predicate = JsonDocument.Parse(json).RootElement;
var result = _validator.Validate("stella.ops/human-approval@v1", predicate);
Assert.True(result.IsValid);
}
[Fact]
public void Validate_InvalidVexStatus_ReturnsFail()
{
var json = """
{
"format": "openvex",
"statements": [
{
"vulnerability": "CVE-2024-12345",
"status": "invalid_status",
"products": []
}
],
"digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
}
""";
var predicate = JsonDocument.Parse(json).RootElement;
var result = _validator.Validate("stella.ops/vex@v1", predicate);
Assert.False(result.IsValid);
Assert.NotNull(result.ErrorMessage);
}
[Fact]
public void Validate_MissingRequiredField_ReturnsFail()
{
var json = """
{
"format": "spdx-3.0.1",
"componentCount": 42
}
""";
@@ -185,8 +28,139 @@ public sealed class PredicateSchemaValidatorTests
var predicate = JsonDocument.Parse(json).RootElement;
var result = _validator.Validate("stella.ops/sbom@v1", predicate);
Assert.True(result.IsValid);
Assert.Contains("skip", result.ErrorMessage ?? string.Empty, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_VexDeltaPredicate_ReturnsValid()
{
var json = """
{
"fromDigest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"toDigest": "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
"tenantId": "tenant-a",
"summary": {
"addedCount": 1,
"removedCount": 0,
"changedCount": 0,
"unchangedCount": 2,
"netRiskDirection": "neutral"
},
"comparedAt": "2025-12-22T00:00:00Z"
}
""";
var predicate = JsonDocument.Parse(json).RootElement;
var result = _validator.Validate("stella.ops/vex-delta@v1", predicate);
Assert.True(result.IsValid);
Assert.Null(result.ErrorMessage);
}
[Fact]
public void Validate_SbomDeltaPredicate_ReturnsValid()
{
var json = """
{
"fromDigest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"toDigest": "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
"fromSbomDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
"toSbomDigest": "sha256:2222222222222222222222222222222222222222222222222222222222222222",
"tenantId": "tenant-a",
"summary": {
"addedCount": 1,
"removedCount": 0,
"versionChangedCount": 0,
"unchangedCount": 2,
"fromTotalCount": 2,
"toTotalCount": 3,
"vulnerabilitiesFixedCount": 0,
"vulnerabilitiesIntroducedCount": 0
},
"comparedAt": "2025-12-22T00:00:00Z"
}
""";
var predicate = JsonDocument.Parse(json).RootElement;
var result = _validator.Validate("stella.ops/sbom-delta@v1", predicate);
Assert.True(result.IsValid);
Assert.Null(result.ErrorMessage);
}
[Fact]
public void Validate_VerdictDeltaPredicate_ReturnsValid()
{
var json = """
{
"fromDigest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"toDigest": "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
"tenantId": "tenant-a",
"fromPolicyVersion": "1.0.0",
"toPolicyVersion": "1.1.0",
"fromVerdict": {
"outcome": "pass",
"confidence": 0.9,
"riskScore": 5.0,
"passingRules": 10,
"failingRules": 0,
"warningRules": 1
},
"toVerdict": {
"outcome": "warn",
"confidence": 0.8,
"riskScore": 7.5,
"passingRules": 9,
"failingRules": 0,
"warningRules": 2
},
"summary": {
"verdictChanged": true,
"riskDirection": "increased",
"riskScoreDelta": 2.5,
"confidenceDelta": -0.1,
"findingsImproved": 0,
"findingsWorsened": 1,
"findingsNew": 1,
"findingsResolved": 0,
"rulesChanged": 1
},
"comparedAt": "2025-12-22T00:00:00Z"
}
""";
var predicate = JsonDocument.Parse(json).RootElement;
var result = _validator.Validate("stella.ops/verdict-delta@v1", predicate);
Assert.True(result.IsValid);
Assert.Null(result.ErrorMessage);
}
[Fact]
public void Validate_InvalidDeltaPredicate_ReturnsFail()
{
var json = """
{
"fromDigest": "sha256:not-a-hex",
"toDigest": "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
"tenantId": "tenant-a",
"summary": {
"addedCount": 1,
"removedCount": 0,
"changedCount": 0,
"unchangedCount": 2,
"netRiskDirection": "neutral"
},
"comparedAt": "2025-12-22T00:00:00Z"
}
""";
var predicate = JsonDocument.Parse(json).RootElement;
var result = _validator.Validate("stella.ops/vex-delta@v1", predicate);
Assert.False(result.IsValid);
Assert.Contains("digest", result.ErrorMessage ?? string.Empty, StringComparison.OrdinalIgnoreCase);
Assert.NotNull(result.ErrorMessage);
}
[Fact]
@@ -204,89 +178,4 @@ public sealed class PredicateSchemaValidatorTests
Assert.True(result.IsValid);
Assert.Contains("skip", result.ErrorMessage ?? string.Empty, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_InvalidDigestFormat_ReturnsFail()
{
var json = """
{
"format": "spdx-3.0.1",
"digest": "invalid-digest-format",
"componentCount": 42
}
""";
var predicate = JsonDocument.Parse(json).RootElement;
var result = _validator.Validate("stella.ops/sbom@v1", predicate);
Assert.False(result.IsValid);
Assert.NotEmpty(result.Errors);
}
[Fact]
public void Validate_NormalizePredicateType_HandlesWithAndWithoutPrefix()
{
var json = """
{
"format": "spdx-3.0.1",
"digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"componentCount": 42
}
""";
var predicate = JsonDocument.Parse(json).RootElement;
var result1 = _validator.Validate("stella.ops/sbom@v1", predicate);
var result2 = _validator.Validate("sbom@v1", predicate);
Assert.True(result1.IsValid);
Assert.True(result2.IsValid);
}
[Fact]
public void Validate_ValidBoundaryPredicate_ReturnsValid()
{
var json = """
{
"surface": "http",
"exposure": "public",
"observedAt": "2025-12-22T00:00:00Z",
"endpoints": [
{
"route": "/api/users/:id",
"method": "GET",
"auth": "required"
}
],
"auth": {
"mechanism": "jwt",
"required_scopes": ["read:users"]
},
"controls": ["rate-limit", "WAF"],
"expiresAt": "2025-12-25T00:00:00Z"
}
""";
var predicate = JsonDocument.Parse(json).RootElement;
var result = _validator.Validate("stella.ops/boundary@v1", predicate);
Assert.True(result.IsValid);
}
[Fact]
public void Validate_InvalidReachabilityConfidence_ReturnsFail()
{
var json = """
{
"result": "reachable",
"confidence": 1.5,
"graphDigest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
}
""";
var predicate = JsonDocument.Parse(json).RootElement;
var result = _validator.Validate("stella.ops/reachability@v1", predicate);
Assert.False(result.IsValid);
}
}

View File

@@ -0,0 +1,40 @@
using System.Formats.Asn1;
using System.Text;
using StellaOps.Attestor.Core.Verification;
using Xunit;
namespace StellaOps.Attestor.Core.Tests.Verification;
public sealed class CheckpointSignatureVerifierTests
{
[Fact]
public void VerifyCheckpoint_Ed25519Spki_ReturnsFalseWithoutThrow()
{
var checkpoint = CreateCheckpoint();
var signature = Encoding.UTF8.GetBytes("signature");
var publicKey = CreateEd25519Spki(new byte[32]);
var result = CheckpointSignatureVerifier.VerifyCheckpoint(checkpoint, signature, publicKey);
Assert.False(result.Verified);
Assert.NotNull(result.FailureReason);
}
private static string CreateCheckpoint()
{
var rootHash = Convert.ToBase64String(new byte[32]);
return "rekor.sigstore.dev - test\n1\n" + rootHash + "\n";
}
private static byte[] CreateEd25519Spki(byte[] publicKey)
{
var writer = new AsnWriter(AsnEncodingRules.DER);
writer.PushSequence();
writer.PushSequence();
writer.WriteObjectIdentifier("1.3.101.112");
writer.PopSequence();
writer.WriteBitString(publicKey);
writer.PopSequence();
return writer.Encode();
}
}

View File

@@ -0,0 +1,17 @@
using StellaOps.Attestor.Core.Verification;
using Xunit;
namespace StellaOps.Attestor.Core.Tests.Verification;
public sealed class TimeSkewOptionsTests
{
[Fact]
public void Defaults_MatchCharter()
{
var options = new TimeSkewOptions();
Assert.Equal(300, options.WarnThresholdSeconds);
Assert.Equal(3600, options.RejectThresholdSeconds);
Assert.Equal(60, options.MaxFutureSkewSeconds);
}
}

View File

@@ -14,6 +14,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.Core.Signing;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Attestor.Serialization;
using StellaOps.Signer.Core;
using StellaOps.Signer.Core.Predicates;
@@ -27,13 +28,6 @@ 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;
@@ -143,7 +137,7 @@ public sealed class DeltaAttestationService : IDeltaAttestationService
{
// Build in-toto statement
var statement = BuildInTotoStatement(request, predicate, predicateType);
var statementJson = JsonSerializer.Serialize(statement, JsonOptions);
var statementJson = CanonicalJsonSerializer.SerializeToString(statement, prettify: false);
var payloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(statementJson));
// Compute envelope digest
@@ -188,7 +182,7 @@ public sealed class DeltaAttestationService : IDeltaAttestationService
// Build envelope base64 from signed result
var envelopeBase64 = signResult.Bundle?.Dsse != null
? Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(signResult.Bundle.Dsse, JsonOptions)))
? Convert.ToBase64String(Encoding.UTF8.GetBytes(CanonicalJsonSerializer.SerializeToString(signResult.Bundle.Dsse, prettify: false)))
: null;
_logger.LogInformation(
@@ -231,7 +225,7 @@ public sealed class DeltaAttestationService : IDeltaAttestationService
new()
{
Name = $"lineage:{request.FromDigest}..{request.ToDigest}",
Digest = new Dictionary<string, string>
Digest = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
["sha256_from"] = ExtractHash(request.FromDigest),
["sha256_to"] = ExtractHash(request.ToDigest)
@@ -242,7 +236,7 @@ public sealed class DeltaAttestationService : IDeltaAttestationService
// Add annotations if provided
if (request.Annotations?.Count > 0)
{
foreach (var (key, value) in request.Annotations)
foreach (var (key, value) in request.Annotations.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
subjects[0].Digest[$"annotation:{key}"] = value;
}

View File

@@ -18,6 +18,8 @@ public interface IProofEmitter
/// <param name="metadata">PoE metadata (analyzer version, repro steps, etc.)</param>
/// <param name="graphHash">Parent richgraph-v1 BLAKE3 hash</param>
/// <param name="imageDigest">Optional container image digest</param>
/// <param name="evidenceRefs">Optional evidence references to include in the PoE.</param>
/// <param name="options">Optional emission options (prettify, evidence flags).</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>
/// Canonical PoE JSON bytes (unsigned). Hash these bytes to get poe_hash.
@@ -27,6 +29,8 @@ public interface IProofEmitter
ProofMetadata metadata,
string graphHash,
string? imageDigest = null,
PoEEvidenceRefs? evidenceRefs = null,
PoEEmissionOptions? options = null,
CancellationToken cancellationToken = default
);
@@ -62,6 +66,8 @@ public interface IProofEmitter
/// <param name="metadata">Shared metadata for all PoEs</param>
/// <param name="graphHash">Parent richgraph-v1 BLAKE3 hash</param>
/// <param name="imageDigest">Optional container image digest</param>
/// <param name="evidenceRefs">Optional evidence references to include in the PoE.</param>
/// <param name="options">Optional emission options (prettify, evidence flags).</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>
/// Dictionary mapping vuln_id to (poe_bytes, poe_hash).
@@ -71,6 +77,8 @@ public interface IProofEmitter
ProofMetadata metadata,
string graphHash,
string? imageDigest = null,
PoEEvidenceRefs? evidenceRefs = null,
PoEEmissionOptions? options = null,
CancellationToken cancellationToken = default
);
}
@@ -117,6 +125,18 @@ public record PoEEmissionOptions(
);
}
/// <summary>
/// Optional evidence references for PoE emission.
/// </summary>
/// <param name="SbomRef">Reference to SBOM artifact</param>
/// <param name="VexClaimUri">Reference to VEX claim</param>
/// <param name="RuntimeFactsUri">Reference to runtime facts</param>
public record PoEEvidenceRefs(
string? SbomRef,
string? VexClaimUri,
string? RuntimeFactsUri
);
/// <summary>
/// Result of PoE emission with hash and optional DSSE signature.
/// </summary>

View File

@@ -0,0 +1,163 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
using System.Collections.Frozen;
using System.Collections.Immutable;
using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.Core.InToto;
/// <summary>
/// Cryptographic digests for an artifact, following in-toto digest specification.
/// </summary>
/// <remarks>
/// Per the in-toto spec, at least one digest algorithm must be present.
/// SHA-256 is required for compatibility; SHA-512 is recommended for stronger security.
/// </remarks>
public sealed record ArtifactDigests
{
/// <summary>
/// SHA-256 digest in lowercase hex.
/// </summary>
[JsonPropertyName("sha256")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Sha256 { get; init; }
/// <summary>
/// SHA-512 digest in lowercase hex.
/// </summary>
[JsonPropertyName("sha512")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Sha512 { get; init; }
/// <summary>
/// SHA-1 digest in lowercase hex. Deprecated but sometimes needed for compatibility.
/// </summary>
[JsonPropertyName("sha1")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Sha1 { get; init; }
/// <summary>
/// Returns true if at least one digest is present.
/// </summary>
[JsonIgnore]
public bool HasDigest => Sha256 is not null || Sha512 is not null || Sha1 is not null;
/// <summary>
/// Returns a dictionary representation for JSON serialization.
/// </summary>
public IReadOnlyDictionary<string, string> ToDictionary()
{
var dict = new Dictionary<string, string>(3);
if (Sha256 is not null) dict["sha256"] = Sha256;
if (Sha512 is not null) dict["sha512"] = Sha512;
if (Sha1 is not null) dict["sha1"] = Sha1;
return dict.ToFrozenDictionary();
}
/// <summary>
/// Creates digests from a dictionary.
/// </summary>
public static ArtifactDigests FromDictionary(IReadOnlyDictionary<string, string> dict)
{
ArgumentNullException.ThrowIfNull(dict);
return new ArtifactDigests
{
Sha256 = dict.GetValueOrDefault("sha256"),
Sha512 = dict.GetValueOrDefault("sha512"),
Sha1 = dict.GetValueOrDefault("sha1")
};
}
/// <summary>
/// Computes digests from a byte array.
/// </summary>
/// <param name="data">The data to hash.</param>
/// <param name="includeSha512">Whether to include SHA-512 digest.</param>
/// <param name="includeSha1">Whether to include SHA-1 digest (deprecated).</param>
public static ArtifactDigests Compute(ReadOnlySpan<byte> data, bool includeSha512 = true, bool includeSha1 = false)
{
var sha256 = Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
var sha512 = includeSha512 ? Convert.ToHexString(SHA512.HashData(data)).ToLowerInvariant() : null;
#pragma warning disable CA5350 // SHA-1 is weak but sometimes required for compatibility
var sha1 = includeSha1 ? Convert.ToHexString(SHA1.HashData(data)).ToLowerInvariant() : null;
#pragma warning restore CA5350
return new ArtifactDigests
{
Sha256 = sha256,
Sha512 = sha512,
Sha1 = sha1
};
}
/// <summary>
/// Computes digests from a stream.
/// </summary>
/// <param name="stream">The stream to hash.</param>
/// <param name="includeSha512">Whether to include SHA-512 digest.</param>
/// <param name="includeSha1">Whether to include SHA-1 digest (deprecated).</param>
/// <param name="cancellationToken">Cancellation token.</param>
public static async Task<ArtifactDigests> ComputeAsync(
Stream stream,
bool includeSha512 = true,
bool includeSha1 = false,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(stream);
// We need to read the stream multiple times for multiple hashes.
// If stream is seekable, reset position. Otherwise, read into memory.
byte[] data;
if (stream.CanSeek)
{
var position = stream.Position;
stream.Position = 0;
data = new byte[stream.Length];
await stream.ReadExactlyAsync(data, cancellationToken).ConfigureAwait(false);
stream.Position = position;
}
else
{
using var ms = new MemoryStream();
await stream.CopyToAsync(ms, cancellationToken).ConfigureAwait(false);
data = ms.ToArray();
}
return Compute(data, includeSha512, includeSha1);
}
/// <summary>
/// Computes digests from a file.
/// </summary>
/// <param name="filePath">Path to the file.</param>
/// <param name="includeSha512">Whether to include SHA-512 digest.</param>
/// <param name="includeSha1">Whether to include SHA-1 digest (deprecated).</param>
/// <param name="cancellationToken">Cancellation token.</param>
public static async Task<ArtifactDigests> ComputeFromFileAsync(
string filePath,
bool includeSha512 = true,
bool includeSha1 = false,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
var data = await File.ReadAllBytesAsync(filePath, cancellationToken).ConfigureAwait(false);
return Compute(data, includeSha512, includeSha1);
}
/// <summary>
/// Returns the primary digest (SHA-256 preferred, then SHA-512, then SHA-1).
/// </summary>
public string? GetPrimaryDigest() => Sha256 ?? Sha512 ?? Sha1;
/// <summary>
/// Returns the primary digest algorithm name.
/// </summary>
public string? GetPrimaryAlgorithm() =>
Sha256 is not null ? "sha256" :
Sha512 is not null ? "sha512" :
Sha1 is not null ? "sha1" : null;
}

View File

@@ -0,0 +1,73 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
namespace StellaOps.Attestor.Core.InToto;
/// <summary>
/// Extension interface for services that emit in-toto link attestations.
/// Implement this in scanner, build, or other services that want to
/// produce provenance attestations for their operations.
/// </summary>
/// <remarks>
/// Usage example:
/// <code>
/// public class MyScanService : IInTotoLinkEmitter
/// {
/// private readonly IInTotoLinkSigningService _linkSigner;
///
/// public async Task&lt;ScanResult&gt; ScanAsync(string imageRef, CancellationToken ct)
/// {
/// var materials = new[] { MaterialSpec.FromUri($"oci://{imageRef}") };
/// var sbomPath = Path.GetTempFileName();
/// var products = new[] { ProductSpec.WithLocalPath("file://sbom.cdx.json", sbomPath) };
///
/// var result = await _linkSigner.RecordAndSignStepAsync(
/// stepName: "scan",
/// action: () => PerformScanAsync(imageRef, sbomPath, ct),
/// materials: materials,
/// products: products,
/// options: new InTotoLinkSigningOptions { KeyId = "scanner-key" },
/// ct);
///
/// return new ScanResult { ProvenanceLink = result.Link, Envelope = result.Envelope };
/// }
/// }
/// </code>
/// </remarks>
public interface IInTotoLinkEmitter
{
/// <summary>
/// Gets whether this emitter supports in-toto link generation.
/// </summary>
bool SupportsInTotoLinks { get; }
/// <summary>
/// Gets the step names this emitter produces.
/// </summary>
IReadOnlyList<string> StepNames { get; }
}
/// <summary>
/// Extension methods for creating material and product specs.
/// </summary>
public static class InTotoSpecExtensions
{
/// <summary>
/// Creates a material spec from a URI with no local path (digest must be provided separately).
/// </summary>
public static MaterialSpec FromUri(string uri) => new() { Uri = uri };
/// <summary>
/// Creates a material spec from a URI with pre-computed digest.
/// </summary>
public static MaterialSpec FromUri(string uri, ArtifactDigests digest) => MaterialSpec.WithDigest(uri, digest);
/// <summary>
/// Creates a product spec from a URI with no local path.
/// </summary>
public static ProductSpec ProductFromUri(string uri) => new() { Uri = uri };
/// <summary>
/// Creates a product spec from a URI with pre-computed digest.
/// </summary>
public static ProductSpec ProductFromUri(string uri, ArtifactDigests digest) => ProductSpec.WithDigest(uri, digest);
}

View File

@@ -0,0 +1,158 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
namespace StellaOps.Attestor.Core.InToto;
/// <summary>
/// Service for signing in-toto links as DSSE envelopes.
/// Combines link generation with attestation signing for supply chain provenance.
/// </summary>
public interface IInTotoLinkSigningService
{
/// <summary>
/// Signs an in-toto link and optionally submits to transparency log.
/// </summary>
/// <param name="link">The in-toto link to sign.</param>
/// <param name="options">Signing options including key selection and Rekor preferences.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Signed link with DSSE envelope and optional Rekor entry.</returns>
Task<SignedInTotoLinkResult> SignLinkAsync(
InTotoLink link,
InTotoLinkSigningOptions options,
CancellationToken cancellationToken = default);
/// <summary>
/// Records a step and signs the resulting link in one operation.
/// </summary>
/// <param name="stepName">Name of the supply chain step.</param>
/// <param name="action">The action to execute.</param>
/// <param name="materials">Input materials.</param>
/// <param name="products">Output products.</param>
/// <param name="options">Signing options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Signed link with provenance metadata.</returns>
Task<SignedInTotoLinkResult> RecordAndSignStepAsync(
string stepName,
Func<Task<int>> action,
IEnumerable<MaterialSpec> materials,
IEnumerable<ProductSpec> products,
InTotoLinkSigningOptions options,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Options for signing in-toto links.
/// </summary>
public sealed record InTotoLinkSigningOptions
{
/// <summary>
/// Identifier of the signing key. If null, uses default key.
/// </summary>
public string? KeyId { get; init; }
/// <summary>
/// Signing mode (e.g., "kms", "keyless", "local").
/// </summary>
public string Mode { get; init; } = "kms";
/// <summary>
/// Whether to submit to Rekor transparency log.
/// </summary>
public bool SubmitToRekor { get; init; } = true;
/// <summary>
/// Preferred Rekor log ("primary", "mirror", "both").
/// </summary>
public string LogPreference { get; init; } = "primary";
/// <summary>
/// Whether to archive the signed attestation.
/// </summary>
public bool Archive { get; init; } = true;
/// <summary>
/// Caller subject for submission context (e.g., "system", user identity).
/// </summary>
public string? CallerSubject { get; init; }
/// <summary>
/// Caller audience for submission context.
/// </summary>
public string? CallerAudience { get; init; }
/// <summary>
/// Caller client ID for submission context.
/// </summary>
public string? CallerClientId { get; init; }
/// <summary>
/// Caller tenant for submission context.
/// </summary>
public string? CallerTenant { get; init; }
/// <summary>
/// Additional metadata to include in the submission.
/// </summary>
public IDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>();
}
/// <summary>
/// Result of signing an in-toto link.
/// </summary>
public sealed record SignedInTotoLinkResult
{
/// <summary>
/// The original in-toto link.
/// </summary>
public required InTotoLink Link { get; init; }
/// <summary>
/// The signed DSSE envelope.
/// </summary>
public required global::StellaOps.Attestor.Envelope.DsseEnvelope Envelope { get; init; }
/// <summary>
/// Key ID used for signing.
/// </summary>
public required string SignerKeyId { get; init; }
/// <summary>
/// Algorithm used for signing (e.g., "ECDSA-P256").
/// </summary>
public required string Algorithm { get; init; }
/// <summary>
/// Timestamp when signing occurred.
/// </summary>
public required DateTimeOffset SignedAt { get; init; }
/// <summary>
/// Rekor log entry reference, if submitted.
/// </summary>
public RekorEntryReference? RekorEntry { get; init; }
}
/// <summary>
/// Reference to a Rekor transparency log entry.
/// </summary>
public sealed record RekorEntryReference
{
/// <summary>
/// Log ID (Rekor instance identifier).
/// </summary>
public required string LogId { get; init; }
/// <summary>
/// Log index (monotonically increasing entry number).
/// </summary>
public required long LogIndex { get; init; }
/// <summary>
/// Entry UUID (hash-based identifier).
/// </summary>
public string? Uuid { get; init; }
/// <summary>
/// Timestamp when entry was integrated into log.
/// </summary>
public DateTimeOffset? IntegratedTime { get; init; }
}

View File

@@ -0,0 +1,60 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
namespace StellaOps.Attestor.Core.InToto;
/// <summary>
/// Records supply chain step execution as an in-toto link.
/// </summary>
/// <remarks>
/// <para>
/// Use this interface to capture materials (inputs), products (outputs),
/// and execution metadata for supply chain transparency and SLSA compliance.
/// </para>
/// <para>
/// Example usage:
/// <code>
/// var link = await recorder.RecordStepAsync(
/// stepName: "scan",
/// action: async () => { await PerformScan(); return 0; },
/// materials: [MaterialSpec.OciImage("nginx:1.25", imageDigest)],
/// products: [ProductSpec.File("/tmp/sbom.json")],
/// ct);
/// </code>
/// </para>
/// </remarks>
public interface ILinkRecorder
{
/// <summary>
/// Records a step execution and produces an in-toto link.
/// </summary>
/// <param name="stepName">Name of the step (e.g., "scan", "build", "sign").</param>
/// <param name="action">The action to execute. Returns the exit code.</param>
/// <param name="materials">Specifications for input artifacts.</param>
/// <param name="products">Specifications for output artifacts.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The recorded in-toto link.</returns>
Task<InTotoLink> RecordStepAsync(
string stepName,
Func<Task<int>> action,
IEnumerable<MaterialSpec> materials,
IEnumerable<ProductSpec> products,
CancellationToken cancellationToken = default);
/// <summary>
/// Records a step without executing an action (for external/pre-executed steps).
/// </summary>
/// <param name="stepName">Name of the step.</param>
/// <param name="command">The command that was executed.</param>
/// <param name="returnValue">The command's return value.</param>
/// <param name="materials">Specifications for input artifacts.</param>
/// <param name="products">Specifications for output artifacts.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The recorded in-toto link.</returns>
Task<InTotoLink> RecordExternalStepAsync(
string stepName,
IEnumerable<string> command,
int returnValue,
IEnumerable<MaterialSpec> materials,
IEnumerable<ProductSpec> products,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,169 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.Core.InToto;
/// <summary>
/// An in-toto link attestation that records supply chain step execution.
/// </summary>
/// <remarks>
/// <para>
/// An in-toto link is an authenticated record of a supply chain step's execution.
/// It captures what went in (materials), what came out (products), and how
/// the transformation happened (command, environment, byproducts).
/// </para>
/// <para>
/// This follows the in-toto attestation spec v1:
/// https://github.com/in-toto/attestation/blob/main/spec/predicates/link.md
/// </para>
/// </remarks>
public sealed record InTotoLink
{
/// <summary>
/// The in-toto statement type URI.
/// </summary>
public const string StatementType = "https://in-toto.io/Statement/v1";
/// <summary>
/// The in-toto link predicate type URI.
/// </summary>
public const string PredicateTypeUri = "https://in-toto.io/Link/v1";
/// <summary>
/// Subject artifacts (typically the products of this step).
/// </summary>
public required ImmutableArray<InTotoSubject> Subjects { get; init; }
/// <summary>
/// The link predicate containing step execution details.
/// </summary>
public required InTotoLinkPredicate Predicate { get; init; }
/// <summary>
/// Timestamp when the link was created (ISO 8601 UTC).
/// </summary>
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// Serializes to in-toto statement JSON.
/// </summary>
/// <param name="indented">Whether to indent the output.</param>
/// <returns>JSON string.</returns>
public string ToJson(bool indented = false)
{
var options = new JsonSerializerOptions
{
WriteIndented = indented,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
var statement = ToStatement();
return JsonSerializer.Serialize(statement, options);
}
/// <summary>
/// Converts to an in-toto statement object for serialization.
/// </summary>
public InTotoStatement ToStatement()
{
var subjects = Subjects.Select(s => new StatementSubject
{
Name = s.Name,
Digest = s.Digest.ToDictionary()
}).ToImmutableArray();
return new InTotoStatement
{
Type = StatementType,
Subject = subjects,
PredicateType = PredicateTypeUri,
Predicate = Predicate.ToJsonElement()
};
}
/// <summary>
/// Parses an in-toto link from JSON.
/// </summary>
/// <param name="json">The JSON string.</param>
/// <returns>The parsed link.</returns>
public static InTotoLink FromJson(string json)
{
ArgumentException.ThrowIfNullOrWhiteSpace(json);
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
var statement = JsonSerializer.Deserialize<InTotoStatement>(json, options)
?? throw new JsonException("Failed to deserialize in-toto statement");
if (statement.Type != StatementType)
{
throw new JsonException($"Invalid statement type: expected '{StatementType}', got '{statement.Type}'");
}
if (statement.PredicateType != PredicateTypeUri)
{
throw new JsonException($"Invalid predicate type: expected '{PredicateTypeUri}', got '{statement.PredicateType}'");
}
var subjects = statement.Subject
.Select(s => new InTotoSubject(s.Name, ArtifactDigests.FromDictionary(s.Digest)))
.ToImmutableArray();
var predicate = InTotoLinkPredicate.FromJsonElement(statement.Predicate);
return new InTotoLink
{
Subjects = subjects,
Predicate = predicate
};
}
/// <summary>
/// Returns the payload bytes for signing (UTF-8 encoded JSON).
/// </summary>
public byte[] GetPayloadBytes() => System.Text.Encoding.UTF8.GetBytes(ToJson(indented: false));
}
/// <summary>
/// A subject in an in-toto statement.
/// </summary>
public sealed record InTotoSubject(
string Name,
ArtifactDigests Digest);
/// <summary>
/// In-toto statement representation for JSON serialization.
/// </summary>
public sealed record InTotoStatement
{
[JsonPropertyName("_type")]
public required string Type { get; init; }
[JsonPropertyName("subject")]
public required ImmutableArray<StatementSubject> Subject { get; init; }
[JsonPropertyName("predicateType")]
public required string PredicateType { get; init; }
[JsonPropertyName("predicate")]
public required JsonElement Predicate { get; init; }
}
/// <summary>
/// Subject representation for JSON serialization.
/// </summary>
public sealed record StatementSubject
{
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("digest")]
public required IReadOnlyDictionary<string, string> Digest { get; init; }
}

View File

@@ -0,0 +1,242 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
using System.Collections.Frozen;
using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.Core.InToto;
/// <summary>
/// The predicate portion of an in-toto link attestation.
/// </summary>
/// <remarks>
/// Contains the step name, command executed, materials (inputs),
/// products (outputs), byproducts (logs, return code), and environment.
/// </remarks>
public sealed record InTotoLinkPredicate
{
/// <summary>
/// The name of the supply chain step (e.g., "build", "scan", "sign").
/// </summary>
public required string Name { get; init; }
/// <summary>
/// The command that was executed (can be empty for external steps).
/// </summary>
public ImmutableArray<string> Command { get; init; } = [];
/// <summary>
/// Materials (inputs) consumed by this step.
/// </summary>
public ImmutableArray<InTotoMaterial> Materials { get; init; } = [];
/// <summary>
/// Products (outputs) produced by this step.
/// </summary>
public ImmutableArray<InTotoProduct> Products { get; init; } = [];
/// <summary>
/// Byproducts of the step execution (return value, stdout, stderr).
/// </summary>
public InTotoByProducts ByProducts { get; init; } = new();
/// <summary>
/// Environment variables and context for reproducibility.
/// </summary>
public ImmutableDictionary<string, string> Environment { get; init; } =
ImmutableDictionary<string, string>.Empty;
/// <summary>
/// Converts to a JsonElement for embedding in the statement.
/// </summary>
internal JsonElement ToJsonElement()
{
var options = new JsonSerializerOptions
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
var obj = new PredicateJson
{
Name = Name,
Command = Command.IsDefaultOrEmpty ? null : Command,
Materials = Materials.IsDefaultOrEmpty ? null : Materials.Select(m => m.ToJsonObject()).ToImmutableArray(),
Products = Products.IsDefaultOrEmpty ? null : Products.Select(p => p.ToJsonObject()).ToImmutableArray(),
ByProducts = ByProducts.ToJsonObject(),
Environment = Environment.IsEmpty ? null : Environment.ToFrozenDictionary()
};
var json = JsonSerializer.Serialize(obj, options);
return JsonDocument.Parse(json).RootElement.Clone();
}
/// <summary>
/// Parses from a JsonElement.
/// </summary>
internal static InTotoLinkPredicate FromJsonElement(JsonElement element)
{
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
var obj = JsonSerializer.Deserialize<PredicateJson>(element.GetRawText(), options)
?? throw new JsonException("Failed to deserialize predicate");
return new InTotoLinkPredicate
{
Name = obj.Name ?? throw new JsonException("Missing required field: name"),
Command = obj.Command ?? [],
Materials = obj.Materials?.Select(m => InTotoMaterial.FromJsonObject(m)).ToImmutableArray() ?? [],
Products = obj.Products?.Select(p => InTotoProduct.FromJsonObject(p)).ToImmutableArray() ?? [],
ByProducts = obj.ByProducts is not null ? InTotoByProducts.FromJsonObject(obj.ByProducts) : new(),
Environment = obj.Environment?.ToImmutableDictionary() ?? ImmutableDictionary<string, string>.Empty
};
}
private sealed record PredicateJson
{
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("command")]
public ImmutableArray<string>? Command { get; init; }
[JsonPropertyName("materials")]
public ImmutableArray<MaterialJson>? Materials { get; init; }
[JsonPropertyName("products")]
public ImmutableArray<ProductJson>? Products { get; init; }
[JsonPropertyName("byproducts")]
public ByProductsJson? ByProducts { get; init; }
[JsonPropertyName("environment")]
public IReadOnlyDictionary<string, string>? Environment { get; init; }
}
}
/// <summary>
/// A material (input artifact) in an in-toto link.
/// </summary>
public sealed record InTotoMaterial
{
/// <summary>
/// URI identifying the material (e.g., "oci://...", "file://...", "git://...").
/// </summary>
public required string Uri { get; init; }
/// <summary>
/// Cryptographic digests of the material.
/// </summary>
public required ArtifactDigests Digest { get; init; }
internal MaterialJson ToJsonObject() => new()
{
Uri = Uri,
Digest = Digest.ToDictionary()
};
internal static InTotoMaterial FromJsonObject(MaterialJson obj) => new()
{
Uri = obj.Uri ?? throw new JsonException("Missing required field: uri"),
Digest = obj.Digest is not null ? ArtifactDigests.FromDictionary(obj.Digest) : new()
};
}
internal sealed record MaterialJson
{
[JsonPropertyName("uri")]
public string? Uri { get; init; }
[JsonPropertyName("digest")]
public IReadOnlyDictionary<string, string>? Digest { get; init; }
}
/// <summary>
/// A product (output artifact) in an in-toto link.
/// </summary>
public sealed record InTotoProduct
{
/// <summary>
/// URI identifying the product (e.g., "file://sbom.json").
/// </summary>
public required string Uri { get; init; }
/// <summary>
/// Cryptographic digests of the product.
/// </summary>
public required ArtifactDigests Digest { get; init; }
internal ProductJson ToJsonObject() => new()
{
Uri = Uri,
Digest = Digest.ToDictionary()
};
internal static InTotoProduct FromJsonObject(ProductJson obj) => new()
{
Uri = obj.Uri ?? throw new JsonException("Missing required field: uri"),
Digest = obj.Digest is not null ? ArtifactDigests.FromDictionary(obj.Digest) : new()
};
}
internal sealed record ProductJson
{
[JsonPropertyName("uri")]
public string? Uri { get; init; }
[JsonPropertyName("digest")]
public IReadOnlyDictionary<string, string>? Digest { get; init; }
}
/// <summary>
/// Byproducts of step execution (return value, stdout, stderr).
/// </summary>
public sealed record InTotoByProducts
{
/// <summary>
/// The return value of the command (0 = success).
/// </summary>
public int ReturnValue { get; init; }
/// <summary>
/// Standard output captured during execution (may be truncated).
/// </summary>
public string? Stdout { get; init; }
/// <summary>
/// Standard error captured during execution (may be truncated).
/// </summary>
public string? Stderr { get; init; }
internal ByProductsJson ToJsonObject() => new()
{
ReturnValue = ReturnValue,
Stdout = Stdout,
Stderr = Stderr
};
internal static InTotoByProducts FromJsonObject(ByProductsJson obj) => new()
{
ReturnValue = obj.ReturnValue,
Stdout = obj.Stdout,
Stderr = obj.Stderr
};
}
internal sealed record ByProductsJson
{
[JsonPropertyName("return-value")]
public int ReturnValue { get; init; }
[JsonPropertyName("stdout")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Stdout { get; init; }
[JsonPropertyName("stderr")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Stderr { get; init; }
}

View File

@@ -0,0 +1,269 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
using System.Collections.Immutable;
using StellaOps.Attestor.Envelope;
namespace StellaOps.Attestor.Core.InToto.Layout;
/// <summary>
/// Verifies in-toto link chains against layouts.
/// </summary>
public interface ILayoutVerifier
{
/// <summary>
/// Verifies that the provided links satisfy the layout constraints.
/// </summary>
/// <param name="layout">The layout defining required steps and rules.</param>
/// <param name="links">The signed links to verify.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Verification result with details.</returns>
Task<LayoutVerificationResult> VerifyAsync(
InTotoLayout layout,
IEnumerable<SignedLink> links,
CancellationToken cancellationToken = default);
}
/// <summary>
/// A signed in-toto link with its DSSE envelope.
/// </summary>
public sealed record SignedLink
{
/// <summary>
/// The in-toto link.
/// </summary>
public required InTotoLink Link { get; init; }
/// <summary>
/// The DSSE envelope containing the signed link.
/// </summary>
public required global::StellaOps.Attestor.Envelope.DsseEnvelope Envelope { get; init; }
/// <summary>
/// The key ID of the signer.
/// </summary>
public required string SignerKeyId { get; init; }
/// <summary>
/// Whether the signature has been cryptographically verified.
/// </summary>
public bool SignatureVerified { get; init; }
}
/// <summary>
/// Result of layout verification.
/// </summary>
public sealed record LayoutVerificationResult
{
/// <summary>
/// Whether verification succeeded (no errors).
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// Violations found during verification.
/// </summary>
public ImmutableArray<LayoutViolation> Violations { get; init; } = [];
/// <summary>
/// Steps that were successfully verified.
/// </summary>
public ImmutableArray<string> VerifiedSteps { get; init; } = [];
/// <summary>
/// Mapping of step names to functionary key IDs that signed them.
/// </summary>
public ImmutableDictionary<string, ImmutableArray<string>> StepToFunctionaries { get; init; } =
ImmutableDictionary<string, ImmutableArray<string>>.Empty;
/// <summary>
/// Warnings (non-fatal issues).
/// </summary>
public ImmutableArray<string> Warnings { get; init; } = [];
/// <summary>
/// Creates a successful result.
/// </summary>
public static LayoutVerificationResult Succeeded(
ImmutableArray<string> verifiedSteps,
ImmutableDictionary<string, ImmutableArray<string>>? stepToFunctionaries = null,
ImmutableArray<string>? warnings = null) => new()
{
Success = true,
VerifiedSteps = verifiedSteps,
StepToFunctionaries = stepToFunctionaries ?? ImmutableDictionary<string, ImmutableArray<string>>.Empty,
Warnings = warnings ?? []
};
/// <summary>
/// Creates a failed result.
/// </summary>
public static LayoutVerificationResult Failed(
ImmutableArray<LayoutViolation> violations,
ImmutableArray<string>? verifiedSteps = null,
ImmutableDictionary<string, ImmutableArray<string>>? stepToFunctionaries = null) => new()
{
Success = false,
Violations = violations,
VerifiedSteps = verifiedSteps ?? [],
StepToFunctionaries = stepToFunctionaries ?? ImmutableDictionary<string, ImmutableArray<string>>.Empty
};
}
/// <summary>
/// A violation found during layout verification.
/// </summary>
public sealed record LayoutViolation
{
/// <summary>
/// The step where the violation occurred (null for layout-level violations).
/// </summary>
public string? StepName { get; init; }
/// <summary>
/// The type of violation.
/// </summary>
public required LayoutViolationType Type { get; init; }
/// <summary>
/// Human-readable description of the violation.
/// </summary>
public required string Message { get; init; }
/// <summary>
/// Additional details (e.g., expected vs. actual values).
/// </summary>
public string? Details { get; init; }
/// <summary>
/// Creates a missing step violation.
/// </summary>
public static LayoutViolation MissingStep(string stepName) => new()
{
StepName = stepName,
Type = LayoutViolationType.MissingStep,
Message = $"Required step '{stepName}' is missing"
};
/// <summary>
/// Creates an unauthorized functionary violation.
/// </summary>
public static LayoutViolation UnauthorizedFunctionary(string stepName, string keyId, IEnumerable<string> authorizedKeyIds) => new()
{
StepName = stepName,
Type = LayoutViolationType.UnauthorizedFunctionary,
Message = $"Step '{stepName}' signed by unauthorized key '{keyId}'",
Details = $"Authorized keys: {string.Join(", ", authorizedKeyIds)}"
};
/// <summary>
/// Creates an invalid signature violation.
/// </summary>
public static LayoutViolation InvalidSignature(string stepName, string keyId) => new()
{
StepName = stepName,
Type = LayoutViolationType.InvalidSignature,
Message = $"Invalid signature on step '{stepName}' from key '{keyId}'"
};
/// <summary>
/// Creates a material mismatch violation.
/// </summary>
public static LayoutViolation MaterialMismatch(string stepName, string uri, string rule, string? details = null) => new()
{
StepName = stepName,
Type = LayoutViolationType.MaterialMismatch,
Message = $"Material '{uri}' violates rule '{rule}' in step '{stepName}'",
Details = details
};
/// <summary>
/// Creates a product mismatch violation.
/// </summary>
public static LayoutViolation ProductMismatch(string stepName, string uri, string rule, string? details = null) => new()
{
StepName = stepName,
Type = LayoutViolationType.ProductMismatch,
Message = $"Product '{uri}' violates rule '{rule}' in step '{stepName}'",
Details = details
};
/// <summary>
/// Creates a threshold not met violation.
/// </summary>
public static LayoutViolation ThresholdNotMet(string stepName, int required, int actual) => new()
{
StepName = stepName,
Type = LayoutViolationType.ThresholdNotMet,
Message = $"Step '{stepName}' requires {required} signatures but only has {actual}"
};
/// <summary>
/// Creates a step order violation.
/// </summary>
public static LayoutViolation StepOrderViolation(string stepName, string requiredPreviousStep) => new()
{
StepName = stepName,
Type = LayoutViolationType.StepOrderViolation,
Message = $"Step '{stepName}' requires '{requiredPreviousStep}' to complete first"
};
/// <summary>
/// Creates an expired layout violation.
/// </summary>
public static LayoutViolation LayoutExpired(DateTimeOffset expiration) => new()
{
Type = LayoutViolationType.LayoutExpired,
Message = $"Layout expired at {expiration:O}"
};
}
/// <summary>
/// Types of layout violations.
/// </summary>
public enum LayoutViolationType
{
/// <summary>
/// A required step is missing from the link chain.
/// </summary>
MissingStep,
/// <summary>
/// Step was signed by an unauthorized functionary.
/// </summary>
UnauthorizedFunctionary,
/// <summary>
/// Signature verification failed.
/// </summary>
InvalidSignature,
/// <summary>
/// Material doesn't match expected rule.
/// </summary>
MaterialMismatch,
/// <summary>
/// Product doesn't match expected rule.
/// </summary>
ProductMismatch,
/// <summary>
/// Signature threshold not met.
/// </summary>
ThresholdNotMet,
/// <summary>
/// Steps executed in wrong order.
/// </summary>
StepOrderViolation,
/// <summary>
/// Layout has expired.
/// </summary>
LayoutExpired,
/// <summary>
/// Command doesn't match expected command.
/// </summary>
CommandMismatch
}

View File

@@ -0,0 +1,266 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.Core.InToto.Layout;
/// <summary>
/// An in-toto layout defines the expected supply chain steps, functionaries, and artifact rules.
/// </summary>
/// <remarks>
/// Layouts are signed by project owners and specify:
/// - Required steps and their expected functionaries (who can sign each step)
/// - Artifact rules (MATCH, ALLOW, DISALLOW patterns)
/// - Key threshold requirements
/// - Expiration dates
/// </remarks>
public sealed record InTotoLayout
{
/// <summary>
/// The in-toto layout type URI.
/// </summary>
public const string LayoutType = "https://in-toto.io/Layout/v1";
/// <summary>
/// Unique identifier for this layout.
/// </summary>
public required string Id { get; init; }
/// <summary>
/// Human-readable name for the layout.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Description of what this layout validates.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Layout expiration date (UTC).
/// </summary>
public DateTimeOffset? Expires { get; init; }
/// <summary>
/// Steps required by this layout.
/// </summary>
public ImmutableArray<LayoutStep> Steps { get; init; } = [];
/// <summary>
/// Keys authorized to sign the layout and steps.
/// </summary>
public ImmutableArray<LayoutKey> Keys { get; init; } = [];
/// <summary>
/// Root material rules (what materials the first step can consume).
/// </summary>
public ImmutableArray<ArtifactRule> RootMaterialRules { get; init; } = [];
/// <summary>
/// Final product rules (what products the last step must produce).
/// </summary>
public ImmutableArray<ArtifactRule> FinalProductRules { get; init; } = [];
/// <summary>
/// Whether to allow custom metadata in links (default: true).
/// </summary>
public bool AllowCustomMetadata { get; init; } = true;
/// <summary>
/// Serializes to JSON.
/// </summary>
public string ToJson(bool indented = false)
{
var options = new JsonSerializerOptions
{
WriteIndented = indented,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
return JsonSerializer.Serialize(this, options);
}
/// <summary>
/// Parses from JSON.
/// </summary>
public static InTotoLayout FromJson(string json)
{
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
return JsonSerializer.Deserialize<InTotoLayout>(json, options)
?? throw new JsonException("Failed to deserialize layout");
}
}
/// <summary>
/// A step in an in-toto layout.
/// </summary>
public sealed record LayoutStep
{
/// <summary>
/// Name of the step (must match step name in links).
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Human-readable description of the step.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Key IDs of functionaries authorized to sign this step.
/// At least one signature from an authorized functionary is required.
/// </summary>
public ImmutableArray<string> AuthorizedKeyIds { get; init; } = [];
/// <summary>
/// Minimum number of unique signatures required for this step.
/// Default is 1.
/// </summary>
public int Threshold { get; init; } = 1;
/// <summary>
/// Expected command (optional). If set, link command must match.
/// </summary>
public ImmutableArray<string>? ExpectedCommand { get; init; }
/// <summary>
/// Rules for validating materials (inputs) of this step.
/// </summary>
public ImmutableArray<ArtifactRule> MaterialRules { get; init; } = [];
/// <summary>
/// Rules for validating products (outputs) of this step.
/// </summary>
public ImmutableArray<ArtifactRule> ProductRules { get; init; } = [];
/// <summary>
/// Steps that must complete before this step (ordering constraint).
/// </summary>
public ImmutableArray<string> RequiresPreviousSteps { get; init; } = [];
}
/// <summary>
/// A key authorized in the layout.
/// </summary>
public sealed record LayoutKey
{
/// <summary>
/// Unique key ID (typically SHA-256 of public key).
/// </summary>
public required string KeyId { get; init; }
/// <summary>
/// The public key in PEM format.
/// </summary>
public required string PublicKeyPem { get; init; }
/// <summary>
/// Key type (e.g., "ed25519", "ecdsa-p256", "rsa").
/// </summary>
public string? KeyType { get; init; }
/// <summary>
/// Human-readable description of the key owner.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Steps this key is authorized to sign. If empty, key can sign any step.
/// </summary>
public ImmutableArray<string> AllowedSteps { get; init; } = [];
}
/// <summary>
/// An artifact rule for validating materials or products.
/// </summary>
public sealed record ArtifactRule
{
/// <summary>
/// The rule type.
/// </summary>
public required ArtifactRuleType Type { get; init; }
/// <summary>
/// Pattern to match artifact URIs (supports * and ** wildcards).
/// </summary>
public required string Pattern { get; init; }
/// <summary>
/// For MATCH rules: the step name whose products must match.
/// </summary>
public string? FromStep { get; init; }
/// <summary>
/// For MATCH rules: the attribute to match ("products" or "materials").
/// </summary>
public string? FromAttribute { get; init; } = "products";
/// <summary>
/// Creates an ALLOW rule.
/// </summary>
public static ArtifactRule Allow(string pattern) => new()
{
Type = ArtifactRuleType.Allow,
Pattern = pattern
};
/// <summary>
/// Creates a DISALLOW rule.
/// </summary>
public static ArtifactRule Disallow(string pattern) => new()
{
Type = ArtifactRuleType.Disallow,
Pattern = pattern
};
/// <summary>
/// Creates a MATCH rule (artifacts must match products from another step).
/// </summary>
public static ArtifactRule Match(string pattern, string fromStep, string fromAttribute = "products") => new()
{
Type = ArtifactRuleType.Match,
Pattern = pattern,
FromStep = fromStep,
FromAttribute = fromAttribute
};
/// <summary>
/// Creates a REQUIRE rule (artifact must exist).
/// </summary>
public static ArtifactRule Require(string pattern) => new()
{
Type = ArtifactRuleType.Require,
Pattern = pattern
};
}
/// <summary>
/// Types of artifact rules.
/// </summary>
public enum ArtifactRuleType
{
/// <summary>
/// Allow artifacts matching the pattern.
/// </summary>
Allow,
/// <summary>
/// Disallow artifacts matching the pattern.
/// </summary>
Disallow,
/// <summary>
/// Materials must match products from a previous step.
/// </summary>
Match,
/// <summary>
/// Require at least one artifact matching the pattern.
/// </summary>
Require
}

View File

@@ -0,0 +1,408 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
using System.Collections.Immutable;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
namespace StellaOps.Attestor.Core.InToto.Layout;
/// <summary>
/// Implementation of <see cref="ILayoutVerifier"/> that verifies in-toto link chains against layouts.
/// </summary>
public sealed partial class LayoutVerifier : ILayoutVerifier
{
private readonly ILogger<LayoutVerifier> _logger;
private readonly TimeProvider _timeProvider;
/// <summary>
/// Initializes a new instance of the <see cref="LayoutVerifier"/> class.
/// </summary>
public LayoutVerifier(ILogger<LayoutVerifier> logger, TimeProvider timeProvider)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
/// <inheritdoc/>
public Task<LayoutVerificationResult> VerifyAsync(
InTotoLayout layout,
IEnumerable<SignedLink> links,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(links);
_logger.LogDebug("Verifying {LinkCount} links against layout '{LayoutId}'",
links.Count(), layout.Id);
var violations = new List<LayoutViolation>();
var warnings = new List<string>();
var verifiedSteps = new List<string>();
var stepToFunctionaries = new Dictionary<string, List<string>>();
// Check layout expiration
if (layout.Expires.HasValue && layout.Expires.Value < _timeProvider.GetUtcNow())
{
violations.Add(LayoutViolation.LayoutExpired(layout.Expires.Value));
return Task.FromResult(LayoutVerificationResult.Failed([.. violations]));
}
// Build key lookup
var keyLookup = layout.Keys.ToDictionary(k => k.KeyId, StringComparer.OrdinalIgnoreCase);
// Group links by step name
var linksByStep = links
.GroupBy(l => l.Link.Predicate.Name, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
// Verify each required step
foreach (var step in layout.Steps)
{
var stepViolations = VerifyStep(step, linksByStep, keyLookup, layout);
violations.AddRange(stepViolations);
if (stepViolations.Count == 0)
{
verifiedSteps.Add(step.Name);
// Record functionaries
if (linksByStep.TryGetValue(step.Name, out var stepLinks))
{
stepToFunctionaries[step.Name] = stepLinks.Select(l => l.SignerKeyId).ToList();
}
}
}
// Verify step ordering
var orderViolations = VerifyStepOrder(layout.Steps, linksByStep);
violations.AddRange(orderViolations);
// Verify material/product chains
var chainViolations = VerifyArtifactChains(layout, linksByStep);
violations.AddRange(chainViolations);
// Check for unexpected steps
foreach (var stepName in linksByStep.Keys)
{
if (!layout.Steps.Any(s => s.Name.Equals(stepName, StringComparison.OrdinalIgnoreCase)))
{
warnings.Add($"Unexpected step '{stepName}' not defined in layout");
}
}
var result = violations.Count == 0
? LayoutVerificationResult.Succeeded(
[.. verifiedSteps],
stepToFunctionaries.ToImmutableDictionary(
kvp => kvp.Key,
kvp => kvp.Value.ToImmutableArray()),
[.. warnings])
: LayoutVerificationResult.Failed(
[.. violations],
[.. verifiedSteps],
stepToFunctionaries.ToImmutableDictionary(
kvp => kvp.Key,
kvp => kvp.Value.ToImmutableArray()));
_logger.LogInformation(
"Layout verification {Result}: {VerifiedCount}/{TotalSteps} steps verified, {ViolationCount} violations",
result.Success ? "succeeded" : "failed",
verifiedSteps.Count,
layout.Steps.Length,
violations.Count);
return Task.FromResult(result);
}
private List<LayoutViolation> VerifyStep(
LayoutStep step,
Dictionary<string, List<SignedLink>> linksByStep,
Dictionary<string, LayoutKey> keyLookup,
InTotoLayout layout)
{
var violations = new List<LayoutViolation>();
// Check if step exists
if (!linksByStep.TryGetValue(step.Name, out var stepLinks) || stepLinks.Count == 0)
{
violations.Add(LayoutViolation.MissingStep(step.Name));
return violations;
}
// Count unique valid signatures
var validSigners = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var signedLink in stepLinks)
{
// Check signature verification
if (!signedLink.SignatureVerified)
{
violations.Add(LayoutViolation.InvalidSignature(step.Name, signedLink.SignerKeyId));
continue;
}
// Check if signer is authorized
if (step.AuthorizedKeyIds.Length > 0)
{
var isAuthorized = step.AuthorizedKeyIds.Any(
k => k.Equals(signedLink.SignerKeyId, StringComparison.OrdinalIgnoreCase));
if (!isAuthorized)
{
// Also check if key has step in AllowedSteps
if (keyLookup.TryGetValue(signedLink.SignerKeyId, out var key))
{
isAuthorized = key.AllowedSteps.Length == 0 ||
key.AllowedSteps.Any(s => s.Equals(step.Name, StringComparison.OrdinalIgnoreCase));
}
if (!isAuthorized)
{
violations.Add(LayoutViolation.UnauthorizedFunctionary(
step.Name, signedLink.SignerKeyId, step.AuthorizedKeyIds));
continue;
}
}
}
validSigners.Add(signedLink.SignerKeyId);
// Verify command if specified
if (step.ExpectedCommand is { Length: > 0 } expectedCmd)
{
var actualCmd = signedLink.Link.Predicate.Command;
if (!expectedCmd.SequenceEqual(actualCmd))
{
violations.Add(new LayoutViolation
{
StepName = step.Name,
Type = LayoutViolationType.CommandMismatch,
Message = $"Step '{step.Name}' command doesn't match expected",
Details = $"Expected: [{string.Join(", ", expectedCmd)}], Actual: [{string.Join(", ", actualCmd)}]"
});
}
}
// Verify material rules
foreach (var rule in step.MaterialRules)
{
var materialViolations = VerifyArtifactRule(
rule, signedLink.Link.Predicate.Materials.Select(m => (m.Uri, m.Digest)),
step.Name, "material", linksByStep);
violations.AddRange(materialViolations);
}
// Verify product rules
foreach (var rule in step.ProductRules)
{
var productViolations = VerifyArtifactRule(
rule, signedLink.Link.Predicate.Products.Select(p => (p.Uri, p.Digest)),
step.Name, "product", linksByStep);
violations.AddRange(productViolations);
}
}
// Check threshold
if (validSigners.Count < step.Threshold)
{
violations.Add(LayoutViolation.ThresholdNotMet(step.Name, step.Threshold, validSigners.Count));
}
return violations;
}
private List<LayoutViolation> VerifyArtifactRule(
ArtifactRule rule,
IEnumerable<(string Uri, ArtifactDigests Digest)> artifacts,
string stepName,
string artifactType,
Dictionary<string, List<SignedLink>> linksByStep)
{
var violations = new List<LayoutViolation>();
var regex = WildcardToRegex(rule.Pattern);
var matchingArtifacts = artifacts.Where(a => regex.IsMatch(a.Uri)).ToList();
switch (rule.Type)
{
case ArtifactRuleType.Require:
if (matchingArtifacts.Count == 0)
{
var violation = artifactType == "material"
? LayoutViolation.MaterialMismatch(stepName, rule.Pattern, "REQUIRE", "No matching artifacts found")
: LayoutViolation.ProductMismatch(stepName, rule.Pattern, "REQUIRE", "No matching artifacts found");
violations.Add(violation);
}
break;
case ArtifactRuleType.Disallow:
foreach (var artifact in matchingArtifacts)
{
var violation = artifactType == "material"
? LayoutViolation.MaterialMismatch(stepName, artifact.Uri, "DISALLOW")
: LayoutViolation.ProductMismatch(stepName, artifact.Uri, "DISALLOW");
violations.Add(violation);
}
break;
case ArtifactRuleType.Match:
if (rule.FromStep is not null && linksByStep.TryGetValue(rule.FromStep, out var fromLinks))
{
var fromArtifacts = rule.FromAttribute?.ToLowerInvariant() switch
{
"materials" => fromLinks.SelectMany(l => l.Link.Predicate.Materials.Select(m => (m.Uri, m.Digest))),
_ => fromLinks.SelectMany(l => l.Link.Predicate.Products.Select(p => (p.Uri, p.Digest)))
};
var fromDigests = fromArtifacts
.Where(a => regex.IsMatch(a.Uri))
.ToDictionary(a => a.Uri, a => a.Digest, StringComparer.OrdinalIgnoreCase);
foreach (var artifact in matchingArtifacts)
{
if (!fromDigests.TryGetValue(artifact.Uri, out var expectedDigest))
{
// Try matching by pattern
var matched = false;
foreach (var (fromUri, fromDig) in fromDigests)
{
if (DigestsMatch(artifact.Digest, fromDig))
{
matched = true;
break;
}
}
if (!matched)
{
var violation = artifactType == "material"
? LayoutViolation.MaterialMismatch(stepName, artifact.Uri, $"MATCH from {rule.FromStep}", "Artifact not found in source step")
: LayoutViolation.ProductMismatch(stepName, artifact.Uri, $"MATCH from {rule.FromStep}", "Artifact not found in source step");
violations.Add(violation);
}
}
else if (!DigestsMatch(artifact.Digest, expectedDigest))
{
var violation = artifactType == "material"
? LayoutViolation.MaterialMismatch(stepName, artifact.Uri, $"MATCH from {rule.FromStep}", "Digest mismatch")
: LayoutViolation.ProductMismatch(stepName, artifact.Uri, $"MATCH from {rule.FromStep}", "Digest mismatch");
violations.Add(violation);
}
}
}
break;
}
return violations;
}
private static bool DigestsMatch(ArtifactDigests a, ArtifactDigests b)
{
// Match if any common digest algorithm matches
if (a.Sha256 is not null && b.Sha256 is not null)
return a.Sha256.Equals(b.Sha256, StringComparison.OrdinalIgnoreCase);
if (a.Sha512 is not null && b.Sha512 is not null)
return a.Sha512.Equals(b.Sha512, StringComparison.OrdinalIgnoreCase);
if (a.Sha1 is not null && b.Sha1 is not null)
return a.Sha1.Equals(b.Sha1, StringComparison.OrdinalIgnoreCase);
return false;
}
private List<LayoutViolation> VerifyStepOrder(
ImmutableArray<LayoutStep> steps,
Dictionary<string, List<SignedLink>> linksByStep)
{
var violations = new List<LayoutViolation>();
// Build completion times from link timestamps
var stepCompletionTimes = new Dictionary<string, DateTimeOffset>(StringComparer.OrdinalIgnoreCase);
foreach (var (stepName, stepLinks) in linksByStep)
{
if (stepLinks.Count > 0)
{
// Use earliest completion time
stepCompletionTimes[stepName] = stepLinks.Min(l => l.Link.CreatedAt);
}
}
foreach (var step in steps)
{
if (!stepCompletionTimes.TryGetValue(step.Name, out var currentTime))
continue;
foreach (var requiredStep in step.RequiresPreviousSteps)
{
if (!stepCompletionTimes.TryGetValue(requiredStep, out var requiredTime))
{
violations.Add(LayoutViolation.StepOrderViolation(step.Name, requiredStep));
}
else if (requiredTime >= currentTime)
{
violations.Add(LayoutViolation.StepOrderViolation(step.Name, requiredStep));
}
}
}
return violations;
}
private List<LayoutViolation> VerifyArtifactChains(
InTotoLayout layout,
Dictionary<string, List<SignedLink>> linksByStep)
{
var violations = new List<LayoutViolation>();
// Verify root material rules (for first step)
if (layout.Steps.Length > 0 && layout.RootMaterialRules.Length > 0)
{
var firstStep = layout.Steps[0];
if (linksByStep.TryGetValue(firstStep.Name, out var firstLinks))
{
var materials = firstLinks
.SelectMany(l => l.Link.Predicate.Materials)
.Select(m => (m.Uri, m.Digest));
foreach (var rule in layout.RootMaterialRules)
{
var ruleViolations = VerifyArtifactRule(rule, materials, firstStep.Name, "material", linksByStep);
violations.AddRange(ruleViolations);
}
}
}
// Verify final product rules (for last step)
if (layout.Steps.Length > 0 && layout.FinalProductRules.Length > 0)
{
var lastStep = layout.Steps[^1];
if (linksByStep.TryGetValue(lastStep.Name, out var lastLinks))
{
var products = lastLinks
.SelectMany(l => l.Link.Predicate.Products)
.Select(p => (p.Uri, p.Digest));
foreach (var rule in layout.FinalProductRules)
{
var ruleViolations = VerifyArtifactRule(rule, products, lastStep.Name, "product", linksByStep);
violations.AddRange(ruleViolations);
}
}
}
return violations;
}
private static Regex WildcardToRegex(string pattern)
{
// Convert glob-style wildcards to regex
// ** = match any characters including /
// * = match any characters except /
var escaped = Regex.Escape(pattern)
.Replace(@"\*\*", ".*")
.Replace(@"\*", "[^/]*");
return new Regex($"^{escaped}$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
}
}

View File

@@ -0,0 +1,293 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
using System.Collections.Immutable;
namespace StellaOps.Attestor.Core.InToto;
/// <summary>
/// Fluent builder for constructing in-toto links.
/// </summary>
/// <remarks>
/// <para>
/// Use this builder when you need fine-grained control over link construction,
/// or when you're building links from pre-computed data rather than recording
/// step execution in real-time.
/// </para>
/// <para>
/// Example:
/// <code>
/// var link = new LinkBuilder("scan")
/// .AddMaterial("oci://nginx:1.25", digest)
/// .AddProduct("file://sbom.json", sbomDigest)
/// .WithCommand("stella", "scan", "--image", "nginx:1.25")
/// .WithReturnValue(0)
/// .WithEnvironment("CI", "true")
/// .Build();
/// </code>
/// </para>
/// </remarks>
public sealed class LinkBuilder
{
private readonly string _stepName;
private readonly List<InTotoMaterial> _materials = [];
private readonly List<InTotoProduct> _products = [];
private readonly List<string> _command = [];
private readonly Dictionary<string, string> _environment = [];
private int _returnValue;
private string? _stdout;
private string? _stderr;
private DateTimeOffset? _createdAt;
/// <summary>
/// Initializes a new link builder for the specified step.
/// </summary>
/// <param name="stepName">The name of the supply chain step.</param>
public LinkBuilder(string stepName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(stepName);
_stepName = stepName;
}
/// <summary>
/// Adds a material (input artifact) to the link.
/// </summary>
/// <param name="uri">URI identifying the material.</param>
/// <param name="digest">Cryptographic digests of the material.</param>
/// <returns>This builder for chaining.</returns>
public LinkBuilder AddMaterial(string uri, ArtifactDigests digest)
{
ArgumentException.ThrowIfNullOrWhiteSpace(uri);
ArgumentNullException.ThrowIfNull(digest);
_materials.Add(new InTotoMaterial { Uri = uri, Digest = digest });
return this;
}
/// <summary>
/// Adds a material with SHA-256 digest.
/// </summary>
public LinkBuilder AddMaterial(string uri, string sha256Digest)
{
return AddMaterial(uri, new ArtifactDigests { Sha256 = sha256Digest.ToLowerInvariant() });
}
/// <summary>
/// Adds multiple materials to the link.
/// </summary>
public LinkBuilder AddMaterials(IEnumerable<InTotoMaterial> materials)
{
ArgumentNullException.ThrowIfNull(materials);
_materials.AddRange(materials);
return this;
}
/// <summary>
/// Adds a product (output artifact) to the link.
/// </summary>
/// <param name="uri">URI identifying the product.</param>
/// <param name="digest">Cryptographic digests of the product.</param>
/// <returns>This builder for chaining.</returns>
public LinkBuilder AddProduct(string uri, ArtifactDigests digest)
{
ArgumentException.ThrowIfNullOrWhiteSpace(uri);
ArgumentNullException.ThrowIfNull(digest);
_products.Add(new InTotoProduct { Uri = uri, Digest = digest });
return this;
}
/// <summary>
/// Adds a product with SHA-256 digest.
/// </summary>
public LinkBuilder AddProduct(string uri, string sha256Digest)
{
return AddProduct(uri, new ArtifactDigests { Sha256 = sha256Digest.ToLowerInvariant() });
}
/// <summary>
/// Adds multiple products to the link.
/// </summary>
public LinkBuilder AddProducts(IEnumerable<InTotoProduct> products)
{
ArgumentNullException.ThrowIfNull(products);
_products.AddRange(products);
return this;
}
/// <summary>
/// Sets the command that was executed.
/// </summary>
/// <param name="args">Command arguments.</param>
public LinkBuilder WithCommand(params string[] args)
{
_command.Clear();
_command.AddRange(args);
return this;
}
/// <summary>
/// Sets the command that was executed.
/// </summary>
public LinkBuilder WithCommand(IEnumerable<string> args)
{
_command.Clear();
_command.AddRange(args);
return this;
}
/// <summary>
/// Sets the return value of the command.
/// </summary>
public LinkBuilder WithReturnValue(int returnValue)
{
_returnValue = returnValue;
return this;
}
/// <summary>
/// Sets captured stdout.
/// </summary>
public LinkBuilder WithStdout(string? stdout)
{
_stdout = stdout;
return this;
}
/// <summary>
/// Sets captured stderr.
/// </summary>
public LinkBuilder WithStderr(string? stderr)
{
_stderr = stderr;
return this;
}
/// <summary>
/// Adds an environment variable.
/// </summary>
public LinkBuilder WithEnvironment(string name, string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
_environment[name] = value;
return this;
}
/// <summary>
/// Adds multiple environment variables.
/// </summary>
public LinkBuilder WithEnvironment(IReadOnlyDictionary<string, string> environment)
{
ArgumentNullException.ThrowIfNull(environment);
foreach (var (key, value) in environment)
{
_environment[key] = value;
}
return this;
}
/// <summary>
/// Captures environment variables from the current process.
/// </summary>
/// <param name="names">Names of environment variables to capture.</param>
public LinkBuilder CaptureEnvironment(params string[] names)
{
foreach (var name in names)
{
var value = Environment.GetEnvironmentVariable(name);
if (value is not null)
{
_environment[name] = value;
}
}
return this;
}
/// <summary>
/// Sets the creation timestamp (defaults to UTC now at build time).
/// </summary>
public LinkBuilder WithCreatedAt(DateTimeOffset createdAt)
{
_createdAt = createdAt;
return this;
}
/// <summary>
/// Builds the in-toto link.
/// </summary>
/// <returns>The constructed link.</returns>
public InTotoLink Build()
{
var predicate = new InTotoLinkPredicate
{
Name = _stepName,
Command = [.. _command],
Materials = [.. _materials],
Products = [.. _products],
ByProducts = new InTotoByProducts
{
ReturnValue = _returnValue,
Stdout = _stdout,
Stderr = _stderr
},
Environment = _environment.ToImmutableDictionary()
};
// Products become subjects
var subjects = _products
.Select(p => new InTotoSubject(p.Uri, p.Digest))
.ToImmutableArray();
return new InTotoLink
{
Subjects = subjects,
Predicate = predicate,
CreatedAt = _createdAt ?? DateTimeOffset.UtcNow
};
}
/// <summary>
/// Validates the builder state and returns any errors.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (_products.Count == 0)
{
errors.Add("At least one product is required");
}
foreach (var material in _materials)
{
if (!material.Digest.HasDigest)
{
errors.Add($"Material '{material.Uri}' has no digest");
}
}
foreach (var product in _products)
{
if (!product.Digest.HasDigest)
{
errors.Add($"Product '{product.Uri}' has no digest");
}
}
return errors;
}
/// <summary>
/// Builds the link, throwing if validation fails.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown if validation fails.</exception>
public InTotoLink BuildValidated()
{
var errors = Validate();
if (errors.Count > 0)
{
throw new InvalidOperationException(
$"Link validation failed: {string.Join("; ", errors)}");
}
return Build();
}
}

View File

@@ -0,0 +1,300 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Opts = Microsoft.Extensions.Options.Options;
namespace StellaOps.Attestor.Core.InToto;
/// <summary>
/// Options for configuring the link recorder.
/// </summary>
public sealed class LinkRecorderOptions
{
/// <summary>
/// Whether to include SHA-512 digests (in addition to SHA-256).
/// Default is true for stronger security.
/// </summary>
public bool IncludeSha512 { get; set; } = true;
/// <summary>
/// Whether to include SHA-1 digests (deprecated, for compatibility only).
/// Default is false.
/// </summary>
public bool IncludeSha1 { get; set; }
/// <summary>
/// Maximum length for captured stdout/stderr (bytes). Set to 0 to disable capture.
/// Default is 10KB.
/// </summary>
public int MaxByProductLength { get; set; } = 10 * 1024;
/// <summary>
/// Environment variables to include in the link. If null, includes selected defaults.
/// </summary>
public IReadOnlyList<string>? EnvironmentVariables { get; set; }
/// <summary>
/// Default environment variables to capture if <see cref="EnvironmentVariables"/> is null.
/// </summary>
public static readonly IReadOnlyList<string> DefaultEnvironmentVariables =
[
"STELLAOPS_VERSION",
"SCANNER_VERSION",
"CI",
"CI_COMMIT_SHA",
"GITHUB_SHA",
"GITLAB_CI",
"BUILD_NUMBER"
];
}
/// <summary>
/// Implementation of <see cref="ILinkRecorder"/> that records step execution as in-toto links.
/// </summary>
public sealed class LinkRecorder : ILinkRecorder
{
private readonly ILogger<LinkRecorder> _logger;
private readonly LinkRecorderOptions _options;
private readonly TimeProvider _timeProvider;
/// <summary>
/// Initializes a new instance of the <see cref="LinkRecorder"/> class.
/// </summary>
public LinkRecorder(
ILogger<LinkRecorder> logger,
IOptions<LinkRecorderOptions> options,
TimeProvider timeProvider)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
/// <summary>
/// Initializes a new instance with default options.
/// </summary>
public LinkRecorder(ILogger<LinkRecorder> logger, TimeProvider timeProvider)
: this(logger, Opts.Create(new LinkRecorderOptions()), timeProvider)
{
}
/// <inheritdoc/>
public async Task<InTotoLink> RecordStepAsync(
string stepName,
Func<Task<int>> action,
IEnumerable<MaterialSpec> materials,
IEnumerable<ProductSpec> products,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(stepName);
ArgumentNullException.ThrowIfNull(action);
_logger.LogDebug("Recording step '{StepName}'", stepName);
// Compute material digests before execution
var materialList = await ComputeMaterialDigestsAsync(materials, cancellationToken).ConfigureAwait(false);
// Execute the action
var startTime = _timeProvider.GetUtcNow();
int returnValue;
try
{
returnValue = await action().ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Step '{StepName}' failed with exception", stepName);
returnValue = -1;
}
var endTime = _timeProvider.GetUtcNow();
// Compute product digests after execution
var productList = await ComputeProductDigestsAsync(products, cancellationToken).ConfigureAwait(false);
// Build the link
var link = BuildLink(
stepName,
command: [], // No explicit command when using action delegate
returnValue,
materialList,
productList,
stdout: null,
stderr: null);
_logger.LogInformation(
"Recorded step '{StepName}' with {MaterialCount} materials and {ProductCount} products in {Duration:F2}ms",
stepName, materialList.Length, productList.Length, (endTime - startTime).TotalMilliseconds);
return link;
}
/// <inheritdoc/>
public async Task<InTotoLink> RecordExternalStepAsync(
string stepName,
IEnumerable<string> command,
int returnValue,
IEnumerable<MaterialSpec> materials,
IEnumerable<ProductSpec> products,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(stepName);
_logger.LogDebug("Recording external step '{StepName}'", stepName);
var materialList = await ComputeMaterialDigestsAsync(materials, cancellationToken).ConfigureAwait(false);
var productList = await ComputeProductDigestsAsync(products, cancellationToken).ConfigureAwait(false);
var commandList = command?.ToImmutableArray() ?? [];
var link = BuildLink(
stepName,
commandList,
returnValue,
materialList,
productList,
stdout: null,
stderr: null);
_logger.LogInformation(
"Recorded external step '{StepName}' with {MaterialCount} materials and {ProductCount} products",
stepName, materialList.Length, productList.Length);
return link;
}
private async Task<ImmutableArray<InTotoMaterial>> ComputeMaterialDigestsAsync(
IEnumerable<MaterialSpec>? specs,
CancellationToken cancellationToken)
{
if (specs is null)
return [];
var results = new List<InTotoMaterial>();
foreach (var spec in specs)
{
var digest = spec.Digest;
if (digest is null or { HasDigest: false } && spec.LocalPath is not null)
{
if (!File.Exists(spec.LocalPath))
{
_logger.LogWarning("Material file not found: {Path}", spec.LocalPath);
continue;
}
digest = await ArtifactDigests.ComputeFromFileAsync(
spec.LocalPath,
_options.IncludeSha512,
_options.IncludeSha1,
cancellationToken).ConfigureAwait(false);
}
if (digest is null or { HasDigest: false })
{
_logger.LogWarning("Material '{Uri}' has no digest and no local path for computation", spec.Uri);
continue;
}
results.Add(new InTotoMaterial { Uri = spec.Uri, Digest = digest });
}
return [.. results];
}
private async Task<ImmutableArray<InTotoProduct>> ComputeProductDigestsAsync(
IEnumerable<ProductSpec>? specs,
CancellationToken cancellationToken)
{
if (specs is null)
return [];
var results = new List<InTotoProduct>();
foreach (var spec in specs)
{
var digest = spec.Digest;
if (digest is null or { HasDigest: false } && spec.LocalPath is not null)
{
if (!File.Exists(spec.LocalPath))
{
_logger.LogWarning("Product file not found after execution: {Path}", spec.LocalPath);
continue;
}
digest = await ArtifactDigests.ComputeFromFileAsync(
spec.LocalPath,
_options.IncludeSha512,
_options.IncludeSha1,
cancellationToken).ConfigureAwait(false);
}
if (digest is null or { HasDigest: false })
{
_logger.LogWarning("Product '{Uri}' has no digest and no local path for computation", spec.Uri);
continue;
}
results.Add(new InTotoProduct { Uri = spec.Uri, Digest = digest });
}
return [.. results];
}
private InTotoLink BuildLink(
string stepName,
ImmutableArray<string> command,
int returnValue,
ImmutableArray<InTotoMaterial> materials,
ImmutableArray<InTotoProduct> products,
string? stdout,
string? stderr)
{
// Capture environment variables
var envVars = (_options.EnvironmentVariables ?? LinkRecorderOptions.DefaultEnvironmentVariables)
.Select(name => (name, value: Environment.GetEnvironmentVariable(name)))
.Where(x => x.value is not null)
.ToImmutableDictionary(x => x.name, x => x.value!);
// Build predicate
var predicate = new InTotoLinkPredicate
{
Name = stepName,
Command = command,
Materials = materials,
Products = products,
ByProducts = new InTotoByProducts
{
ReturnValue = returnValue,
Stdout = TruncateByProduct(stdout),
Stderr = TruncateByProduct(stderr)
},
Environment = envVars
};
// Build subjects (products become subjects in the statement)
var subjects = products
.Select(p => new InTotoSubject(p.Uri, p.Digest))
.ToImmutableArray();
return new InTotoLink
{
Subjects = subjects,
Predicate = predicate,
CreatedAt = _timeProvider.GetUtcNow()
};
}
private string? TruncateByProduct(string? value)
{
if (value is null || _options.MaxByProductLength <= 0)
return null;
if (value.Length <= _options.MaxByProductLength)
return value;
return value[.._options.MaxByProductLength] + "... (truncated)";
}
}

View File

@@ -0,0 +1,128 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
namespace StellaOps.Attestor.Core.InToto;
/// <summary>
/// Specification for a material (input artifact) to be recorded in an in-toto link.
/// </summary>
/// <remarks>
/// Materials represent inputs consumed by a supply chain step. They can be
/// specified with a pre-computed digest or with a local path for automatic
/// digest computation.
/// </remarks>
public sealed record MaterialSpec
{
/// <summary>
/// URI identifying the material (e.g., "oci://...", "file://...", "git://...").
/// </summary>
public required string Uri { get; init; }
/// <summary>
/// Local file path for automatic digest computation. If null, <see cref="Digest"/> must be provided.
/// </summary>
public string? LocalPath { get; init; }
/// <summary>
/// Pre-computed digest. If null, digest will be computed from <see cref="LocalPath"/>.
/// </summary>
public ArtifactDigests? Digest { get; init; }
/// <summary>
/// Creates a material spec with a pre-computed digest.
/// </summary>
public static MaterialSpec WithDigest(string uri, ArtifactDigests digest) => new()
{
Uri = uri,
Digest = digest
};
/// <summary>
/// Creates a material spec with a local path for automatic digest computation.
/// </summary>
public static MaterialSpec WithLocalPath(string uri, string localPath) => new()
{
Uri = uri,
LocalPath = localPath
};
/// <summary>
/// Creates a material spec for an OCI image reference.
/// </summary>
/// <param name="imageRef">The image reference (e.g., "docker.io/library/nginx@sha256:...").</param>
/// <param name="digest">The image digest.</param>
public static MaterialSpec OciImage(string imageRef, ArtifactDigests digest) => new()
{
Uri = $"oci://{imageRef}",
Digest = digest
};
/// <summary>
/// Creates a material spec for a git reference.
/// </summary>
/// <param name="repoUrl">The repository URL.</param>
/// <param name="commitHash">The commit SHA.</param>
public static MaterialSpec GitCommit(string repoUrl, string commitHash) => new()
{
Uri = $"git://{repoUrl}@{commitHash}",
Digest = new ArtifactDigests { Sha256 = commitHash.ToLowerInvariant() }
};
}
/// <summary>
/// Specification for a product (output artifact) to be recorded in an in-toto link.
/// </summary>
/// <remarks>
/// Products represent outputs produced by a supply chain step. They can be
/// specified with a pre-computed digest or with a local path for automatic
/// digest computation (computed after the step executes).
/// </remarks>
public sealed record ProductSpec
{
/// <summary>
/// URI identifying the product (e.g., "file://sbom.json").
/// </summary>
public required string Uri { get; init; }
/// <summary>
/// Local file path for automatic digest computation. If null, <see cref="Digest"/> must be provided.
/// </summary>
public string? LocalPath { get; init; }
/// <summary>
/// Pre-computed digest. If null, digest will be computed from <see cref="LocalPath"/> after step execution.
/// </summary>
public ArtifactDigests? Digest { get; init; }
/// <summary>
/// Creates a product spec with a pre-computed digest.
/// </summary>
public static ProductSpec WithDigest(string uri, ArtifactDigests digest) => new()
{
Uri = uri,
Digest = digest
};
/// <summary>
/// Creates a product spec with a local path for automatic digest computation.
/// </summary>
public static ProductSpec WithLocalPath(string uri, string localPath) => new()
{
Uri = uri,
LocalPath = localPath
};
/// <summary>
/// Creates a product spec for a file with automatic URI generation.
/// </summary>
/// <param name="localPath">The local file path.</param>
/// <param name="logicalName">Optional logical name for the URI (defaults to filename).</param>
public static ProductSpec File(string localPath, string? logicalName = null)
{
var name = logicalName ?? Path.GetFileName(localPath);
return new ProductSpec
{
Uri = $"file://{name}",
LocalPath = localPath
};
}
}

View File

@@ -1,9 +1,10 @@
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.Serialization;
using StellaOps.Cryptography;
// Models are now in the same namespace
namespace StellaOps.Attestor;
@@ -16,6 +17,8 @@ public class PoEArtifactGenerator : IProofEmitter
{
private readonly IDsseSigningService _signingService;
private readonly ILogger<PoEArtifactGenerator> _logger;
private readonly ICryptoHash _cryptoHash;
private readonly PoEEmissionOptions _options;
private const string PoEPredicateType = "https://stellaops.dev/predicates/proof-of-exposure@v1";
private const string PoESchemaVersion = "stellaops.dev/poe@v1";
@@ -23,10 +26,14 @@ public class PoEArtifactGenerator : IProofEmitter
public PoEArtifactGenerator(
IDsseSigningService signingService,
ILogger<PoEArtifactGenerator> logger)
ILogger<PoEArtifactGenerator> logger,
ICryptoHash? cryptoHash = null,
IOptions<PoEEmissionOptions>? options = null)
{
_signingService = signingService ?? throw new ArgumentNullException(nameof(signingService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_cryptoHash = cryptoHash ?? CryptoHashFactory.CreateDefault();
_options = options?.Value ?? PoEEmissionOptions.Default;
}
public Task<byte[]> EmitPoEAsync(
@@ -34,6 +41,8 @@ public class PoEArtifactGenerator : IProofEmitter
ProofMetadata metadata,
string graphHash,
string? imageDigest = null,
PoEEvidenceRefs? evidenceRefs = null,
PoEEmissionOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(subgraph);
@@ -42,8 +51,9 @@ public class PoEArtifactGenerator : IProofEmitter
try
{
var poe = BuildProofOfExposure(subgraph, metadata, graphHash, imageDigest);
var canonicalJson = CanonicalJsonSerializer.SerializeToBytes(poe);
var resolvedOptions = options ?? _options;
var poe = BuildProofOfExposure(subgraph, metadata, graphHash, imageDigest, evidenceRefs, resolvedOptions);
var canonicalJson = CanonicalJsonSerializer.SerializeToBytes(poe, resolvedOptions.PrettifyJson);
_logger.LogDebug(
"Generated PoE for {VulnId}: {Size} bytes",
@@ -93,16 +103,7 @@ public class PoEArtifactGenerator : IProofEmitter
public string ComputePoEHash(byte[] poeBytes)
{
ArgumentNullException.ThrowIfNull(poeBytes);
// Use BLAKE3-256 for content addressing
// Note: .NET doesn't have built-in BLAKE3, using SHA256 as placeholder
// Real implementation should use a BLAKE3 library like Blake3.NET
using var hasher = SHA256.Create();
var hashBytes = hasher.ComputeHash(poeBytes);
var hashHex = Convert.ToHexString(hashBytes).ToLowerInvariant();
// Format: blake3:{hex} (using sha256 as placeholder for now)
return $"blake3:{hashHex}";
return _cryptoHash.ComputePrefixedHashForPurpose(poeBytes, HashPurpose.Graph);
}
public async Task<IReadOnlyDictionary<string, (byte[] PoeBytes, string PoeHash)>> EmitPoEBatchAsync(
@@ -110,6 +111,8 @@ public class PoEArtifactGenerator : IProofEmitter
ProofMetadata metadata,
string graphHash,
string? imageDigest = null,
PoEEvidenceRefs? evidenceRefs = null,
PoEEmissionOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(subgraphs);
@@ -121,9 +124,10 @@ public class PoEArtifactGenerator : IProofEmitter
var results = new Dictionary<string, (byte[], string)>();
var resolvedOptions = options ?? _options;
foreach (var subgraph in subgraphs)
{
var poeBytes = await EmitPoEAsync(subgraph, metadata, graphHash, imageDigest, cancellationToken);
var poeBytes = await EmitPoEAsync(subgraph, metadata, graphHash, imageDigest, evidenceRefs, resolvedOptions, cancellationToken);
var poeHash = ComputePoEHash(poeBytes);
results[subgraph.VulnId] = (poeBytes, poeHash);
}
@@ -138,7 +142,9 @@ public class PoEArtifactGenerator : IProofEmitter
PoESubgraph subgraph,
ProofMetadata metadata,
string graphHash,
string? imageDigest)
string? imageDigest,
PoEEvidenceRefs? evidenceRefs,
PoEEmissionOptions options)
{
// Convert PoESubgraph to SubgraphData (flatten for JSON)
var nodes = subgraph.Nodes.Select(n => new NodeData(
@@ -173,10 +179,9 @@ public class PoEArtifactGenerator : IProofEmitter
var evidence = new EvidenceInfo(
GraphHash: graphHash,
SbomRef: null, // Populated by caller if available
VexClaimUri: null,
RuntimeFactsUri: null
);
SbomRef: options.IncludeSbomRef ? evidenceRefs?.SbomRef : null,
VexClaimUri: options.IncludeVexClaimUri ? evidenceRefs?.VexClaimUri : null,
RuntimeFactsUri: options.IncludeRuntimeFactsUri ? evidenceRefs?.RuntimeFactsUri : null);
return new ProofOfExposure(
Type: PoEPredicateType,

View File

@@ -1,6 +1,8 @@
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
using System.Collections;
using System.Text;
using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -13,14 +15,15 @@ namespace StellaOps.Attestor.Serialization;
/// </summary>
public static class CanonicalJsonSerializer
{
private static readonly JsonSerializerOptions _options = CreateOptions();
private static readonly JsonSerializerOptions PrettyOptions = CreateOptions(writeIndented: true);
private static readonly JsonSerializerOptions MinifiedOptions = CreateOptions(writeIndented: false);
/// <summary>
/// Serialize object to canonical JSON bytes (UTF-8 encoded).
/// </summary>
public static byte[] SerializeToBytes<T>(T value)
{
var json = JsonSerializer.Serialize(value, _options);
var json = JsonSerializer.Serialize(value, PrettyOptions);
return Encoding.UTF8.GetBytes(json);
}
@@ -29,7 +32,7 @@ public static class CanonicalJsonSerializer
/// </summary>
public static string SerializeToString<T>(T value)
{
return JsonSerializer.Serialize(value, _options);
return JsonSerializer.Serialize(value, PrettyOptions);
}
/// <summary>
@@ -37,7 +40,7 @@ public static class CanonicalJsonSerializer
/// </summary>
public static T? Deserialize<T>(byte[] bytes)
{
return JsonSerializer.Deserialize<T>(bytes, _options);
return JsonSerializer.Deserialize<T>(bytes, PrettyOptions);
}
/// <summary>
@@ -45,14 +48,31 @@ public static class CanonicalJsonSerializer
/// </summary>
public static T? Deserialize<T>(string json)
{
return JsonSerializer.Deserialize<T>(json, _options);
return JsonSerializer.Deserialize<T>(json, PrettyOptions);
}
private static JsonSerializerOptions CreateOptions()
/// <summary>
/// Serialize object to canonical JSON bytes (UTF-8 encoded) with optional pretty formatting.
/// </summary>
public static byte[] SerializeToBytes<T>(T value, bool prettify)
{
var json = JsonSerializer.Serialize(value, GetOptions(prettify));
return Encoding.UTF8.GetBytes(json);
}
/// <summary>
/// Serialize object to canonical JSON string with optional pretty formatting.
/// </summary>
public static string SerializeToString<T>(T value, bool prettify)
{
return JsonSerializer.Serialize(value, GetOptions(prettify));
}
private static JsonSerializerOptions CreateOptions(bool writeIndented)
{
var options = new JsonSerializerOptions
{
WriteIndented = true, // Prettified for readability
WriteIndented = writeIndented,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
@@ -70,17 +90,12 @@ public static class CanonicalJsonSerializer
/// </summary>
public static JsonSerializerOptions GetMinifiedOptions()
{
var options = new JsonSerializerOptions
{
WriteIndented = false, // Minified
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
return MinifiedOptions;
}
options.Converters.Add(new SortedKeysJsonConverter());
return options;
private static JsonSerializerOptions GetOptions(bool prettify)
{
return prettify ? PrettyOptions : MinifiedOptions;
}
}
@@ -92,17 +107,111 @@ public class SortedKeysJsonConverter : JsonConverterFactory
{
public override bool CanConvert(Type typeToConvert)
{
// Apply to all objects (not primitives or arrays)
return !typeToConvert.IsPrimitive &&
typeToConvert != typeof(string) &&
!typeToConvert.IsArray &&
!typeToConvert.IsGenericType;
return TryGetDictionaryTypes(typeToConvert, out var keyType, out _)
&& keyType == typeof(string);
}
public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options)
{
// For now, we rely on property ordering in record types
// A full implementation would use reflection to sort properties
return null; // System.Text.Json respects property order in records by default
if (!TryGetDictionaryTypes(typeToConvert, out var keyType, out var valueType) ||
keyType != typeof(string))
{
return null;
}
var converterType = typeof(SortedStringKeyDictionaryConverter<,>).MakeGenericType(typeToConvert, valueType);
return (JsonConverter?)Activator.CreateInstance(converterType);
}
private static bool TryGetDictionaryTypes(Type typeToConvert, out Type keyType, out Type valueType)
{
keyType = typeof(object);
valueType = typeof(object);
if (typeToConvert.IsGenericType)
{
var genericDef = typeToConvert.GetGenericTypeDefinition();
if (genericDef == typeof(IDictionary<,>) || genericDef == typeof(IReadOnlyDictionary<,>))
{
var args = typeToConvert.GetGenericArguments();
keyType = args[0];
valueType = args[1];
return true;
}
}
foreach (var iface in typeToConvert.GetInterfaces())
{
if (!iface.IsGenericType)
{
continue;
}
var genericDef = iface.GetGenericTypeDefinition();
if (genericDef != typeof(IDictionary<,>) && genericDef != typeof(IReadOnlyDictionary<,>))
{
continue;
}
var args = iface.GetGenericArguments();
keyType = args[0];
valueType = args[1];
return true;
}
return typeof(IDictionary).IsAssignableFrom(typeToConvert);
}
}
internal sealed class SortedStringKeyDictionaryConverter<TDictionary, TValue> : JsonConverter<TDictionary>
where TDictionary : class, IEnumerable<KeyValuePair<string, TValue>>
{
public override TDictionary? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
using var document = JsonDocument.ParseValue(ref reader);
if (document.RootElement.ValueKind != JsonValueKind.Object)
{
return default;
}
var dictionary = new Dictionary<string, TValue>(StringComparer.Ordinal);
foreach (var property in document.RootElement.EnumerateObject())
{
var value = property.Value.Deserialize<TValue>(options);
dictionary[property.Name] = value!;
}
if (typeof(TDictionary).IsAssignableFrom(typeof(Dictionary<string, TValue>)))
{
return (TDictionary)(object)dictionary;
}
if (typeof(TDictionary).IsAssignableFrom(typeof(SortedDictionary<string, TValue>)))
{
return (TDictionary)(object)new SortedDictionary<string, TValue>(dictionary, StringComparer.Ordinal);
}
if (Activator.CreateInstance(typeToConvert) is IDictionary<string, TValue> target)
{
foreach (var kvp in dictionary)
{
target[kvp.Key] = kvp.Value;
}
return (TDictionary)target;
}
return (TDictionary)(object)dictionary;
}
public override void Write(Utf8JsonWriter writer, TDictionary value, JsonSerializerOptions options)
{
writer.WriteStartObject();
foreach (var kvp in value.OrderBy(entry => entry.Key, StringComparer.Ordinal))
{
writer.WritePropertyName(kvp.Key);
JsonSerializer.Serialize(writer, kvp.Value, options);
}
writer.WriteEndObject();
}
}

View File

@@ -4,6 +4,7 @@ using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Attestor.Core.Signing;
namespace StellaOps.Attestor.Signing;
@@ -41,7 +42,7 @@ public class DsseSigningService : IDsseSigningService
try
{
// Step 1: Create DSSE Pre-Authentication Encoding (PAE)
var pae = CreatePae(payloadType, payload);
var pae = DssePreAuthenticationEncoding.Compute(payloadType, payload);
// Step 2: Sign the PAE
var signingKey = await _keyProvider.GetSigningKeyAsync(signingKeyId, cancellationToken);
@@ -113,7 +114,7 @@ public class DsseSigningService : IDsseSigningService
var payload = Convert.FromBase64String(envelope.Payload);
// Step 3: Create PAE
var pae = CreatePae(envelope.PayloadType, payload);
var pae = DssePreAuthenticationEncoding.Compute(envelope.PayloadType, payload);
// Step 4: Verify at least one signature matches a trusted key
foreach (var signature in envelope.Signatures)
@@ -159,32 +160,6 @@ public class DsseSigningService : IDsseSigningService
}
}
/// <summary>
/// Create DSSE Pre-Authentication Encoding (PAE).
/// PAE = "DSSEv1" + SP + LEN(type) + SP + type + SP + LEN(body) + SP + body
/// </summary>
private byte[] CreatePae(string payloadType, byte[] payload)
{
using var stream = new MemoryStream();
using var writer = new BinaryWriter(stream);
// DSSE version prefix
var version = Encoding.UTF8.GetBytes("DSSEv1");
writer.Write((ulong)version.Length);
writer.Write(version);
// Payload type
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
writer.Write((ulong)typeBytes.Length);
writer.Write(typeBytes);
// Payload body
writer.Write((ulong)payload.Length);
writer.Write(payload);
return stream.ToArray();
}
/// <summary>
/// Sign PAE with private key.
/// </summary>

View File

@@ -4,7 +4,7 @@
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="JsonSchema.Net" />
@@ -18,5 +18,6 @@
<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" />
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
</ItemGroup>
</Project>

View File

@@ -9,7 +9,7 @@ namespace StellaOps.Attestor.Core.Submission;
public sealed class AttestorSubmissionValidator
{
private static readonly string[] AllowedKinds = ["sbom", "report", "vex-export"];
private static readonly string[] AllowedKinds = ["sbom", "report", "vex-export", "delta-attestation", "poe"];
private readonly IDsseCanonicalizer _canonicalizer;
private readonly HashSet<string> _allowedModes;
@@ -131,9 +131,10 @@ public sealed class AttestorSubmissionValidator
if (!string.Equals(request.Meta.LogPreference, "primary", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(request.Meta.LogPreference, "mirror", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(request.Meta.LogPreference, "both", StringComparison.OrdinalIgnoreCase))
&& !string.Equals(request.Meta.LogPreference, "both", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(request.Meta.LogPreference, "none", StringComparison.OrdinalIgnoreCase))
{
throw new AttestorValidationException("log_preference_invalid", "logPreference must be 'primary', 'mirror', or 'both'.");
throw new AttestorValidationException("log_preference_invalid", "logPreference must be 'primary', 'mirror', 'both', or 'none'.");
}
return new AttestorSubmissionValidationResult(canonical);

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0049-M | DONE | Maintainability audit for StellaOps.Attestor.Core. |
| AUDIT-0049-T | DONE | Test coverage audit for StellaOps.Attestor.Core. |
| AUDIT-0049-A | DOING | Pending approval for changes. |
| AUDIT-0049-A | DONE | Applied audit fixes + tests. |

View File

@@ -58,7 +58,7 @@ public sealed class PredicateSchemaValidator : IPredicateSchemaValidator
public PredicateSchemaValidator(ILogger<PredicateSchemaValidator> logger)
{
_logger = logger;
_schemas = LoadSchemas();
_schemas = LoadSchemas(_logger);
}
public ValidationResult Validate(string predicateType, JsonElement predicate)
@@ -133,7 +133,7 @@ public sealed class PredicateSchemaValidator : IPredicateSchemaValidator
return errors;
}
private static IReadOnlyDictionary<string, JsonSchema> LoadSchemas()
private static IReadOnlyDictionary<string, JsonSchema> LoadSchemas(ILogger logger)
{
var schemas = new Dictionary<string, JsonSchema>(StringComparer.OrdinalIgnoreCase);
@@ -162,7 +162,7 @@ public sealed class PredicateSchemaValidator : IPredicateSchemaValidator
if (stream is null)
{
// Schema not embedded, skip gracefully
logger.LogWarning("Schema resource {ResourceName} was not found; skipping", resourceName);
continue;
}
@@ -176,7 +176,7 @@ public sealed class PredicateSchemaValidator : IPredicateSchemaValidator
catch (Exception ex)
{
// Log and continue - don't fail on single schema load error
Console.WriteLine($"Failed to load schema {fileName}: {ex.Message}");
logger.LogWarning(ex, "Failed to load schema {SchemaFile}", fileName);
}
}

View File

@@ -1,6 +1,8 @@
using System.Formats.Asn1;
using System.Security.Cryptography;
using System.Text;
using System.Globalization;
using System.Linq;
namespace StellaOps.Attestor.Core.Verification;
@@ -10,6 +12,7 @@ namespace StellaOps.Attestor.Core.Verification;
/// </summary>
public static partial class CheckpointSignatureVerifier
{
private const string Ed25519Oid = "1.3.101.112";
/// <summary>
/// Verifies a Rekor checkpoint signature.
/// </summary>
@@ -330,7 +333,7 @@ public static partial class CheckpointSignatureVerifier
// Ed25519 public keys are 32 bytes
// ECDSA P-256 public keys are 65 bytes (uncompressed) or 33 bytes (compressed)
if (publicKey.Length == 32)
if (publicKey.Length == 32 || IsEd25519PublicKey(publicKey))
{
// Ed25519
return VerifyEd25519(data, signature, publicKey);
@@ -356,9 +359,69 @@ public static partial class CheckpointSignatureVerifier
// TODO: Implement Ed25519 verification when .NET 10 supports it natively
// or use NSec.Cryptography
throw new NotSupportedException(
"Ed25519 verification requires additional library support. " +
"Please use ECDSA P-256 keys or add Ed25519 library dependency.");
return false;
}
private static bool IsEd25519PublicKey(ReadOnlySpan<byte> publicKey)
{
if (TryExtractPem(publicKey, out var der))
{
return IsEd25519SubjectPublicKeyInfo(der);
}
return IsEd25519SubjectPublicKeyInfo(publicKey);
}
private static bool IsEd25519SubjectPublicKeyInfo(ReadOnlySpan<byte> der)
{
try
{
var reader = new AsnReader(der.ToArray(), AsnEncodingRules.DER);
var spki = reader.ReadSequence();
var algorithm = spki.ReadSequence();
var oid = algorithm.ReadObjectIdentifier();
return string.Equals(oid, Ed25519Oid, StringComparison.Ordinal);
}
catch
{
return false;
}
}
private static bool TryExtractPem(ReadOnlySpan<byte> publicKey, out byte[] der)
{
const string begin = "-----BEGIN PUBLIC KEY-----";
const string end = "-----END PUBLIC KEY-----";
der = Array.Empty<byte>();
var text = Encoding.ASCII.GetString(publicKey);
var start = text.IndexOf(begin, StringComparison.Ordinal);
if (start < 0)
{
return false;
}
start += begin.Length;
var endIndex = text.IndexOf(end, start, StringComparison.Ordinal);
if (endIndex < 0)
{
return false;
}
var base64 = text.Substring(start, endIndex - start);
var normalized = new string(base64.Where(static ch => !char.IsWhiteSpace(ch)).ToArray());
try
{
der = Convert.FromBase64String(normalized);
return true;
}
catch (FormatException)
{
der = Array.Empty<byte>();
return false;
}
}
/// <summary>

View File

@@ -15,16 +15,16 @@ public sealed class TimeSkewOptions
/// <summary>
/// Warning threshold in seconds.
/// If skew is between warn and reject thresholds, log a warning but don't fail.
/// Default: 60 seconds (1 minute).
/// Default: 300 seconds (5 minutes).
/// </summary>
public int WarnThresholdSeconds { get; set; } = 60;
public int WarnThresholdSeconds { get; set; } = 300;
/// <summary>
/// Rejection threshold in seconds.
/// If skew exceeds this value, reject the entry.
/// Default: 300 seconds (5 minutes).
/// Default: 3600 seconds (60 minutes).
/// </summary>
public int RejectThresholdSeconds { get; set; } = 300;
public int RejectThresholdSeconds { get; set; } = 3600;
/// <summary>
/// Maximum allowed future time skew in seconds.

View File

@@ -0,0 +1,175 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.Core.InToto;
using StellaOps.Attestor.Core.Signing;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Attestor.Envelope;
namespace StellaOps.Attestor.Infrastructure.InToto;
/// <summary>
/// Implementation of <see cref="IInTotoLinkSigningService"/> that integrates
/// in-toto link generation with the attestation signing infrastructure.
/// </summary>
internal sealed class InTotoLinkSigningService : IInTotoLinkSigningService
{
private readonly ILinkRecorder _linkRecorder;
private readonly IAttestationSigningService _signingService;
private readonly ILogger<InTotoLinkSigningService> _logger;
private readonly TimeProvider _timeProvider;
public InTotoLinkSigningService(
ILinkRecorder linkRecorder,
IAttestationSigningService signingService,
ILogger<InTotoLinkSigningService> logger,
TimeProvider timeProvider)
{
_linkRecorder = linkRecorder ?? throw new ArgumentNullException(nameof(linkRecorder));
_signingService = signingService ?? throw new ArgumentNullException(nameof(signingService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public async Task<SignedInTotoLinkResult> SignLinkAsync(
InTotoLink link,
InTotoLinkSigningOptions options,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(link);
ArgumentNullException.ThrowIfNull(options);
_logger.LogDebug("Signing in-toto link for step {StepName}", link.Predicate.Name);
// Serialize link to JSON payload
var payloadJson = link.ToJson();
var payloadBytes = Encoding.UTF8.GetBytes(payloadJson);
var payloadBase64 = Convert.ToBase64String(payloadBytes);
// Build signing request
var request = new AttestationSignRequest
{
KeyId = options.KeyId ?? string.Empty,
PayloadType = InTotoLink.PredicateTypeUri,
PayloadBase64 = payloadBase64,
Mode = options.Mode,
LogPreference = options.LogPreference,
Archive = options.Archive,
Artifact = new AttestorSubmissionRequest.ArtifactInfo
{
Kind = "in-toto-link",
SubjectUri = $"in-toto:{link.Predicate.Name}"
}
};
// Create submission context for signing
var context = new SubmissionContext
{
CallerSubject = options.CallerSubject ?? "system",
CallerAudience = options.CallerAudience ?? "in-toto-link-signer",
CallerClientId = options.CallerClientId ?? "intoto-link-signing-service",
CallerTenant = options.CallerTenant
};
// Sign the attestation
var signResult = await _signingService.SignAsync(request, context, cancellationToken)
.ConfigureAwait(false);
_logger.LogInformation(
"Signed in-toto link for step {StepName} with key {KeyId}",
link.Predicate.Name,
signResult.KeyId);
// Build DSSE envelope from result
var envelope = BuildEnvelopeFromResult(payloadBytes, signResult);
// Build result
var result = new SignedInTotoLinkResult
{
Link = link,
Envelope = envelope,
SignerKeyId = signResult.KeyId,
Algorithm = signResult.Algorithm,
SignedAt = signResult.SignedAt,
RekorEntry = ExtractRekorEntry(signResult)
};
return result;
}
/// <inheritdoc />
public async Task<SignedInTotoLinkResult> RecordAndSignStepAsync(
string stepName,
Func<Task<int>> action,
IEnumerable<MaterialSpec> materials,
IEnumerable<ProductSpec> products,
InTotoLinkSigningOptions options,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(stepName);
ArgumentNullException.ThrowIfNull(action);
ArgumentNullException.ThrowIfNull(materials);
ArgumentNullException.ThrowIfNull(products);
ArgumentNullException.ThrowIfNull(options);
_logger.LogDebug("Recording and signing step {StepName}", stepName);
// Record the step to create link
var link = await _linkRecorder.RecordStepAsync(
stepName,
action,
materials,
products,
cancellationToken).ConfigureAwait(false);
// Sign the resulting link
return await SignLinkAsync(link, options, cancellationToken).ConfigureAwait(false);
}
private static global::StellaOps.Attestor.Envelope.DsseEnvelope BuildEnvelopeFromResult(
byte[] payloadBytes,
AttestationSignResult signResult)
{
// Extract signature from bundle
var signatures = new List<global::StellaOps.Attestor.Envelope.DsseSignature>();
if (signResult.Bundle.Dsse?.Signatures != null)
{
foreach (var sig in signResult.Bundle.Dsse.Signatures)
{
signatures.Add(new global::StellaOps.Attestor.Envelope.DsseSignature(
sig.Signature,
sig.KeyId));
}
}
if (signatures.Count == 0)
{
// Fallback: create signature from keyId if no envelope signatures present
signatures.Add(new global::StellaOps.Attestor.Envelope.DsseSignature(
"pending", // Will be replaced by actual signing
signResult.KeyId));
}
return new global::StellaOps.Attestor.Envelope.DsseEnvelope(
InTotoLink.PredicateTypeUri,
new ReadOnlyMemory<byte>(payloadBytes),
signatures);
}
// Note: Rekor entry information comes from the submission service after
// the envelope is submitted to the transparency log. The signing service
// produces the signed envelope, but Rekor submission is a separate step.
// For now, we return null and let callers handle Rekor submission separately.
private static RekorEntryReference? ExtractRekorEntry(AttestationSignResult signResult)
{
// The signing result does not include Rekor entry info directly.
// Rekor submission happens in a separate step via IAttestorSubmissionService.
// Callers who need Rekor transparency should submit the result to Rekor
// and capture the entry reference from that operation.
return null;
}
}

View File

@@ -26,6 +26,9 @@ using StellaOps.Attestor.Infrastructure.Transparency;
using StellaOps.Attestor.Infrastructure.Verification;
using StellaOps.Attestor.Infrastructure.Bulk;
using StellaOps.Attestor.Core.Signing;
using StellaOps.Attestor.Core.InToto;
using StellaOps.Attestor.Core.InToto.Layout;
using StellaOps.Attestor.Infrastructure.InToto;
using StellaOps.Attestor.Verify;
namespace StellaOps.Attestor.Infrastructure;
@@ -67,6 +70,12 @@ public static class ServiceCollectionExtensions
services.AddSingleton<IAttestorBundleService, AttestorBundleService>();
services.AddSingleton<AttestorSigningKeyRegistry>();
services.AddSingleton<IAttestationSigningService, AttestorSigningService>();
// In-toto link generation services
services.AddSingleton<ILinkRecorder, LinkRecorder>();
services.AddSingleton<ILayoutVerifier, LayoutVerifier>();
services.AddSingleton<IInTotoLinkSigningService, InTotoLinkSigningService>();
services.AddHttpClient<HttpRekorClient>((sp, client) =>
{
var options = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;

View File

@@ -0,0 +1,86 @@
using System;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using StellaOps.Attestor.WebService.Contracts;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Attestor.Tests;
public sealed class WebServiceFeatureGateTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task AnchorsEndpoints_Disabled_Returns501()
{
using var factory = new AttestorWebApplicationFactory();
var client = factory.CreateClient();
AttachAuth(client);
var response = await client.GetAsync("/anchors");
Assert.Equal(HttpStatusCode.NotImplemented, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(payload.TryGetProperty("code", out var code));
Assert.Equal("feature_not_implemented", code.GetString());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ProofsEndpoints_Disabled_Returns501()
{
using var factory = new AttestorWebApplicationFactory();
var client = factory.CreateClient();
AttachAuth(client);
var entry = "sha256:deadbeef:pkg:npm/test@1.0.0";
var response = await client.GetAsync($"/proofs/{Uri.EscapeDataString(entry)}/receipt");
Assert.Equal(HttpStatusCode.NotImplemented, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(payload.TryGetProperty("code", out var code));
Assert.Equal("feature_not_implemented", code.GetString());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerifyEndpoints_Disabled_Returns501()
{
using var factory = new AttestorWebApplicationFactory();
var client = factory.CreateClient();
AttachAuth(client);
var response = await client.PostAsync("/verify/test-bundle", new StringContent(string.Empty));
Assert.Equal(HttpStatusCode.NotImplemented, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(payload.TryGetProperty("code", out var code));
Assert.Equal("feature_not_implemented", code.GetString());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerdictEndpoint_RequiresAuthentication()
{
using var factory = new AttestorWebApplicationFactory();
var client = factory.CreateClient();
var request = new VerdictAttestationRequestDto
{
PredicateType = "https://stellaops.dev/predicates/policy-verdict@v1",
Predicate = "{\"verdict\":{\"status\":\"pass\"}}",
Subject = new VerdictSubjectDto { Name = "finding-1" }
};
var response = await client.PostAsJsonAsync("/internal/api/v1/attestations/verdict", request);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
private static void AttachAuth(HttpClient client)
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "test-token");
}
}

View File

@@ -22,3 +22,4 @@
## Testing
- Use WebApplicationFactory for endpoint tests and include auth/mtls coverage.
- Add contract tests for request/response DTOs and error handling.
- WebService endpoint tests live in `src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Tests`.

View File

@@ -0,0 +1,443 @@
using System.Security.Authentication;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Https;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
using Serilog;
using Serilog.Context;
using Serilog.Events;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Attestor.Core.Bulk;
using StellaOps.Attestor.Core.Observability;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Signing;
using StellaOps.Attestor.Core.Storage;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Attestor.Core.Verification;
using StellaOps.Attestor.Infrastructure;
using StellaOps.Attestor.WebService.Options;
using StellaOps.Configuration;
using StellaOps.Cryptography.DependencyInjection;
using StellaOps.Router.AspNet;
namespace StellaOps.Attestor.WebService;
internal static class AttestorWebServiceComposition
{
public static AttestorOptions BindAttestorOptions(this WebApplicationBuilder builder, string configurationSection)
{
return builder.Configuration.BindOptions<AttestorOptions>(configurationSection);
}
public static void ConfigureAttestorLogging(this WebApplicationBuilder builder)
{
builder.Host.UseSerilog((context, services, loggerConfiguration) =>
{
loggerConfiguration
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.Enrich.FromLogContext()
.WriteTo.Console();
});
}
public static void AddAttestorWebService(this WebApplicationBuilder builder, AttestorOptions attestorOptions, string configurationSection)
{
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton(attestorOptions);
builder.Services.AddStellaOpsCryptoRu(builder.Configuration, CryptoProviderRegistryValidator.EnforceRuLinuxDefaults);
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = static (context, _) =>
{
context.HttpContext.Response.Headers.TryAdd("Retry-After", "1");
return ValueTask.CompletedTask;
};
static string ResolveIdentity(HttpContext httpContext)
{
return httpContext.Connection.ClientCertificate?.Thumbprint
?? httpContext.User.FindFirst("sub")?.Value
?? httpContext.User.FindFirst("client_id")?.Value
?? httpContext.Connection.RemoteIpAddress?.ToString()
?? "anonymous";
}
RateLimitPartition<string> BuildTokenBucket(HttpContext httpContext, AttestorOptions.PerCallerQuotaOptions quota)
{
var identity = ResolveIdentity(httpContext);
var tokensPerPeriod = Math.Max(1, quota.Qps);
var tokenLimit = Math.Max(tokensPerPeriod, quota.Burst);
var queueLimit = Math.Max(quota.Burst, tokensPerPeriod);
return RateLimitPartition.GetTokenBucketLimiter(identity, _ => new TokenBucketRateLimiterOptions
{
TokenLimit = tokenLimit,
TokensPerPeriod = tokensPerPeriod,
ReplenishmentPeriod = TimeSpan.FromSeconds(1),
QueueLimit = queueLimit,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
AutoReplenishment = true
});
}
var perCallerQuota = attestorOptions.Quotas.PerCaller;
options.AddPolicy("attestor-submissions", httpContext => BuildTokenBucket(httpContext, perCallerQuota));
options.AddPolicy("attestor-verifications", httpContext => BuildTokenBucket(httpContext, perCallerQuota));
options.AddPolicy("attestor-reads", httpContext => BuildTokenBucket(httpContext, perCallerQuota));
options.AddPolicy("attestor-bulk", httpContext =>
{
var identity = ResolveIdentity(httpContext);
var bulkQuota = attestorOptions.Quotas.Bulk;
var permitLimit = Math.Max(1, bulkQuota.RequestsPerMinute);
var queueLimit = Math.Max(0, bulkQuota.RequestsPerMinute / 2);
return RateLimitPartition.GetFixedWindowLimiter(identity, _ => new FixedWindowRateLimiterOptions
{
PermitLimit = permitLimit,
Window = TimeSpan.FromMinutes(1),
QueueLimit = queueLimit,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
});
});
});
builder.Services.AddOptions<AttestorOptions>()
.Bind(builder.Configuration.GetSection(configurationSection))
.ValidateOnStart();
builder.Services.AddOptions<AttestorWebServiceFeatures>()
.Bind(builder.Configuration.GetSection($"{configurationSection}:features"))
.ValidateOnStart();
builder.Services.AddProblemDetails();
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddAttestorInfrastructure();
builder.Services.AddScoped<Services.IProofChainQueryService, Services.ProofChainQueryService>();
builder.Services.AddScoped<Services.IProofVerificationService, Services.ProofVerificationService>();
builder.Services.AddSingleton<StellaOps.Attestor.StandardPredicates.IStandardPredicateRegistry>(sp =>
{
var registry = new StellaOps.Attestor.StandardPredicates.StandardPredicateRegistry();
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var spdxParser = new StellaOps.Attestor.StandardPredicates.Parsers.SpdxPredicateParser(
loggerFactory.CreateLogger<StellaOps.Attestor.StandardPredicates.Parsers.SpdxPredicateParser>());
registry.Register(spdxParser.PredicateType, spdxParser);
var cycloneDxParser = new StellaOps.Attestor.StandardPredicates.Parsers.CycloneDxPredicateParser(
loggerFactory.CreateLogger<StellaOps.Attestor.StandardPredicates.Parsers.CycloneDxPredicateParser>());
registry.Register(cycloneDxParser.PredicateType, cycloneDxParser);
var slsaParser = new StellaOps.Attestor.StandardPredicates.Parsers.SlsaProvenancePredicateParser(
loggerFactory.CreateLogger<StellaOps.Attestor.StandardPredicates.Parsers.SlsaProvenancePredicateParser>());
registry.Register(slsaParser.PredicateType, slsaParser);
return registry;
});
builder.Services.AddScoped<Services.IPredicateTypeRouter, Services.PredicateTypeRouter>();
builder.Services.AddHttpContextAccessor();
var evidenceLockerUrl = builder.Configuration.GetValue<string>("EvidenceLocker:BaseUrl")
?? builder.Configuration.GetValue<string>("EvidenceLockerUrl");
if (string.IsNullOrWhiteSpace(evidenceLockerUrl))
{
throw new InvalidOperationException("EvidenceLocker base URL must be configured (EvidenceLocker:BaseUrl or EvidenceLockerUrl).");
}
builder.Services.AddHttpClient("EvidenceLocker", client =>
{
client.BaseAddress = new Uri(evidenceLockerUrl, UriKind.Absolute);
client.Timeout = TimeSpan.FromSeconds(30);
});
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy());
var openTelemetry = builder.Services.AddOpenTelemetry();
openTelemetry.WithMetrics(metricsBuilder =>
{
metricsBuilder.AddMeter(AttestorMetrics.MeterName);
metricsBuilder.AddAspNetCoreInstrumentation();
metricsBuilder.AddRuntimeInstrumentation();
});
if (attestorOptions.Telemetry.EnableTracing)
{
openTelemetry.WithTracing(tracingBuilder =>
{
tracingBuilder.AddSource(AttestorActivitySource.Name);
tracingBuilder.AddAspNetCoreInstrumentation();
tracingBuilder.AddHttpClientInstrumentation();
});
}
if (attestorOptions.Security.Authority is { Issuer: not null } authority)
{
builder.Services.AddAuthentication();
builder.Services.AddStellaOpsResourceServerAuthentication(
builder.Configuration,
configurationSection: null,
configure: resourceOptions =>
{
resourceOptions.Authority = authority.Issuer!;
resourceOptions.RequireHttpsMetadata = authority.RequireHttpsMetadata;
if (!string.IsNullOrWhiteSpace(authority.JwksUrl))
{
resourceOptions.MetadataAddress = authority.JwksUrl;
}
foreach (var audience in authority.Audiences)
{
resourceOptions.Audiences.Add(audience);
}
if (authority.RequiredScopes.Count == 0)
{
resourceOptions.RequiredScopes.Add("attestor.write");
resourceOptions.RequiredScopes.Add("attestor.verify");
resourceOptions.RequiredScopes.Add("attestor.read");
}
else
{
foreach (var scope in authority.RequiredScopes)
{
resourceOptions.RequiredScopes.Add(scope);
}
}
});
}
else
{
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = NoAuthHandler.SchemeName;
options.DefaultChallengeScheme = NoAuthHandler.SchemeName;
}).AddScheme<AuthenticationSchemeOptions, NoAuthHandler>(
authenticationScheme: NoAuthHandler.SchemeName,
displayName: null,
configureOptions: options => { options.TimeProvider ??= TimeProvider.System; });
}
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("attestor:write", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireAssertion(context => HasAnyScope(context.User, "attestor.write"));
});
options.AddPolicy("attestor:verify", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireAssertion(context => HasAnyScope(context.User, "attestor.verify", "attestor.write"));
});
options.AddPolicy("attestor:read", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireAssertion(context => HasAnyScope(context.User, "attestor.read", "attestor.verify", "attestor.write"));
});
});
}
public static void ConfigureAttestorKestrel(this ConfigureWebHostBuilder webHost, AttestorOptions attestorOptions, IReadOnlyCollection<X509Certificate2> clientCertificateAuthorities)
{
webHost.ConfigureKestrel(kestrel =>
{
kestrel.ConfigureHttpsDefaults(https =>
{
if (attestorOptions.Security.Mtls.RequireClientCertificate)
{
https.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
}
https.SslProtocols = SslProtocols.Tls13 | SslProtocols.Tls12;
https.ClientCertificateValidation = (certificate, _, _) =>
{
if (!attestorOptions.Security.Mtls.RequireClientCertificate)
{
return true;
}
if (certificate is null)
{
Log.Warning("Client certificate missing");
return false;
}
if (clientCertificateAuthorities.Count > 0)
{
using var chain = new X509Chain
{
ChainPolicy =
{
RevocationMode = X509RevocationMode.NoCheck,
TrustMode = X509ChainTrustMode.CustomRootTrust
}
};
foreach (var authority in clientCertificateAuthorities)
{
chain.ChainPolicy.CustomTrustStore.Add(authority);
}
if (!chain.Build(certificate))
{
Log.Warning("Client certificate chain validation failed for {Subject}", certificate.Subject);
return false;
}
}
if (attestorOptions.Security.Mtls.AllowedThumbprints.Count > 0 &&
!attestorOptions.Security.Mtls.AllowedThumbprints.Contains(certificate.Thumbprint ?? string.Empty, StringComparer.OrdinalIgnoreCase))
{
Log.Warning("Client certificate thumbprint {Thumbprint} rejected", certificate.Thumbprint);
return false;
}
if (attestorOptions.Security.Mtls.AllowedSubjects.Count > 0 &&
!attestorOptions.Security.Mtls.AllowedSubjects.Contains(certificate.Subject, StringComparer.OrdinalIgnoreCase))
{
Log.Warning("Client certificate subject {Subject} rejected", certificate.Subject);
return false;
}
return true;
};
});
});
}
public static void UseAttestorWebService(this WebApplication app, AttestorOptions attestorOptions, StellaRouterOptionsBase? routerOptions)
{
app.UseSerilogRequestLogging();
app.Use(async (context, next) =>
{
var correlationId = context.Request.Headers["X-Correlation-Id"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(correlationId))
{
correlationId = Guid.NewGuid().ToString("N");
}
context.Response.Headers["X-Correlation-Id"] = correlationId;
using (LogContext.PushProperty("CorrelationId", correlationId))
{
await next().ConfigureAwait(false);
}
});
app.UseExceptionHandler(static handler =>
{
handler.Run(async context =>
{
var result = Results.Problem(statusCode: StatusCodes.Status500InternalServerError);
await result.ExecuteAsync(context);
});
});
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.TryUseStellaRouter(routerOptions);
app.MapHealthChecks("/health/ready");
app.MapHealthChecks("/health/live");
app.MapControllers();
app.MapAttestorEndpoints(attestorOptions);
app.TryRefreshStellaRouterEndpoints(routerOptions);
}
public static List<X509Certificate2> LoadClientCertificateAuthorities(string? path)
{
var certificates = new List<X509Certificate2>();
if (string.IsNullOrWhiteSpace(path))
{
return certificates;
}
try
{
if (!File.Exists(path))
{
Log.Warning("Client CA bundle '{Path}' not found", path);
return certificates;
}
var collection = new X509Certificate2Collection();
collection.ImportFromPemFile(path);
certificates.AddRange(collection.Cast<X509Certificate2>());
}
catch (Exception ex) when (ex is IOException or CryptographicException)
{
Log.Warning(ex, "Failed to load client CA bundle from {Path}", path);
}
return certificates;
}
private static bool HasAnyScope(ClaimsPrincipal user, params string[] scopes)
{
if (user?.Identity is not { IsAuthenticated: true } || scopes.Length == 0)
{
return false;
}
foreach (var granted in ExtractScopes(user))
{
foreach (var required in scopes)
{
if (string.Equals(granted, required, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
return false;
}
private static IEnumerable<string> ExtractScopes(ClaimsPrincipal user)
{
foreach (var claim in user.FindAll("scope"))
{
foreach (var value in claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
yield return value;
}
}
foreach (var claim in user.FindAll("scp"))
{
foreach (var value in claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
yield return value;
}
}
}
}

View File

@@ -1,7 +1,9 @@
using System.Security.Claims;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using StellaOps.Attestor.Core.Bulk;
using StellaOps.Attestor.Core.InToto;
using StellaOps.Attestor.Core.Offline;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Signing;
@@ -144,6 +146,139 @@ internal static class AttestorWebServiceEndpoints
}).RequireAuthorization("attestor:write")
.RequireRateLimiting("attestor-submissions");
// In-toto link creation endpoint
app.MapPost("/api/v1/attestor/links", async (
InTotoLinkCreateRequestDto? requestDto,
HttpContext httpContext,
IInTotoLinkSigningService linkSigningService,
CancellationToken cancellationToken) =>
{
if (requestDto is null)
{
return Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: "Request body is required.");
}
if (!IsJsonContentType(httpContext.Request.ContentType))
{
return UnsupportedMediaTypeResult();
}
if (string.IsNullOrWhiteSpace(requestDto.StepName))
{
return Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: "stepName is required.");
}
var certificate = httpContext.Connection.ClientCertificate;
var user = httpContext.User;
var callerSubject = user?.FindFirst("sub")?.Value ?? certificate?.Subject ?? "anonymous";
try
{
// Build the link using LinkBuilder
var builder = new LinkBuilder(requestDto.StepName);
// Add command if provided
if (requestDto.Command?.Count > 0)
{
builder.WithCommand(requestDto.Command);
}
// Add environment if provided
if (requestDto.Environment?.Count > 0)
{
foreach (var (key, value) in requestDto.Environment)
{
builder.WithEnvironment(key, value);
}
}
// Add return value if provided
if (requestDto.ReturnValue.HasValue)
{
builder.WithReturnValue(requestDto.ReturnValue.Value);
}
// Add materials
if (requestDto.Materials?.Count > 0)
{
foreach (var material in requestDto.Materials)
{
if (string.IsNullOrWhiteSpace(material.Uri))
{
return Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: "Material URI is required.");
}
var digests = new ArtifactDigests { Sha256 = material.Sha256, Sha512 = material.Sha512 };
builder.AddMaterial(material.Uri, digests);
}
}
// Add products
if (requestDto.Products?.Count > 0)
{
foreach (var product in requestDto.Products)
{
if (string.IsNullOrWhiteSpace(product.Uri))
{
return Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: "Product URI is required.");
}
var digests = new ArtifactDigests { Sha256 = product.Sha256, Sha512 = product.Sha512 };
builder.AddProduct(product.Uri, digests);
}
}
var link = builder.Build();
var options = requestDto.ToSigningOptions(callerSubject);
var result = await linkSigningService.SignLinkAsync(link, options, cancellationToken).ConfigureAwait(false);
// Build response
var response = new InTotoLinkCreateResponseDto
{
Link = JsonSerializer.Deserialize<object>(link.ToJson()),
Envelope = new InTotoDsseEnvelopeDto
{
PayloadType = result.Envelope.PayloadType,
PayloadBase64 = Convert.ToBase64String(result.Envelope.Payload.ToArray()),
Signatures = result.Envelope.Signatures.Select(s => new InTotoDsseSignatureDto
{
KeyId = s.KeyId,
Signature = s.Signature
}).ToList()
},
Signing = new InTotoSigningInfoDto
{
KeyId = result.SignerKeyId,
Algorithm = result.Algorithm,
SignedAt = result.SignedAt.ToString("O")
},
Rekor = result.RekorEntry is null ? null : new InTotoRekorEntryDto
{
LogId = result.RekorEntry.LogId,
LogIndex = result.RekorEntry.LogIndex,
Uuid = result.RekorEntry.Uuid,
IntegratedTime = result.RekorEntry.IntegratedTime?.ToString("O")
}
};
return Results.Ok(response);
}
catch (ArgumentException argEx)
{
return Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: argEx.Message);
}
catch (AttestorSigningException signingEx)
{
return Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: signingEx.Message, extensions: new Dictionary<string, object?>
{
["code"] = signingEx.Code
});
}
}).RequireAuthorization("attestor:write")
.RequireRateLimiting("attestor-submissions")
.Produces<InTotoLinkCreateResponseDto>(StatusCodes.Status200OK);
app.MapPost("/api/v1/rekor/entries", async (AttestorSubmissionRequest request, HttpContext httpContext, IAttestorSubmissionService submissionService, CancellationToken cancellationToken) =>
{
if (!IsJsonContentType(httpContext.Request.ContentType))

View File

@@ -0,0 +1,247 @@
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
using System.Text.Json.Serialization;
using StellaOps.Attestor.Core.InToto;
namespace StellaOps.Attestor.WebService.Contracts;
/// <summary>
/// Request DTO for creating an in-toto link.
/// </summary>
public sealed class InTotoLinkCreateRequestDto
{
/// <summary>
/// Name of the step (e.g., "build", "scan", "sign").
/// </summary>
[JsonPropertyName("stepName")]
public string? StepName { get; set; }
/// <summary>
/// Command executed (optional).
/// </summary>
[JsonPropertyName("command")]
public List<string>? Command { get; set; }
/// <summary>
/// Return value from command execution (optional).
/// </summary>
[JsonPropertyName("returnValue")]
public int? ReturnValue { get; set; }
/// <summary>
/// Input materials for this step.
/// </summary>
[JsonPropertyName("materials")]
public List<InTotoArtifactDto>? Materials { get; set; }
/// <summary>
/// Output products from this step.
/// </summary>
[JsonPropertyName("products")]
public List<InTotoArtifactDto>? Products { get; set; }
/// <summary>
/// Environment variables (optional).
/// </summary>
[JsonPropertyName("environment")]
public Dictionary<string, string>? Environment { get; set; }
/// <summary>
/// Signing key ID (optional, uses default if not specified).
/// </summary>
[JsonPropertyName("keyId")]
public string? KeyId { get; set; }
/// <summary>
/// Whether to submit to Rekor transparency log.
/// </summary>
[JsonPropertyName("submitToRekor")]
public bool SubmitToRekor { get; set; } = true;
/// <summary>
/// Log preference for Rekor ("primary", "mirror", "both").
/// </summary>
[JsonPropertyName("logPreference")]
public string? LogPreference { get; set; }
/// <summary>
/// Whether to archive the attestation.
/// </summary>
[JsonPropertyName("archive")]
public bool Archive { get; set; } = true;
}
/// <summary>
/// Artifact (material or product) in an in-toto link.
/// </summary>
public sealed class InTotoArtifactDto
{
/// <summary>
/// URI identifying the artifact (e.g., "file://sbom.json", "oci://image@sha256:...").
/// </summary>
[JsonPropertyName("uri")]
public string? Uri { get; set; }
/// <summary>
/// SHA-256 digest (hex-encoded).
/// </summary>
[JsonPropertyName("sha256")]
public string? Sha256 { get; set; }
/// <summary>
/// SHA-512 digest (hex-encoded, optional).
/// </summary>
[JsonPropertyName("sha512")]
public string? Sha512 { get; set; }
}
/// <summary>
/// Response DTO for in-toto link creation.
/// </summary>
public sealed class InTotoLinkCreateResponseDto
{
/// <summary>
/// The generated in-toto link as JSON.
/// </summary>
[JsonPropertyName("link")]
public object? Link { get; set; }
/// <summary>
/// The signed DSSE envelope as JSON.
/// </summary>
[JsonPropertyName("envelope")]
public InTotoDsseEnvelopeDto? Envelope { get; set; }
/// <summary>
/// Signing metadata.
/// </summary>
[JsonPropertyName("signing")]
public InTotoSigningInfoDto? Signing { get; set; }
/// <summary>
/// Rekor entry reference (if submitted to transparency log).
/// </summary>
[JsonPropertyName("rekor")]
public InTotoRekorEntryDto? Rekor { get; set; }
}
/// <summary>
/// DSSE envelope representation.
/// </summary>
public sealed class InTotoDsseEnvelopeDto
{
[JsonPropertyName("payloadType")]
public string? PayloadType { get; set; }
[JsonPropertyName("payload")]
public string? PayloadBase64 { get; set; }
[JsonPropertyName("signatures")]
public List<InTotoDsseSignatureDto>? Signatures { get; set; }
}
/// <summary>
/// DSSE signature.
/// </summary>
public sealed class InTotoDsseSignatureDto
{
[JsonPropertyName("keyid")]
public string? KeyId { get; set; }
[JsonPropertyName("sig")]
public string? Signature { get; set; }
}
/// <summary>
/// Signing information.
/// </summary>
public sealed class InTotoSigningInfoDto
{
[JsonPropertyName("keyId")]
public string? KeyId { get; set; }
[JsonPropertyName("algorithm")]
public string? Algorithm { get; set; }
[JsonPropertyName("signedAt")]
public string? SignedAt { get; set; }
}
/// <summary>
/// Rekor transparency log entry reference.
/// </summary>
public sealed class InTotoRekorEntryDto
{
[JsonPropertyName("logId")]
public string? LogId { get; set; }
[JsonPropertyName("logIndex")]
public long? LogIndex { get; set; }
[JsonPropertyName("uuid")]
public string? Uuid { get; set; }
[JsonPropertyName("integratedTime")]
public string? IntegratedTime { get; set; }
}
/// <summary>
/// Extension methods for mapping between DTOs and domain models.
/// </summary>
public static class InTotoLinkContractExtensions
{
/// <summary>
/// Converts an artifact DTO to a MaterialSpec.
/// </summary>
public static MaterialSpec ToMaterialSpec(this InTotoArtifactDto dto)
{
if (string.IsNullOrWhiteSpace(dto.Uri))
{
throw new ArgumentException("Material URI is required");
}
ArtifactDigests? digest = null;
if (!string.IsNullOrWhiteSpace(dto.Sha256) || !string.IsNullOrWhiteSpace(dto.Sha512))
{
digest = new ArtifactDigests { Sha256 = dto.Sha256, Sha512 = dto.Sha512 };
}
return new MaterialSpec { Uri = dto.Uri, Digest = digest };
}
/// <summary>
/// Converts an artifact DTO to a ProductSpec.
/// </summary>
public static ProductSpec ToProductSpec(this InTotoArtifactDto dto)
{
if (string.IsNullOrWhiteSpace(dto.Uri))
{
throw new ArgumentException("Product URI is required");
}
ArtifactDigests? digest = null;
if (!string.IsNullOrWhiteSpace(dto.Sha256) || !string.IsNullOrWhiteSpace(dto.Sha512))
{
digest = new ArtifactDigests { Sha256 = dto.Sha256, Sha512 = dto.Sha512 };
}
return new ProductSpec { Uri = dto.Uri, Digest = digest };
}
/// <summary>
/// Converts signing options from the request DTO.
/// </summary>
public static InTotoLinkSigningOptions ToSigningOptions(this InTotoLinkCreateRequestDto dto, string callerSubject)
{
return new InTotoLinkSigningOptions
{
KeyId = dto.KeyId,
SubmitToRekor = dto.SubmitToRekor,
LogPreference = dto.LogPreference ?? "primary",
Archive = dto.Archive,
CallerSubject = callerSubject,
CallerAudience = "intoto-link-api",
CallerClientId = "intoto-link-endpoint"
};
}
}

View File

@@ -1,6 +1,8 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.WebService.Options;
using StellaOps.Attestor.WebService.Contracts.Anchors;
namespace StellaOps.Attestor.WebService.Controllers;
@@ -11,14 +13,17 @@ namespace StellaOps.Attestor.WebService.Controllers;
[ApiController]
[Route("anchors")]
[Produces("application/json")]
[ProducesResponseType(StatusCodes.Status501NotImplemented)]
public class AnchorsController : ControllerBase
{
private readonly ILogger<AnchorsController> _logger;
private readonly AttestorWebServiceFeatures _features;
// TODO: Inject IProofChainRepository
public AnchorsController(ILogger<AnchorsController> logger)
public AnchorsController(ILogger<AnchorsController> logger, IOptions<AttestorWebServiceFeatures> features)
{
_logger = logger;
_features = features.Value;
}
/// <summary>
@@ -32,6 +37,11 @@ public class AnchorsController : ControllerBase
[ProducesResponseType(typeof(TrustAnchorDto[]), StatusCodes.Status200OK)]
public async Task<ActionResult<TrustAnchorDto[]>> GetAnchorsAsync(CancellationToken ct = default)
{
if (!_features.AnchorsEnabled)
{
return NotImplementedResult();
}
_logger.LogInformation("Getting all trust anchors");
return NotImplementedResult();
}
@@ -52,6 +62,11 @@ public class AnchorsController : ControllerBase
[FromRoute] string anchorId,
CancellationToken ct = default)
{
if (!_features.AnchorsEnabled)
{
return NotImplementedResult();
}
_logger.LogInformation("Getting trust anchor {AnchorId}", anchorId);
return NotImplementedResult();
}
@@ -72,6 +87,11 @@ public class AnchorsController : ControllerBase
[FromBody] CreateTrustAnchorRequest request,
CancellationToken ct = default)
{
if (!_features.AnchorsEnabled)
{
return NotImplementedResult();
}
_logger.LogInformation("Creating trust anchor for pattern {Pattern}", request.PurlPattern);
return NotImplementedResult();
}
@@ -93,6 +113,11 @@ public class AnchorsController : ControllerBase
[FromBody] UpdateTrustAnchorRequest request,
CancellationToken ct = default)
{
if (!_features.AnchorsEnabled)
{
return NotImplementedResult();
}
_logger.LogInformation("Updating trust anchor {AnchorId}", anchorId);
return NotImplementedResult();
}
@@ -115,6 +140,11 @@ public class AnchorsController : ControllerBase
[FromBody] RevokeKeyRequest request,
CancellationToken ct = default)
{
if (!_features.AnchorsEnabled)
{
return NotImplementedResult();
}
_logger.LogInformation("Revoking key {KeyId} in anchor {AnchorId}", request.KeyId, anchorId);
return NotImplementedResult();
}
@@ -134,6 +164,11 @@ public class AnchorsController : ControllerBase
[FromRoute] Guid anchorId,
CancellationToken ct = default)
{
if (!_features.AnchorsEnabled)
{
return NotImplementedResult();
}
_logger.LogInformation("Deactivating trust anchor {AnchorId}", anchorId);
return NotImplementedResult();
}

View File

@@ -1,7 +1,9 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.WebService.Contracts.Proofs;
using StellaOps.Attestor.WebService.Options;
namespace StellaOps.Attestor.WebService.Controllers;
@@ -11,14 +13,17 @@ namespace StellaOps.Attestor.WebService.Controllers;
[ApiController]
[Route("proofs")]
[Produces("application/json")]
[ProducesResponseType(StatusCodes.Status501NotImplemented)]
public class ProofsController : ControllerBase
{
private readonly ILogger<ProofsController> _logger;
private readonly AttestorWebServiceFeatures _features;
// TODO: Inject IProofSpineAssembler, IReceiptGenerator, IProofChainRepository
public ProofsController(ILogger<ProofsController> logger)
public ProofsController(ILogger<ProofsController> logger, IOptions<AttestorWebServiceFeatures> features)
{
_logger = logger;
_features = features.Value;
}
/// <summary>
@@ -40,6 +45,11 @@ public class ProofsController : ControllerBase
[FromBody] CreateSpineRequest request,
CancellationToken ct = default)
{
if (!_features.ProofsEnabled)
{
return NotImplementedResult();
}
_logger.LogInformation("Creating proof spine for entry {Entry}", entry);
return NotImplementedResult();
}
@@ -59,6 +69,11 @@ public class ProofsController : ControllerBase
[FromRoute] string entry,
CancellationToken ct = default)
{
if (!_features.ProofsEnabled)
{
return NotImplementedResult();
}
_logger.LogInformation("Getting receipt for entry {Entry}", entry);
return NotImplementedResult();
}
@@ -78,6 +93,11 @@ public class ProofsController : ControllerBase
[FromRoute] string entry,
CancellationToken ct = default)
{
if (!_features.ProofsEnabled)
{
return NotImplementedResult();
}
_logger.LogInformation("Getting spine for entry {Entry}", entry);
return NotImplementedResult();
}
@@ -97,6 +117,11 @@ public class ProofsController : ControllerBase
[FromRoute] string entry,
CancellationToken ct = default)
{
if (!_features.ProofsEnabled)
{
return NotImplementedResult();
}
_logger.LogInformation("Getting VEX for entry {Entry}", entry);
return NotImplementedResult();
}

View File

@@ -4,12 +4,16 @@ using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.Core.Signing;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Attestor.WebService.Contracts;
using StellaOps.Attestor.WebService.Options;
namespace StellaOps.Attestor.WebService.Controllers;
@@ -19,20 +23,28 @@ namespace StellaOps.Attestor.WebService.Controllers;
[ApiController]
[Route("internal/api/v1/attestations")]
[Produces("application/json")]
[Authorize("attestor:write")]
[ProducesResponseType(StatusCodes.Status501NotImplemented)]
public class VerdictController : ControllerBase
{
private readonly IAttestationSigningService _signingService;
private readonly ILogger<VerdictController> _logger;
private readonly IHttpClientFactory? _httpClientFactory;
private readonly AttestorWebServiceFeatures _features;
private readonly TimeProvider _timeProvider;
public VerdictController(
IAttestationSigningService signingService,
ILogger<VerdictController> logger,
IHttpClientFactory? httpClientFactory = null)
IHttpClientFactory? httpClientFactory = null,
IOptions<AttestorWebServiceFeatures>? features = null,
TimeProvider? timeProvider = null)
{
_signingService = signingService ?? throw new ArgumentNullException(nameof(signingService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_httpClientFactory = httpClientFactory;
_features = features?.Value ?? new AttestorWebServiceFeatures();
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
@@ -42,6 +54,7 @@ public class VerdictController : ControllerBase
/// <param name="ct">Cancellation token.</param>
/// <returns>The created verdict attestation response.</returns>
[HttpPost("verdict")]
[EnableRateLimiting("attestor-submissions")]
[ProducesResponseType(typeof(VerdictAttestationResponseDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
@@ -51,6 +64,11 @@ public class VerdictController : ControllerBase
{
try
{
if (!_features.VerdictsEnabled)
{
return NotImplementedResult();
}
_logger.LogInformation(
"Creating verdict attestation for subject {SubjectName}",
request.Subject.Name);
@@ -136,7 +154,7 @@ public class VerdictController : ControllerBase
Envelope = Convert.ToBase64String(Encoding.UTF8.GetBytes(envelopeJson)),
RekorLogIndex = rekorLogIndex,
KeyId = signResult.KeyId ?? request.KeyId ?? "default",
CreatedAt = DateTimeOffset.UtcNow.ToString("O")
CreatedAt = _timeProvider.GetUtcNow().ToString("O")
};
_logger.LogInformation(
@@ -240,7 +258,9 @@ public class VerdictController : ControllerBase
var predicate = JsonSerializer.Deserialize<JsonElement>(predicateJson);
// Extract verdict metadata from predicate
var (verdictStatus, verdictSeverity, verdictScore, evaluatedAt, determinismHash, policyRunId, policyId, policyVersion) = ExtractVerdictMetadata(predicate);
var fallbackEvaluatedAt = _timeProvider.GetUtcNow();
var (verdictStatus, verdictSeverity, verdictScore, evaluatedAt, determinismHash, policyRunId, policyId, policyVersion) =
ExtractVerdictMetadata(predicate, fallbackEvaluatedAt);
// Create Evidence Locker storage request
var storeRequest = new
@@ -295,7 +315,7 @@ public class VerdictController : ControllerBase
/// Tuple of (status, severity, score, evaluatedAt, determinismHash, policyRunId, policyId, policyVersion)
/// </returns>
private static (string status, string severity, decimal score, DateTimeOffset evaluatedAt, string? determinismHash, string policyRunId, string policyId, int policyVersion)
ExtractVerdictMetadata(JsonElement predicate)
ExtractVerdictMetadata(JsonElement predicate, DateTimeOffset fallbackEvaluatedAt)
{
try
{
@@ -310,7 +330,7 @@ public class VerdictController : ControllerBase
var status = "unknown";
var severity = "unknown";
var score = 0.0m;
var evaluatedAt = DateTimeOffset.UtcNow;
var evaluatedAt = fallbackEvaluatedAt;
string? determinismHash = null;
var policyRunId = "unknown";
var policyId = "unknown";
@@ -380,7 +400,20 @@ public class VerdictController : ControllerBase
catch (Exception)
{
// If parsing fails, return defaults (non-fatal)
return ("unknown", "unknown", 0.0m, DateTimeOffset.UtcNow, null, "unknown", "unknown", 1);
return ("unknown", "unknown", 0.0m, fallbackEvaluatedAt, null, "unknown", "unknown", 1);
}
}
private static ObjectResult NotImplementedResult()
{
return new ObjectResult(new ProblemDetails
{
Title = "Verdict attestation is not enabled.",
Status = StatusCodes.Status501NotImplemented,
Extensions = { ["code"] = "feature_not_implemented" }
})
{
StatusCode = StatusCodes.Status501NotImplemented
};
}
}

View File

@@ -1,7 +1,9 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.WebService.Contracts.Proofs;
using StellaOps.Attestor.WebService.Options;
namespace StellaOps.Attestor.WebService.Controllers;
@@ -11,14 +13,17 @@ namespace StellaOps.Attestor.WebService.Controllers;
[ApiController]
[Route("verify")]
[Produces("application/json")]
[ProducesResponseType(StatusCodes.Status501NotImplemented)]
public class VerifyController : ControllerBase
{
private readonly ILogger<VerifyController> _logger;
private readonly AttestorWebServiceFeatures _features;
// TODO: Inject IVerificationPipeline
public VerifyController(ILogger<VerifyController> logger)
public VerifyController(ILogger<VerifyController> logger, IOptions<AttestorWebServiceFeatures> features)
{
_logger = logger;
_features = features.Value;
}
/// <summary>
@@ -39,6 +44,11 @@ public class VerifyController : ControllerBase
[FromBody] VerifyProofRequest? request,
CancellationToken ct = default)
{
if (!_features.VerifyEnabled)
{
return NotImplementedResult();
}
_logger.LogInformation("Verifying proof bundle {BundleId}", proofBundleId);
return NotImplementedResult();
}
@@ -52,6 +62,11 @@ public class VerifyController : ControllerBase
[FromRoute] string envelopeHash,
CancellationToken ct = default)
{
if (!_features.VerifyEnabled)
{
return NotImplementedResult();
}
_logger.LogInformation("Verifying envelope {Hash}", envelopeHash);
return NotImplementedResult();
}
@@ -65,6 +80,11 @@ public class VerifyController : ControllerBase
[FromRoute] string envelopeHash,
CancellationToken ct = default)
{
if (!_features.VerifyEnabled)
{
return NotImplementedResult();
}
_logger.LogInformation("Verifying Rekor inclusion for {Hash}", envelopeHash);
return NotImplementedResult();
}

View File

@@ -1,35 +1,10 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Authentication;
using System.Security.Cryptography;
using System.Security.Claims;
using System.Security.Cryptography.X509Certificates;
using System.Text.Encodings.Web;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using Serilog;
using Serilog.Events;
using StellaOps.Attestor.Core.Offline;
using System.Text.Encodings.Web;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Attestor.Core.Signing;
using StellaOps.Attestor.Infrastructure;
using StellaOps.Configuration;
using StellaOps.Auth.ServerIntegration;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
using StellaOps.Attestor.Core.Observability;
using StellaOps.Attestor.Core.Storage;
using StellaOps.Attestor.Core.Verification;
using StellaOps.Attestor.WebService;
using StellaOps.Attestor.WebService.Contracts;
using StellaOps.Attestor.Core.Bulk;
using Microsoft.AspNetCore.Server.Kestrel.Https;
using Serilog.Context;
using StellaOps.Cryptography.DependencyInjection;
using StellaOps.Configuration;
using StellaOps.Router.AspNet;
const string ConfigurationSection = "attestor";
@@ -43,418 +18,27 @@ builder.Configuration.AddStellaOpsDefaults(options =>
options.BindingSection = ConfigurationSection;
});
builder.Host.UseSerilog((context, services, loggerConfiguration) =>
{
loggerConfiguration
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.Enrich.FromLogContext()
.WriteTo.Console();
});
builder.ConfigureAttestorLogging();
var attestorOptions = builder.Configuration.BindOptions<AttestorOptions>(ConfigurationSection);
var attestorOptions = builder.BindAttestorOptions(ConfigurationSection);
var clientCertificateAuthorities = AttestorWebServiceComposition.LoadClientCertificateAuthorities(attestorOptions.Security.Mtls.CaBundle);
var clientCertificateAuthorities = LoadClientCertificateAuthorities(attestorOptions.Security.Mtls.CaBundle);
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton(attestorOptions);
builder.Services.AddStellaOpsCryptoRu(builder.Configuration, CryptoProviderRegistryValidator.EnforceRuLinuxDefaults);
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = static (context, _) =>
{
context.HttpContext.Response.Headers.TryAdd("Retry-After", "1");
return ValueTask.CompletedTask;
};
static string ResolveIdentity(HttpContext httpContext)
{
return httpContext.Connection.ClientCertificate?.Thumbprint
?? httpContext.User.FindFirst("sub")?.Value
?? httpContext.User.FindFirst("client_id")?.Value
?? httpContext.Connection.RemoteIpAddress?.ToString()
?? "anonymous";
}
RateLimitPartition<string> BuildTokenBucket(HttpContext httpContext, AttestorOptions.PerCallerQuotaOptions quota)
{
var identity = ResolveIdentity(httpContext);
var tokensPerPeriod = Math.Max(1, quota.Qps);
var tokenLimit = Math.Max(tokensPerPeriod, quota.Burst);
var queueLimit = Math.Max(quota.Burst, tokensPerPeriod);
return RateLimitPartition.GetTokenBucketLimiter(identity, _ => new TokenBucketRateLimiterOptions
{
TokenLimit = tokenLimit,
TokensPerPeriod = tokensPerPeriod,
ReplenishmentPeriod = TimeSpan.FromSeconds(1),
QueueLimit = queueLimit,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
AutoReplenishment = true
});
}
var perCallerQuota = attestorOptions.Quotas.PerCaller;
options.AddPolicy("attestor-submissions", httpContext => BuildTokenBucket(httpContext, perCallerQuota));
options.AddPolicy("attestor-verifications", httpContext => BuildTokenBucket(httpContext, perCallerQuota));
options.AddPolicy("attestor-reads", httpContext => BuildTokenBucket(httpContext, perCallerQuota));
options.AddPolicy("attestor-bulk", httpContext =>
{
var identity = ResolveIdentity(httpContext);
var bulkQuota = attestorOptions.Quotas.Bulk;
var permitLimit = Math.Max(1, bulkQuota.RequestsPerMinute);
var queueLimit = Math.Max(0, bulkQuota.RequestsPerMinute / 2);
return RateLimitPartition.GetFixedWindowLimiter(identity, _ => new FixedWindowRateLimiterOptions
{
PermitLimit = permitLimit,
Window = TimeSpan.FromMinutes(1),
QueueLimit = queueLimit,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
});
});
});
builder.Services.AddOptions<AttestorOptions>()
.Bind(builder.Configuration.GetSection(ConfigurationSection))
.ValidateOnStart();
builder.Services.AddProblemDetails();
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddAttestorInfrastructure();
// Register Proof Chain services
builder.Services.AddScoped<StellaOps.Attestor.WebService.Services.IProofChainQueryService,
StellaOps.Attestor.WebService.Services.ProofChainQueryService>();
builder.Services.AddScoped<StellaOps.Attestor.WebService.Services.IProofVerificationService,
StellaOps.Attestor.WebService.Services.ProofVerificationService>();
// Register Standard Predicate services (SPDX, CycloneDX, SLSA parsers)
builder.Services.AddSingleton<StellaOps.Attestor.StandardPredicates.IStandardPredicateRegistry>(sp =>
{
var registry = new StellaOps.Attestor.StandardPredicates.StandardPredicateRegistry();
// Register standard predicate parsers
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var spdxParser = new StellaOps.Attestor.StandardPredicates.Parsers.SpdxPredicateParser(
loggerFactory.CreateLogger<StellaOps.Attestor.StandardPredicates.Parsers.SpdxPredicateParser>());
registry.Register(spdxParser.PredicateType, spdxParser);
var cycloneDxParser = new StellaOps.Attestor.StandardPredicates.Parsers.CycloneDxPredicateParser(
loggerFactory.CreateLogger<StellaOps.Attestor.StandardPredicates.Parsers.CycloneDxPredicateParser>());
registry.Register(cycloneDxParser.PredicateType, cycloneDxParser);
var slsaParser = new StellaOps.Attestor.StandardPredicates.Parsers.SlsaProvenancePredicateParser(
loggerFactory.CreateLogger<StellaOps.Attestor.StandardPredicates.Parsers.SlsaProvenancePredicateParser>());
registry.Register(slsaParser.PredicateType, slsaParser);
return registry;
});
builder.Services.AddScoped<StellaOps.Attestor.WebService.Services.IPredicateTypeRouter,
StellaOps.Attestor.WebService.Services.PredicateTypeRouter>();
builder.Services.AddHttpContextAccessor();
// Configure HttpClient for Evidence Locker integration
var evidenceLockerUrl = builder.Configuration.GetValue<string>("EvidenceLocker:BaseUrl")
?? builder.Configuration.GetValue<string>("EvidenceLockerUrl");
if (string.IsNullOrWhiteSpace(evidenceLockerUrl))
{
throw new InvalidOperationException("EvidenceLocker base URL must be configured (EvidenceLocker:BaseUrl or EvidenceLockerUrl).");
}
builder.Services.AddHttpClient("EvidenceLocker", client =>
{
client.BaseAddress = new Uri(evidenceLockerUrl, UriKind.Absolute);
client.Timeout = TimeSpan.FromSeconds(30);
});
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy());
var openTelemetry = builder.Services.AddOpenTelemetry();
openTelemetry.WithMetrics(metricsBuilder =>
{
metricsBuilder.AddMeter(AttestorMetrics.MeterName);
metricsBuilder.AddAspNetCoreInstrumentation();
metricsBuilder.AddRuntimeInstrumentation();
});
if (attestorOptions.Telemetry.EnableTracing)
{
openTelemetry.WithTracing(tracingBuilder =>
{
tracingBuilder.AddSource(AttestorActivitySource.Name);
tracingBuilder.AddAspNetCoreInstrumentation();
tracingBuilder.AddHttpClientInstrumentation();
});
}
if (attestorOptions.Security.Authority is { Issuer: not null } authority)
{
builder.Services.AddAuthentication();
builder.Services.AddStellaOpsResourceServerAuthentication(
builder.Configuration,
configurationSection: null,
configure: resourceOptions =>
{
resourceOptions.Authority = authority.Issuer!;
resourceOptions.RequireHttpsMetadata = authority.RequireHttpsMetadata;
if (!string.IsNullOrWhiteSpace(authority.JwksUrl))
{
resourceOptions.MetadataAddress = authority.JwksUrl;
}
foreach (var audience in authority.Audiences)
{
resourceOptions.Audiences.Add(audience);
}
if (authority.RequiredScopes.Count == 0)
{
resourceOptions.RequiredScopes.Add("attestor.write");
resourceOptions.RequiredScopes.Add("attestor.verify");
resourceOptions.RequiredScopes.Add("attestor.read");
}
else
{
foreach (var scope in authority.RequiredScopes)
{
resourceOptions.RequiredScopes.Add(scope);
}
}
});
}
else
{
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = NoAuthHandler.SchemeName;
options.DefaultChallengeScheme = NoAuthHandler.SchemeName;
}).AddScheme<AuthenticationSchemeOptions, NoAuthHandler>(
authenticationScheme: NoAuthHandler.SchemeName,
displayName: null,
configureOptions: options => { options.TimeProvider ??= TimeProvider.System; });
}
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("attestor:write", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireAssertion(context => HasAnyScope(context.User, "attestor.write"));
});
options.AddPolicy("attestor:verify", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireAssertion(context => HasAnyScope(context.User, "attestor.verify", "attestor.write"));
});
options.AddPolicy("attestor:read", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireAssertion(context => HasAnyScope(context.User, "attestor.read", "attestor.verify", "attestor.write"));
});
});
builder.WebHost.ConfigureKestrel(kestrel =>
{
kestrel.ConfigureHttpsDefaults(https =>
{
if (attestorOptions.Security.Mtls.RequireClientCertificate)
{
https.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
}
https.SslProtocols = SslProtocols.Tls13 | SslProtocols.Tls12;
https.ClientCertificateValidation = (certificate, _, _) =>
{
if (!attestorOptions.Security.Mtls.RequireClientCertificate)
{
return true;
}
if (certificate is null)
{
Log.Warning("Client certificate missing");
return false;
}
if (clientCertificateAuthorities.Count > 0)
{
using var chain = new X509Chain
{
ChainPolicy =
{
RevocationMode = X509RevocationMode.NoCheck,
TrustMode = X509ChainTrustMode.CustomRootTrust
}
};
foreach (var authority in clientCertificateAuthorities)
{
chain.ChainPolicy.CustomTrustStore.Add(authority);
}
if (!chain.Build(certificate))
{
Log.Warning("Client certificate chain validation failed for {Subject}", certificate.Subject);
return false;
}
}
if (attestorOptions.Security.Mtls.AllowedThumbprints.Count > 0 &&
!attestorOptions.Security.Mtls.AllowedThumbprints.Contains(certificate.Thumbprint ?? string.Empty, StringComparer.OrdinalIgnoreCase))
{
Log.Warning("Client certificate thumbprint {Thumbprint} rejected", certificate.Thumbprint);
return false;
}
if (attestorOptions.Security.Mtls.AllowedSubjects.Count > 0 &&
!attestorOptions.Security.Mtls.AllowedSubjects.Contains(certificate.Subject, StringComparer.OrdinalIgnoreCase))
{
Log.Warning("Client certificate subject {Subject} rejected", certificate.Subject);
return false;
}
return true;
};
});
});
builder.AddAttestorWebService(attestorOptions, ConfigurationSection);
builder.WebHost.ConfigureAttestorKestrel(attestorOptions, clientCertificateAuthorities);
// 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);
serviceName: "attestor",
version: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0",
routerOptions: routerOptions);
var app = builder.Build();
app.UseSerilogRequestLogging();
app.Use(async (context, next) =>
{
var correlationId = context.Request.Headers["X-Correlation-Id"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(correlationId))
{
correlationId = Guid.NewGuid().ToString("N");
}
context.Response.Headers["X-Correlation-Id"] = correlationId;
using (LogContext.PushProperty("CorrelationId", correlationId))
{
await next().ConfigureAwait(false);
}
});
app.UseExceptionHandler(static handler =>
{
handler.Run(async context =>
{
var result = Results.Problem(statusCode: StatusCodes.Status500InternalServerError);
await result.ExecuteAsync(context);
});
});
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.TryUseStellaRouter(routerOptions);
app.MapHealthChecks("/health/ready");
app.MapHealthChecks("/health/live");
app.MapControllers();
app.MapAttestorEndpoints(attestorOptions);
// Refresh Router endpoint cache
app.TryRefreshStellaRouterEndpoints(routerOptions);
app.UseAttestorWebService(attestorOptions, routerOptions);
app.Run();
static List<X509Certificate2> LoadClientCertificateAuthorities(string? path)
{
var certificates = new List<X509Certificate2>();
if (string.IsNullOrWhiteSpace(path))
{
return certificates;
}
try
{
if (!File.Exists(path))
{
Log.Warning("Client CA bundle '{Path}' not found", path);
return certificates;
}
var collection = new X509Certificate2Collection();
collection.ImportFromPemFile(path);
certificates.AddRange(collection.Cast<X509Certificate2>());
}
catch (Exception ex) when (ex is IOException or CryptographicException)
{
Log.Warning(ex, "Failed to load client CA bundle from {Path}", path);
}
return certificates;
}
static bool HasAnyScope(ClaimsPrincipal user, params string[] scopes)
{
if (user?.Identity is not { IsAuthenticated: true } || scopes.Length == 0)
{
return false;
}
foreach (var granted in ExtractScopes(user))
{
foreach (var required in scopes)
{
if (string.Equals(granted, required, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
return false;
}
static IEnumerable<string> ExtractScopes(ClaimsPrincipal user)
{
foreach (var claim in user.FindAll("scope"))
{
foreach (var value in claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
yield return value;
}
}
foreach (var claim in user.FindAll("scp"))
{
foreach (var value in claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
yield return value;
}
}
}
internal sealed class NoAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "NoAuth";

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0072-M | DONE | Maintainability audit for StellaOps.Attestor.WebService. |
| AUDIT-0072-T | DONE | Test coverage audit for StellaOps.Attestor.WebService. |
| AUDIT-0072-A | DOING | Addressing WebService audit findings. |
| AUDIT-0072-A | DONE | Addressed WebService audit findings (composition split, feature gating, auth/rate limits, TimeProvider, tests). |

View File

@@ -0,0 +1,31 @@
// JsonCanonicalizerTests - RFC 8785 canonicalization tests for TrustVerdict
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
using FluentAssertions;
using StellaOps.Attestor.TrustVerdict;
using Xunit;
namespace StellaOps.Attestor.TrustVerdict.Tests;
public class JsonCanonicalizerTests
{
[Fact]
public void Canonicalize_OrdersKeysAndRemovesWhitespace()
{
var input = "{ \"b\": 1, \"a\": 2 }";
var canonical = JsonCanonicalizer.Canonicalize(input);
canonical.Should().Be("{\"a\":2,\"b\":1}");
}
[Fact]
public void Canonicalize_NormalizesExponentNumbers()
{
var input = "{\"n\":1e0}";
var canonical = JsonCanonicalizer.Canonicalize(input);
canonical.Should().Be("{\"n\":1}");
}
}

View File

@@ -63,14 +63,17 @@ public class TrustEvidenceMerkleBuilderTests
}
[Fact]
public void Build_SortsItemsByDigest()
public void Build_SortsItemsByDigestWithTieBreakers()
{
// Arrange - Items in reverse order
// Arrange - Items with matching digests but different tie-breakers
var items = new[]
{
CreateEvidenceItem("sha256:zzz"),
CreateEvidenceItem("sha256:aaa"),
CreateEvidenceItem("sha256:mmm")
CreateEvidenceItem("sha256:dup", TrustEvidenceTypes.Signature, "https://example.com/b", "b",
new DateTimeOffset(2025, 1, 15, 12, 2, 0, TimeSpan.Zero)),
CreateEvidenceItem("sha256:dup", TrustEvidenceTypes.Certificate, "https://example.com/a", "a",
new DateTimeOffset(2025, 1, 15, 12, 1, 0, TimeSpan.Zero)),
CreateEvidenceItem("sha256:dup", TrustEvidenceTypes.VexDocument, "https://example.com/a", "c",
new DateTimeOffset(2025, 1, 15, 12, 3, 0, TimeSpan.Zero))
};
// Act
@@ -78,7 +81,16 @@ public class TrustEvidenceMerkleBuilderTests
// Assert
tree.LeafCount.Should().Be(3);
// First leaf should correspond to "sha256:aaa"
var expectedOrder = items
.OrderBy(i => i.Digest, StringComparer.Ordinal)
.ThenBy(i => i.Type, StringComparer.Ordinal)
.ThenBy(i => i.Uri ?? string.Empty, StringComparer.Ordinal)
.ThenBy(i => i.Description ?? string.Empty, StringComparer.Ordinal)
.ThenBy(i => i.CollectedAt?.ToUniversalTime())
.Select(i => ToDigestString(_builder.ComputeLeafHash(i)))
.ToList();
tree.LeafHashes.Should().BeEquivalentTo(expectedOrder, opts => opts.WithStrictOrdering());
}
[Fact]
@@ -174,7 +186,13 @@ public class TrustEvidenceMerkleBuilderTests
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 sortedItems = items
.OrderBy(i => i.Digest, StringComparer.Ordinal)
.ThenBy(i => i.Type, StringComparer.Ordinal)
.ThenBy(i => i.Uri ?? string.Empty, StringComparer.Ordinal)
.ThenBy(i => i.Description ?? string.Empty, StringComparer.Ordinal)
.ThenBy(i => i.CollectedAt?.ToUniversalTime())
.ToList();
var item = sortedItems[1];
// Act
@@ -259,7 +277,13 @@ public class TrustEvidenceMerkleBuilderTests
CreateEvidenceItem("sha256:bbb")
};
var tree = _builder.Build(items);
var chain = tree.ToEvidenceChain(items.OrderBy(i => i.Digest).ToList());
var chain = tree.ToEvidenceChain(items
.OrderBy(i => i.Digest, StringComparer.Ordinal)
.ThenBy(i => i.Type, StringComparer.Ordinal)
.ThenBy(i => i.Uri ?? string.Empty, StringComparer.Ordinal)
.ThenBy(i => i.Description ?? string.Empty, StringComparer.Ordinal)
.ThenBy(i => i.CollectedAt?.ToUniversalTime())
.ToList());
// Act
var valid = _builder.ValidateChain(chain);
@@ -314,7 +338,13 @@ public class TrustEvidenceMerkleBuilderTests
for (var i = 0; i < count; i++)
{
var proof = tree.GenerateProof(i);
var sortedItems = items.OrderBy(x => x.Digest).ToList();
var sortedItems = items
.OrderBy(x => x.Digest, StringComparer.Ordinal)
.ThenBy(x => x.Type, StringComparer.Ordinal)
.ThenBy(x => x.Uri ?? string.Empty, StringComparer.Ordinal)
.ThenBy(x => x.Description ?? string.Empty, StringComparer.Ordinal)
.ThenBy(x => x.CollectedAt?.ToUniversalTime())
.ToList();
var valid = _builder.VerifyProof(sortedItems[i], proof, tree.Root);
valid.Should().BeTrue($"proof for index {i} should be valid");
}
@@ -342,14 +372,19 @@ public class TrustEvidenceMerkleBuilderTests
private static TrustEvidenceItem CreateEvidenceItem(
string digest,
string type = TrustEvidenceTypes.VexDocument,
string? uri = null)
string? uri = null,
string? description = null,
DateTimeOffset? collectedAt = null)
{
return new TrustEvidenceItem
{
Type = type,
Digest = digest,
Uri = uri,
CollectedAt = new DateTimeOffset(2025, 1, 15, 12, 0, 0, TimeSpan.Zero)
Description = description,
CollectedAt = collectedAt ?? new DateTimeOffset(2025, 1, 15, 12, 0, 0, TimeSpan.Zero)
};
}
private static string ToDigestString(byte[] hash) => $"sha256:{Convert.ToHexStringLower(hash)}";
}

View File

@@ -2,10 +2,12 @@
// 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.Caching;
using StellaOps.Attestor.TrustVerdict.Predicates;
using System.Reflection;
using Xunit;
namespace StellaOps.Attestor.TrustVerdict.Tests;
@@ -101,6 +103,47 @@ public class TrustVerdictCacheTests
result.Should().BeNull();
}
[Fact]
public async Task Get_UpdatesHitCountInCache()
{
// Arrange
var entry = CreateCacheEntry("sha256:verdict1", "sha256:vex1", "tenant1");
await _cache.SetAsync(entry);
// Act
var first = await _cache.GetAsync("sha256:verdict1");
var second = await _cache.GetAsync("sha256:verdict1");
// Assert
first.Should().NotBeNull();
second.Should().NotBeNull();
first!.HitCount.Should().Be(1);
second!.HitCount.Should().Be(2);
}
[Fact]
public async Task GetByVexDigest_RemovesExpiredIndex()
{
// Arrange
var entry = CreateCacheEntry(
"sha256:verdict1",
"sha256:vex1",
"tenant1",
expiresAt: _timeProvider.GetUtcNow().AddMinutes(1));
await _cache.SetAsync(entry);
_timeProvider.Advance(TimeSpan.FromMinutes(2));
// Act
var result = await _cache.GetByVexDigestAsync("sha256:vex1", "tenant1");
// Assert
result.Should().BeNull();
var index = GetVexIndex(_cache);
index.Should().BeEmpty("expired entries should evict the VEX index mapping");
}
[Fact]
public async Task Invalidate_RemovesEntry()
{
@@ -151,6 +194,27 @@ public class TrustVerdictCacheTests
results.Should().NotContainKey("sha256:vex4");
}
[Fact]
public async Task GetBatch_RemovesExpiredEntries()
{
// Arrange
await _cache.SetAsync(CreateCacheEntry(
"sha256:v1",
"sha256:vex1",
"tenant1",
expiresAt: _timeProvider.GetUtcNow().AddMinutes(1)));
_timeProvider.Advance(TimeSpan.FromMinutes(2));
// Act
var results = await _cache.GetBatchAsync(["sha256:vex1"], "tenant1");
// Assert
results.Should().BeEmpty();
var index = GetVexIndex(_cache);
index.Should().BeEmpty();
}
[Fact]
public async Task Set_EvictsOldestWhenFull()
{
@@ -230,6 +294,21 @@ public class TrustVerdictCacheTests
result!.Predicate.Composite.Score.Should().Be(0.99m);
}
[Fact]
public async Task ValkeyCache_WhenEnabled_ThrowsNotSupported()
{
// Arrange
var options = CreateOptions(new TrustVerdictCacheOptions { UseValkey = true });
var cache = new ValkeyTrustVerdictCache(options, NullLogger<ValkeyTrustVerdictCache>.Instance, _timeProvider);
// Act
Func<Task> act = () => cache.GetAsync("sha256:verdict1");
// Assert
await act.Should().ThrowAsync<NotSupportedException>()
.WithMessage("*TrustVerdictCache:UseValkey*");
}
private TrustVerdictCacheEntry CreateCacheEntry(
string verdictDigest,
string vexDigest,
@@ -297,4 +376,12 @@ public class TrustVerdictCacheTests
monitor.Setup(m => m.CurrentValue).Returns(options);
return monitor.Object;
}
private static Dictionary<string, string> GetVexIndex(InMemoryTrustVerdictCache cache)
{
var field = typeof(InMemoryTrustVerdictCache)
.GetField("_vexToVerdictIndex", BindingFlags.NonPublic | BindingFlags.Instance);
field.Should().NotBeNull("vex index should exist");
return (Dictionary<string, string>)field!.GetValue(cache)!;
}
}

View File

@@ -0,0 +1,92 @@
// TrustVerdictOciAttacherTests - Tests for OCI attacher stub behavior
// 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.Oci;
using Xunit;
namespace StellaOps.Attestor.TrustVerdict.Tests;
public class TrustVerdictOciAttacherTests
{
[Fact]
public async Task Attach_WhenDisabled_ReturnsFailure()
{
var options = CreateOptions(new TrustVerdictOciOptions { Enabled = false });
var attacher = new TrustVerdictOciAttacher(options, NullLogger<TrustVerdictOciAttacher>.Instance);
var result = await attacher.AttachAsync("registry.example/repo:tag", "ZXhhbXBsZQ==", "sha256:verdict");
result.Success.Should().BeFalse();
result.ErrorMessage.Should().Be("OCI attachment is disabled");
}
[Fact]
public async Task Attach_InvalidReference_ReturnsFailure()
{
var options = CreateOptions(new TrustVerdictOciOptions { Enabled = true, DefaultRegistry = "registry.example" });
var attacher = new TrustVerdictOciAttacher(options, NullLogger<TrustVerdictOciAttacher>.Instance);
var result = await attacher.AttachAsync("not-a-ref", "ZXhhbXBsZQ==", "sha256:verdict");
result.Success.Should().BeFalse();
result.ErrorMessage.Should().StartWith("Invalid OCI reference:");
}
[Fact]
public async Task Attach_WhenEnabled_ReturnsNotImplemented()
{
var options = CreateOptions(new TrustVerdictOciOptions { Enabled = true, DefaultRegistry = "registry.example" });
var timeProvider = new FakeTimeProvider();
var attacher = new TrustVerdictOciAttacher(options, NullLogger<TrustVerdictOciAttacher>.Instance, timeProvider: timeProvider);
var result = await attacher.AttachAsync("repo:tag", "ZXhhbXBsZQ==", "sha256:verdict");
result.Success.Should().BeFalse();
result.ErrorMessage.Should().Be("OCI attachment is not implemented.");
result.Duration.Should().BeGreaterThanOrEqualTo(TimeSpan.Zero);
}
[Fact]
public async Task Fetch_InvalidReference_ReturnsNull()
{
var options = CreateOptions(new TrustVerdictOciOptions { Enabled = true, DefaultRegistry = "registry.example" });
var attacher = new TrustVerdictOciAttacher(options, NullLogger<TrustVerdictOciAttacher>.Instance);
var result = await attacher.FetchAsync("not-a-ref");
result.Should().BeNull();
}
[Fact]
public async Task List_InvalidReference_ReturnsEmpty()
{
var options = CreateOptions(new TrustVerdictOciOptions { Enabled = true, DefaultRegistry = "registry.example" });
var attacher = new TrustVerdictOciAttacher(options, NullLogger<TrustVerdictOciAttacher>.Instance);
var result = await attacher.ListAsync("not-a-ref");
result.Should().BeEmpty();
}
[Fact]
public async Task Detach_WhenEnabled_ReturnsFalse()
{
var options = CreateOptions(new TrustVerdictOciOptions { Enabled = true, DefaultRegistry = "registry.example" });
var attacher = new TrustVerdictOciAttacher(options, NullLogger<TrustVerdictOciAttacher>.Instance);
var result = await attacher.DetachAsync("repo:tag", "sha256:verdict");
result.Should().BeFalse();
}
private static IOptionsMonitor<TrustVerdictOciOptions> CreateOptions(TrustVerdictOciOptions options)
{
var monitor = new Moq.Mock<IOptionsMonitor<TrustVerdictOciOptions>>();
monitor.Setup(m => m.CurrentValue).Returns(options);
return monitor.Object;
}
}

View File

@@ -0,0 +1,144 @@
// TrustVerdictRepositoryMappingTests - Repository mapping tests
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
using System.Data;
using System.Data.Common;
using FluentAssertions;
using StellaOps.Attestor.TrustVerdict.Persistence;
using Xunit;
namespace StellaOps.Attestor.TrustVerdict.Tests;
public class TrustVerdictRepositoryMappingTests
{
[Fact]
public void ReadEntity_PreservesDateTimeOffset()
{
var issuedAt = new DateTimeOffset(2025, 2, 10, 8, 30, 0, TimeSpan.FromHours(2));
var evaluatedAt = new DateTimeOffset(2025, 2, 11, 9, 45, 0, TimeSpan.FromHours(-5));
var createdAt = new DateTimeOffset(2025, 2, 12, 10, 15, 0, TimeSpan.FromHours(1));
var expiresAt = new DateTimeOffset(2025, 2, 13, 11, 0, 0, TimeSpan.Zero);
using var reader = CreateReader(issuedAt, evaluatedAt, createdAt, expiresAt);
reader.Read();
var entity = PostgresTrustVerdictRepository.ReadEntity(reader);
entity.FreshnessIssuedAt.Should().Be(issuedAt);
entity.EvaluatedAt.Should().Be(evaluatedAt);
entity.CreatedAt.Should().Be(createdAt);
entity.ExpiresAt.Should().Be(expiresAt);
}
private static DbDataReader CreateReader(
DateTimeOffset issuedAt,
DateTimeOffset evaluatedAt,
DateTimeOffset createdAt,
DateTimeOffset expiresAt)
{
var table = new DataTable();
table.Columns.Add("verdict_id", typeof(string));
table.Columns.Add("tenant_id", typeof(Guid));
table.Columns.Add("vex_digest", typeof(string));
table.Columns.Add("vex_format", typeof(string));
table.Columns.Add("provider_id", typeof(string));
table.Columns.Add("statement_id", typeof(string));
table.Columns.Add("vulnerability_id", typeof(string));
table.Columns.Add("product_key", typeof(string));
table.Columns.Add("vex_status", typeof(string));
table.Columns.Add("origin_valid", typeof(bool));
table.Columns.Add("origin_method", typeof(string));
table.Columns.Add("origin_key_id", typeof(string));
table.Columns.Add("origin_issuer_id", typeof(string));
table.Columns.Add("origin_issuer_name", typeof(string));
table.Columns.Add("origin_rekor_log_index", typeof(long));
table.Columns.Add("origin_score", typeof(decimal));
table.Columns.Add("freshness_status", typeof(string));
table.Columns.Add("freshness_issued_at", typeof(DateTimeOffset));
table.Columns.Add("freshness_expires_at", typeof(DateTimeOffset));
table.Columns.Add("freshness_superseded_by", typeof(string));
table.Columns.Add("freshness_age_days", typeof(int));
table.Columns.Add("freshness_score", typeof(decimal));
table.Columns.Add("reputation_composite", typeof(decimal));
table.Columns.Add("reputation_authority", typeof(decimal));
table.Columns.Add("reputation_accuracy", typeof(decimal));
table.Columns.Add("reputation_timeliness", typeof(decimal));
table.Columns.Add("reputation_coverage", typeof(decimal));
table.Columns.Add("reputation_verification", typeof(decimal));
table.Columns.Add("reputation_sample_count", typeof(int));
table.Columns.Add("trust_score", typeof(decimal));
table.Columns.Add("trust_tier", typeof(string));
table.Columns.Add("trust_formula", typeof(string));
table.Columns.Add("trust_reasons", typeof(string[]));
table.Columns.Add("meets_policy_threshold", typeof(bool));
table.Columns.Add("policy_threshold", typeof(decimal));
table.Columns.Add("evidence_merkle_root", typeof(string));
table.Columns.Add("evidence_items_json", typeof(string));
table.Columns.Add("envelope_base64", typeof(string));
table.Columns.Add("verdict_digest", typeof(string));
table.Columns.Add("evaluated_at", typeof(DateTimeOffset));
table.Columns.Add("evaluator_version", typeof(string));
table.Columns.Add("crypto_profile", typeof(string));
table.Columns.Add("policy_digest", typeof(string));
table.Columns.Add("environment", typeof(string));
table.Columns.Add("correlation_id", typeof(string));
table.Columns.Add("oci_digest", typeof(string));
table.Columns.Add("rekor_log_index", typeof(long));
table.Columns.Add("created_at", typeof(DateTimeOffset));
table.Columns.Add("expires_at", typeof(DateTimeOffset));
var row = table.NewRow();
row["verdict_id"] = "verdict-1";
row["tenant_id"] = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
row["vex_digest"] = "sha256:vex";
row["vex_format"] = "openvex";
row["provider_id"] = "provider";
row["statement_id"] = "statement";
row["vulnerability_id"] = "CVE-2025-1234";
row["product_key"] = "pkg:npm/test@1.0.0";
row["vex_status"] = DBNull.Value;
row["origin_valid"] = true;
row["origin_method"] = "dsse";
row["origin_key_id"] = DBNull.Value;
row["origin_issuer_id"] = DBNull.Value;
row["origin_issuer_name"] = DBNull.Value;
row["origin_rekor_log_index"] = DBNull.Value;
row["origin_score"] = 0.9m;
row["freshness_status"] = "fresh";
row["freshness_issued_at"] = issuedAt;
row["freshness_expires_at"] = DBNull.Value;
row["freshness_superseded_by"] = DBNull.Value;
row["freshness_age_days"] = 0;
row["freshness_score"] = 1.0m;
row["reputation_composite"] = 0.8m;
row["reputation_authority"] = 0.8m;
row["reputation_accuracy"] = 0.8m;
row["reputation_timeliness"] = 0.8m;
row["reputation_coverage"] = 0.8m;
row["reputation_verification"] = 0.8m;
row["reputation_sample_count"] = 10;
row["trust_score"] = 0.9m;
row["trust_tier"] = "High";
row["trust_formula"] = "test";
row["trust_reasons"] = new[] { "reason" };
row["meets_policy_threshold"] = DBNull.Value;
row["policy_threshold"] = DBNull.Value;
row["evidence_merkle_root"] = "sha256:root";
row["evidence_items_json"] = "[]";
row["envelope_base64"] = DBNull.Value;
row["verdict_digest"] = "sha256:verdict";
row["evaluated_at"] = evaluatedAt;
row["evaluator_version"] = "1.0.0";
row["crypto_profile"] = "world";
row["policy_digest"] = DBNull.Value;
row["environment"] = DBNull.Value;
row["correlation_id"] = DBNull.Value;
row["oci_digest"] = DBNull.Value;
row["rekor_log_index"] = DBNull.Value;
row["created_at"] = createdAt;
row["expires_at"] = expiresAt;
table.Rows.Add(row);
return table.CreateDataReader();
}
}

View File

@@ -1,10 +1,12 @@
// TrustVerdictServiceTests - Unit tests for TrustVerdictService
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
using System.Globalization;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Attestor.TrustVerdict.Evidence;
using StellaOps.Attestor.TrustVerdict.Predicates;
using StellaOps.Attestor.TrustVerdict.Services;
using Xunit;
@@ -16,12 +18,14 @@ public class TrustVerdictServiceTests
private readonly FakeTimeProvider _timeProvider;
private readonly IOptionsMonitor<TrustVerdictServiceOptions> _options;
private readonly TrustVerdictService _service;
private readonly ITrustEvidenceMerkleBuilder _merkleBuilder;
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);
_merkleBuilder = new TrustEvidenceMerkleBuilder();
_service = new TrustVerdictService(_options, NullLogger<TrustVerdictService>.Instance, _merkleBuilder, _timeProvider);
}
[Fact]
@@ -327,6 +331,131 @@ public class TrustVerdictServiceTests
digests.Should().BeInAscendingOrder();
}
[Fact]
public async Task GenerateVerdictAsync_EvidenceOrderingUsesTieBreakers()
{
// Arrange
var request = CreateValidRequest() with
{
EvidenceItems =
[
new TrustVerdictEvidenceInput
{
Type = TrustEvidenceTypes.Signature,
Digest = "sha256:dup",
Uri = "https://example.com/b",
Description = "b",
CollectedAt = _timeProvider.GetUtcNow().AddMinutes(2)
},
new TrustVerdictEvidenceInput
{
Type = TrustEvidenceTypes.Certificate,
Digest = "sha256:dup",
Uri = "https://example.com/a",
Description = "a",
CollectedAt = _timeProvider.GetUtcNow().AddMinutes(1)
},
new TrustVerdictEvidenceInput
{
Type = TrustEvidenceTypes.VexDocument,
Digest = "sha256:dup",
Uri = "https://example.com/a",
Description = "c",
CollectedAt = _timeProvider.GetUtcNow().AddMinutes(3)
}
]
};
// Act
var result = await _service.GenerateVerdictAsync(request);
// Assert
var ordered = result.Predicate!.Evidence.Items
.OrderBy(i => i.Digest, StringComparer.Ordinal)
.ThenBy(i => i.Type, StringComparer.Ordinal)
.ThenBy(i => i.Uri ?? string.Empty, StringComparer.Ordinal)
.ThenBy(i => i.Description ?? string.Empty, StringComparer.Ordinal)
.ThenBy(i => i.CollectedAt?.ToUniversalTime())
.ToList();
result.Predicate.Evidence.Items.Should().BeEquivalentTo(ordered, opts => opts.WithStrictOrdering());
}
[Fact]
public async Task GenerateVerdictAsync_EvidenceMerkleRootMatchesBuilder()
{
// Arrange
var request = CreateValidRequest() with
{
EvidenceItems =
[
new TrustVerdictEvidenceInput
{
Type = TrustEvidenceTypes.VexDocument,
Digest = "sha256:vex123",
Uri = "https://example.com/vex/123",
Description = "vex"
},
new TrustVerdictEvidenceInput
{
Type = TrustEvidenceTypes.Signature,
Digest = "sha256:sig456",
Description = "signature"
}
]
};
// Act
var result = await _service.GenerateVerdictAsync(request);
// Assert
var tree = _merkleBuilder.Build(result.Predicate!.Evidence.Items);
result.Predicate.Evidence.MerkleRoot.Should().Be(tree.Root);
}
[Fact]
public async Task GenerateVerdictAsync_UsesInvariantCultureForReasons()
{
var originalCulture = CultureInfo.CurrentCulture;
var originalUiCulture = CultureInfo.CurrentUICulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo("fr-FR");
CultureInfo.CurrentUICulture = new CultureInfo("fr-FR");
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 = 10
}
};
var result = await _service.GenerateVerdictAsync(request);
var reasons = result.Predicate!.Composite.Reasons;
reasons.Should().Contain(r => r.Contains("100%", StringComparison.Ordinal));
reasons.Should().NotContain(r => r.Contains("100 %", StringComparison.Ordinal));
}
finally
{
CultureInfo.CurrentCulture = originalCulture;
CultureInfo.CurrentUICulture = originalUiCulture;
}
}
[Fact]
public async Task GenerateVerdictAsync_ChecksPolicyThreshold()
{

View File

@@ -1,7 +1,6 @@
// 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;
@@ -164,11 +163,13 @@ public sealed class InMemoryTrustVerdictCache : ITrustVerdictCache
if (_timeProvider.GetUtcNow() < entry.ExpiresAt)
{
Interlocked.Increment(ref _hitCount);
return Task.FromResult<TrustVerdictCacheEntry?>(entry with { HitCount = entry.HitCount + 1 });
var updated = entry with { HitCount = entry.HitCount + 1 };
_byVerdictDigest[verdictDigest] = updated;
return Task.FromResult<TrustVerdictCacheEntry?>(updated);
}
// Expired, remove
_byVerdictDigest.Remove(verdictDigest);
RemoveEntryLocked(entry);
Interlocked.Increment(ref _evictionCount);
}
@@ -188,7 +189,25 @@ public sealed class InMemoryTrustVerdictCache : ITrustVerdictCache
{
if (_vexToVerdictIndex.TryGetValue(key, out var verdictDigest))
{
return GetAsync(verdictDigest, ct);
if (!_byVerdictDigest.TryGetValue(verdictDigest, out var entry))
{
_vexToVerdictIndex.Remove(key);
Interlocked.Increment(ref _missCount);
return Task.FromResult<TrustVerdictCacheEntry?>(null);
}
if (_timeProvider.GetUtcNow() < entry.ExpiresAt)
{
Interlocked.Increment(ref _hitCount);
var updated = entry with { HitCount = entry.HitCount + 1 };
_byVerdictDigest[verdictDigest] = updated;
return Task.FromResult<TrustVerdictCacheEntry?>(updated);
}
RemoveEntryLocked(entry);
Interlocked.Increment(ref _evictionCount);
Interlocked.Increment(ref _missCount);
return Task.FromResult<TrustVerdictCacheEntry?>(null);
}
}
@@ -267,15 +286,30 @@ public sealed class InMemoryTrustVerdictCache : ITrustVerdictCache
{
var vexKey = BuildVexKey(vexDigest, tenantId);
if (_vexToVerdictIndex.TryGetValue(vexKey, out var verdictDigest) &&
_byVerdictDigest.TryGetValue(verdictDigest, out var entry) &&
now < entry.ExpiresAt)
if (!_vexToVerdictIndex.TryGetValue(vexKey, out var verdictDigest))
{
Interlocked.Increment(ref _missCount);
continue;
}
if (!_byVerdictDigest.TryGetValue(verdictDigest, out var entry))
{
_vexToVerdictIndex.Remove(vexKey);
Interlocked.Increment(ref _missCount);
continue;
}
if (now < entry.ExpiresAt)
{
results[vexDigest] = entry;
Interlocked.Increment(ref _hitCount);
var updated = entry with { HitCount = entry.HitCount + 1 };
_byVerdictDigest[verdictDigest] = updated;
results[vexDigest] = updated;
}
else
{
RemoveEntryLocked(entry);
Interlocked.Increment(ref _evictionCount);
Interlocked.Increment(ref _missCount);
}
}
@@ -312,13 +346,18 @@ public sealed class InMemoryTrustVerdictCache : ITrustVerdictCache
if (oldest != null)
{
_byVerdictDigest.Remove(oldest.VerdictDigest);
var vexKey = BuildVexKey(oldest.VexDigest, oldest.TenantId);
_vexToVerdictIndex.Remove(vexKey);
RemoveEntryLocked(oldest);
Interlocked.Increment(ref _evictionCount);
}
}
private void RemoveEntryLocked(TrustVerdictCacheEntry entry)
{
_byVerdictDigest.Remove(entry.VerdictDigest);
var vexKey = BuildVexKey(entry.VexDigest, entry.TenantId);
_vexToVerdictIndex.Remove(vexKey);
}
private long EstimateMemoryUsage()
{
// Rough estimate: ~1KB per entry average
@@ -334,7 +373,6 @@ public sealed class ValkeyTrustVerdictCache : ITrustVerdictCache, IAsyncDisposab
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
@@ -349,11 +387,6 @@ public sealed class ValkeyTrustVerdictCache : ITrustVerdictCache, IAsyncDisposab
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
_fallback = new InMemoryTrustVerdictCache(options, timeProvider);
}
@@ -366,21 +399,8 @@ public sealed class ValkeyTrustVerdictCache : ITrustVerdictCache, IAsyncDisposab
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);
}
ThrowValkeyNotImplemented();
return null;
}
public async Task<TrustVerdictCacheEntry?> GetByVexDigestAsync(
@@ -395,89 +415,45 @@ public sealed class ValkeyTrustVerdictCache : ITrustVerdictCache, IAsyncDisposab
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);
}
ThrowValkeyNotImplemented();
return null;
}
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)
{
await _fallback.SetAsync(entry, ct);
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);
}
ThrowValkeyNotImplemented();
}
public async Task InvalidateAsync(string verdictDigest, CancellationToken ct = default)
{
await _fallback.InvalidateAsync(verdictDigest, ct);
var opts = _options.CurrentValue;
if (!opts.UseValkey)
{
await _fallback.InvalidateAsync(verdictDigest, ct);
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);
}
ThrowValkeyNotImplemented();
}
public async Task InvalidateByVexDigestAsync(string vexDigest, string tenantId, CancellationToken ct = default)
{
await _fallback.InvalidateByVexDigestAsync(vexDigest, tenantId, ct);
var opts = _options.CurrentValue;
if (!opts.UseValkey)
{
await _fallback.InvalidateByVexDigestAsync(vexDigest, tenantId, ct);
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);
}
ThrowValkeyNotImplemented();
}
public async Task<IReadOnlyDictionary<string, TrustVerdictCacheEntry>> GetBatchAsync(
@@ -492,21 +468,20 @@ public sealed class ValkeyTrustVerdictCache : ITrustVerdictCache, IAsyncDisposab
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);
}
ThrowValkeyNotImplemented();
return new Dictionary<string, TrustVerdictCacheEntry>(StringComparer.Ordinal);
}
public Task<TrustVerdictCacheStats> GetStatsAsync(CancellationToken ct = default)
{
// TODO: Combine Valkey INFO stats with fallback stats
var opts = _options.CurrentValue;
if (!opts.UseValkey)
{
return _fallback.GetStatsAsync(ct);
}
ThrowValkeyNotImplemented();
return _fallback.GetStatsAsync(ct);
}
@@ -515,6 +490,15 @@ public sealed class ValkeyTrustVerdictCache : ITrustVerdictCache, IAsyncDisposab
// TODO: Dispose Valkey client when implemented
return ValueTask.CompletedTask;
}
private void ThrowValkeyNotImplemented()
{
_logger.LogError(
"Valkey TrustVerdict cache is not implemented. Set {SectionKey}:UseValkey=false.",
TrustVerdictCacheOptions.SectionKey);
throw new NotSupportedException(
"Valkey TrustVerdict cache is not implemented. Set TrustVerdictCache:UseValkey=false.");
}
}
/// <summary>

View File

@@ -3,6 +3,7 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Attestor.StandardPredicates;
namespace StellaOps.Attestor.TrustVerdict;

View File

@@ -47,7 +47,7 @@ CREATE TABLE vex.trust_verdicts (
-- Trust composite
trust_score DECIMAL(5,4) NOT NULL,
trust_tier TEXT NOT NULL, -- verified, high, medium, low, untrusted
trust_tier TEXT NOT NULL, -- VeryHigh, High, Medium, Low, VeryLow
trust_formula TEXT NOT NULL,
trust_reasons TEXT[] NOT NULL,
meets_policy_threshold BOOLEAN,

View File

@@ -1,6 +1,7 @@
// TrustVerdictRepository - PostgreSQL persistence for TrustVerdict attestations
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
using System.Data.Common;
using System.Text.Json;
using Npgsql;
using NpgsqlTypes;
@@ -182,12 +183,12 @@ public sealed record TrustVerdictStats
public sealed class PostgresTrustVerdictRepository : ITrustVerdictRepository
{
private readonly NpgsqlDataSource _dataSource;
private readonly JsonSerializerOptions _jsonOptions;
private static readonly JsonSerializerOptions JsonOptions =
new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
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)
@@ -412,8 +413,8 @@ public sealed class PostgresTrustVerdictRepository : ITrustVerdictRepository
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),
OldestEvaluation = reader.IsDBNull(4) ? null : reader.GetFieldValue<DateTimeOffset>(4),
NewestEvaluation = reader.IsDBNull(5) ? null : reader.GetFieldValue<DateTimeOffset>(5),
CountByTier = await GetCountByTierAsync(tenantId, ct),
CountByProvider = await GetCountByProviderAsync(tenantId, ct)
};
@@ -532,7 +533,7 @@ public sealed class PostgresTrustVerdictRepository : ITrustVerdictRepository
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("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);
@@ -551,10 +552,10 @@ public sealed class PostgresTrustVerdictRepository : ITrustVerdictRepository
cmd.Parameters.AddWithValue("expires_at", entity.ExpiresAt ?? (object)DBNull.Value);
}
private TrustVerdictEntity ReadEntity(NpgsqlDataReader reader)
internal static TrustVerdictEntity ReadEntity(DbDataReader reader)
{
var evidenceJson = reader.GetString(reader.GetOrdinal("evidence_items_json"));
var evidenceItems = JsonSerializer.Deserialize<List<TrustEvidenceItem>>(evidenceJson, _jsonOptions) ?? [];
var evidenceItems = JsonSerializer.Deserialize<List<TrustEvidenceItem>>(evidenceJson, JsonOptions) ?? [];
return new TrustVerdictEntity
{
@@ -578,8 +579,10 @@ public sealed class PostgresTrustVerdictRepository : ITrustVerdictRepository
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")),
FreshnessIssuedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("freshness_issued_at")),
FreshnessExpiresAt = reader.IsDBNull(reader.GetOrdinal("freshness_expires_at"))
? null
: reader.GetFieldValue<DateTimeOffset>(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")),
@@ -605,7 +608,7 @@ public sealed class PostgresTrustVerdictRepository : ITrustVerdictRepository
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")),
EvaluatedAt = reader.GetFieldValue<DateTimeOffset>(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")),
@@ -615,8 +618,10 @@ public sealed class PostgresTrustVerdictRepository : ITrustVerdictRepository
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"))
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
ExpiresAt = reader.IsDBNull(reader.GetOrdinal("expires_at"))
? null
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("expires_at"))
};
}
}

View File

@@ -162,6 +162,7 @@ public sealed record TrustVerdictEvidenceInput
public required string Digest { get; init; }
public string? Uri { get; init; }
public string? Description { get; init; }
public DateTimeOffset? CollectedAt { get; init; }
}
/// <summary>
@@ -451,7 +452,7 @@ public sealed class TrustVerdictService : ITrustVerdictService
Digest = e.Digest,
Uri = e.Uri,
Description = e.Description,
CollectedAt = evaluatedAt
CollectedAt = e.CollectedAt ?? evaluatedAt
})
.ToList();

View File

@@ -24,4 +24,8 @@
<ProjectReference Include="..\StellaOps.Attestor.StandardPredicates\StellaOps.Attestor.StandardPredicates.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="StellaOps.Attestor.TrustVerdict.Tests" />
</ItemGroup>
</Project>

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0067-M | DONE | Maintainability audit for StellaOps.Attestor.TrustVerdict. |
| AUDIT-0067-T | DONE | Test coverage audit for StellaOps.Attestor.TrustVerdict. |
| AUDIT-0067-A | DOING | Applying audit fixes for TrustVerdict library. |
| AUDIT-0067-A | DONE | Applied audit fixes for TrustVerdict library. |