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. |

View File

@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using StellaOps.BinaryIndex.Contracts.Resolution;
using StellaOps.BinaryIndex.Core.Resolution;
@@ -13,13 +14,16 @@ namespace StellaOps.BinaryIndex.WebService.Controllers;
public sealed class ResolutionController : ControllerBase
{
private readonly IResolutionService _resolutionService;
private readonly ResolutionServiceOptions _resolutionOptions;
private readonly ILogger<ResolutionController> _logger;
public ResolutionController(
IResolutionService resolutionService,
IOptions<ResolutionServiceOptions> resolutionOptions,
ILogger<ResolutionController> logger)
{
_resolutionService = resolutionService ?? throw new ArgumentNullException(nameof(resolutionService));
_resolutionOptions = resolutionOptions?.Value ?? throw new ArgumentNullException(nameof(resolutionOptions));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -54,6 +58,7 @@ public sealed class ResolutionController : ControllerBase
[ProducesResponseType<VulnResolutionResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status500InternalServerError)]
public async Task<ActionResult<VulnResolutionResponse>> ResolveVulnerabilityAsync(
[FromBody] VulnResolutionRequest request,
[FromQuery] bool bypassCache = false,
@@ -61,12 +66,12 @@ public sealed class ResolutionController : ControllerBase
{
if (request is null)
{
return BadRequest(CreateProblem("Request body is required.", "InvalidRequest"));
return BadRequest(CreateProblem("Request body is required.", "InvalidRequest", StatusCodes.Status400BadRequest));
}
if (string.IsNullOrWhiteSpace(request.Package))
{
return BadRequest(CreateProblem("Package identifier is required.", "MissingPackage"));
return BadRequest(CreateProblem("Package identifier is required.", "MissingPackage", StatusCodes.Status400BadRequest));
}
_logger.LogInformation("Resolving vulnerability for package {Package}, CVE: {CveId}",
@@ -77,7 +82,7 @@ public sealed class ResolutionController : ControllerBase
var options = new ResolutionOptions
{
BypassCache = bypassCache,
IncludeDsseAttestation = true
IncludeDsseAttestation = _resolutionOptions.EnableDsseByDefault
};
var result = await _resolutionService.ResolveAsync(request, options, ct);
@@ -86,7 +91,8 @@ public sealed class ResolutionController : ControllerBase
catch (Exception ex)
{
_logger.LogError(ex, "Failed to resolve vulnerability for package {Package}", request.Package);
return StatusCode(500, CreateProblem("Internal server error during resolution.", "ResolutionError"));
return StatusCode(StatusCodes.Status500InternalServerError,
CreateProblem("Internal server error during resolution.", "ResolutionError", StatusCodes.Status500InternalServerError));
}
}
@@ -119,18 +125,19 @@ public sealed class ResolutionController : ControllerBase
[HttpPost("vuln/batch")]
[ProducesResponseType<BatchVulnResolutionResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status500InternalServerError)]
public async Task<ActionResult<BatchVulnResolutionResponse>> ResolveBatchAsync(
[FromBody] BatchVulnResolutionRequest request,
CancellationToken ct = default)
{
if (request is null)
{
return BadRequest(CreateProblem("Request body is required.", "InvalidRequest"));
return BadRequest(CreateProblem("Request body is required.", "InvalidRequest", StatusCodes.Status400BadRequest));
}
if (request.Items is null || request.Items.Count == 0)
{
return BadRequest(CreateProblem("At least one item is required.", "EmptyBatch"));
return BadRequest(CreateProblem("At least one item is required.", "EmptyBatch", StatusCodes.Status400BadRequest));
}
_logger.LogInformation("Processing batch resolution for {Count} items", request.Items.Count);
@@ -140,7 +147,7 @@ public sealed class ResolutionController : ControllerBase
var options = new ResolutionOptions
{
BypassCache = request.Options?.BypassCache ?? false,
IncludeDsseAttestation = request.Options?.IncludeDsseAttestation ?? true
IncludeDsseAttestation = request.Options?.IncludeDsseAttestation ?? _resolutionOptions.EnableDsseByDefault
};
var result = await _resolutionService.ResolveBatchAsync(request, options, ct);
@@ -149,28 +156,19 @@ public sealed class ResolutionController : ControllerBase
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process batch resolution");
return StatusCode(500, CreateProblem("Internal server error during batch resolution.", "BatchResolutionError"));
return StatusCode(StatusCodes.Status500InternalServerError,
CreateProblem("Internal server error during batch resolution.", "BatchResolutionError", StatusCodes.Status500InternalServerError));
}
}
/// <summary>
/// Health check endpoint.
/// </summary>
[HttpGet("health")]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult Health()
{
return Ok(new { status = "healthy", timestamp = DateTimeOffset.UtcNow });
}
private static ProblemDetails CreateProblem(string detail, string type)
private static ProblemDetails CreateProblem(string detail, string type, int statusCode)
{
return new ProblemDetails
{
Title = "Resolution Error",
Detail = detail,
Type = $"https://stellaops.dev/errors/{type}",
Status = 400
Status = statusCode
};
}
}

View File

@@ -1,7 +1,7 @@
// -----------------------------------------------------------------------------
// RateLimitingMiddleware.cs
// Sprint: SPRINT_1227_0001_0002_BE_resolution_api
// Task: T10 Rate limiting for resolution API
// Task: T10 - Rate limiting for resolution API
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
@@ -23,22 +23,32 @@ public sealed class RateLimitingMiddleware
private readonly ILogger<RateLimitingMiddleware> _logger;
private readonly RateLimitingOptions _options;
private readonly ResolutionTelemetry? _telemetry;
private readonly TimeProvider _timeProvider;
private readonly ConcurrentDictionary<string, SlidingWindowCounter> _counters = new();
private long _requestCounter;
public RateLimitingMiddleware(
RequestDelegate next,
ILogger<RateLimitingMiddleware> logger,
IOptions<RateLimitingOptions> options,
ResolutionTelemetry? telemetry = null)
ResolutionTelemetry? telemetry = null,
TimeProvider? timeProvider = null)
{
_next = next ?? throw new ArgumentNullException(nameof(next));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_telemetry = telemetry;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task InvokeAsync(HttpContext context)
{
if (!_options.Enabled)
{
await _next(context);
return;
}
// Only apply to resolution endpoints
if (!context.Request.Path.StartsWithSegments("/api/v1/resolve"))
{
@@ -46,13 +56,15 @@ public sealed class RateLimitingMiddleware
return;
}
var now = _timeProvider.GetUtcNow();
var tenantId = GetTenantId(context);
var clientIp = GetClientIp(context);
var rateLimitKey = $"{tenantId}:{clientIp}";
var counter = _counters.GetOrAdd(rateLimitKey, _ => new SlidingWindowCounter(_options.WindowSize));
var counter = _counters.GetOrAdd(rateLimitKey, _ => new SlidingWindowCounter(_options.WindowSize, now));
CleanupStaleCounters(now);
if (!counter.TryIncrement(_options.MaxRequests))
if (!counter.TryIncrement(_options.MaxRequests, now))
{
_logger.LogWarning(
"Rate limit exceeded for tenant {TenantId} from {ClientIp}",
@@ -64,8 +76,7 @@ public sealed class RateLimitingMiddleware
context.Response.Headers["Retry-After"] = _options.RetryAfterSeconds.ToString();
context.Response.Headers["X-RateLimit-Limit"] = _options.MaxRequests.ToString();
context.Response.Headers["X-RateLimit-Remaining"] = "0";
context.Response.Headers["X-RateLimit-Reset"] = DateTimeOffset.UtcNow
.AddSeconds(_options.RetryAfterSeconds).ToUnixTimeSeconds().ToString();
context.Response.Headers["X-RateLimit-Reset"] = counter.GetWindowReset(now).ToUnixTimeSeconds().ToString();
await context.Response.WriteAsJsonAsync(new
{
@@ -78,14 +89,35 @@ public sealed class RateLimitingMiddleware
}
// Add rate limit headers
var remaining = Math.Max(0, _options.MaxRequests - counter.Count);
var remaining = Math.Max(0, _options.MaxRequests - counter.GetCount(now));
context.Response.Headers["X-RateLimit-Limit"] = _options.MaxRequests.ToString();
context.Response.Headers["X-RateLimit-Remaining"] = remaining.ToString();
context.Response.Headers["X-RateLimit-Reset"] = counter.WindowReset.ToUnixTimeSeconds().ToString();
context.Response.Headers["X-RateLimit-Reset"] = counter.GetWindowReset(now).ToUnixTimeSeconds().ToString();
await _next(context);
}
private void CleanupStaleCounters(DateTimeOffset now)
{
if (_options.CleanupEveryNRequests <= 0 || _options.EvictionAfter <= TimeSpan.Zero)
{
return;
}
if (Interlocked.Increment(ref _requestCounter) % _options.CleanupEveryNRequests != 0)
{
return;
}
foreach (var entry in _counters)
{
if (entry.Value.ShouldEvict(now, _options.EvictionAfter))
{
_counters.TryRemove(entry.Key, out _);
}
}
}
private static string GetTenantId(HttpContext context)
{
// Try to get tenant from header, claim, or default
@@ -128,33 +160,39 @@ internal sealed class SlidingWindowCounter
private readonly object _lock = new();
private int _count;
private DateTimeOffset _windowStart;
private DateTimeOffset _lastSeen;
public SlidingWindowCounter(TimeSpan windowSize)
public SlidingWindowCounter(TimeSpan windowSize, DateTimeOffset now)
{
_windowSize = windowSize;
_windowStart = DateTimeOffset.UtcNow;
_windowStart = now;
_lastSeen = now;
_count = 0;
}
public int Count
{
get
{
lock (_lock)
{
ResetIfNeeded();
return _count;
}
}
}
public DateTimeOffset WindowReset => _windowStart + _windowSize;
public bool TryIncrement(int maxRequests)
public int GetCount(DateTimeOffset now)
{
lock (_lock)
{
ResetIfNeeded();
ResetIfNeeded(now);
return _count;
}
}
public DateTimeOffset GetWindowReset(DateTimeOffset now)
{
lock (_lock)
{
ResetIfNeeded(now);
return _windowStart + _windowSize;
}
}
public bool TryIncrement(int maxRequests, DateTimeOffset now)
{
lock (_lock)
{
ResetIfNeeded(now);
if (_count >= maxRequests)
{
@@ -166,9 +204,17 @@ internal sealed class SlidingWindowCounter
}
}
private void ResetIfNeeded()
public bool ShouldEvict(DateTimeOffset now, TimeSpan evictionAfter)
{
var now = DateTimeOffset.UtcNow;
lock (_lock)
{
return now - _lastSeen >= evictionAfter;
}
}
private void ResetIfNeeded(DateTimeOffset now)
{
_lastSeen = now;
if (now >= _windowStart + _windowSize)
{
_windowStart = now;
@@ -193,6 +239,12 @@ public sealed class RateLimitingOptions
/// <summary>Enable rate limiting.</summary>
public bool Enabled { get; set; } = true;
/// <summary>Evict counters after this period of inactivity.</summary>
public TimeSpan EvictionAfter { get; set; } = TimeSpan.FromMinutes(10);
/// <summary>Run cleanup every N requests.</summary>
public int CleanupEveryNRequests { get; set; } = 250;
}
/// <summary>

View File

@@ -1,6 +1,12 @@
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.BinaryIndex.Cache;
using StellaOps.BinaryIndex.Core.Resolution;
using StellaOps.BinaryIndex.VexBridge;
using StellaOps.BinaryIndex.WebService.Middleware;
using StellaOps.BinaryIndex.WebService.Services;
using StellaOps.BinaryIndex.WebService.Telemetry;
using StackExchange.Redis;
var builder = WebApplication.CreateBuilder(args);
@@ -13,8 +19,10 @@ builder.Services.AddSwaggerGen();
// Configure options
builder.Services.Configure<ResolutionServiceOptions>(
builder.Configuration.GetSection(ResolutionServiceOptions.SectionName));
builder.Services.Configure<ResolutionCacheOptions>(
builder.Configuration.GetSection(ResolutionCacheOptions.SectionName));
builder.Services.AddSingleton<IValidateOptions<ResolutionCacheOptions>, ResolutionCacheOptionsValidator>();
builder.Services.AddOptions<ResolutionCacheOptions>()
.Bind(builder.Configuration.GetSection(ResolutionCacheOptions.SectionName))
.ValidateOnStart();
// Add Redis/Valkey connection
var redisConnectionString = builder.Configuration.GetConnectionString("Redis") ?? "localhost:6379";
@@ -22,12 +30,29 @@ builder.Services.AddSingleton<IConnectionMultiplexer>(_ =>
ConnectionMultiplexer.Connect(redisConnectionString));
// Add services
builder.Services.TryAddSingleton(TimeProvider.System);
builder.Services.TryAddSingleton<IRandomSource, SystemRandomSource>();
builder.Services.AddSingleton<IResolutionCacheService, ResolutionCacheService>();
builder.Services.AddScoped<IResolutionService, ResolutionService>();
builder.Services.AddScoped<ResolutionService>();
builder.Services.AddScoped<IResolutionService>(sp =>
new CachedResolutionService(
sp.GetRequiredService<ResolutionService>(),
sp.GetRequiredService<IResolutionCacheService>(),
sp.GetRequiredService<IOptions<ResolutionCacheOptions>>(),
sp.GetRequiredService<IOptions<ResolutionServiceOptions>>(),
sp.GetRequiredService<TimeProvider>(),
sp.GetRequiredService<ILogger<CachedResolutionService>>()));
// Add VexBridge
builder.Services.AddBinaryVexBridge(builder.Configuration);
// Add telemetry
builder.Services.AddResolutionTelemetry();
// Add rate limiting
builder.Services.AddResolutionRateLimiting(options =>
builder.Configuration.GetSection("RateLimiting").Bind(options));
// Add health checks
builder.Services.AddHealthChecks()
.AddRedis(redisConnectionString, name: "redis");
@@ -42,6 +67,7 @@ if (app.Environment.IsDevelopment())
}
app.UseHttpsRedirection();
app.UseResolutionRateLimiting();
app.UseAuthorization();
app.MapControllers();
app.MapHealthChecks("/health");

View File

@@ -0,0 +1,189 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.BinaryIndex.Cache;
using StellaOps.BinaryIndex.Contracts.Resolution;
using StellaOps.BinaryIndex.Core.Resolution;
namespace StellaOps.BinaryIndex.WebService.Services;
/// <summary>
/// Adds cache behavior to the core resolution service.
/// </summary>
public sealed class CachedResolutionService : IResolutionService
{
private readonly IResolutionService _inner;
private readonly IResolutionCacheService _cache;
private readonly ResolutionCacheOptions _cacheOptions;
private readonly ResolutionServiceOptions _serviceOptions;
private readonly TimeProvider _timeProvider;
private readonly ILogger<CachedResolutionService> _logger;
public CachedResolutionService(
IResolutionService inner,
IResolutionCacheService cache,
IOptions<ResolutionCacheOptions> cacheOptions,
IOptions<ResolutionServiceOptions> serviceOptions,
TimeProvider timeProvider,
ILogger<CachedResolutionService> logger)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_cacheOptions = cacheOptions?.Value ?? throw new ArgumentNullException(nameof(cacheOptions));
_serviceOptions = serviceOptions?.Value ?? throw new ArgumentNullException(nameof(serviceOptions));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<VulnResolutionResponse> ResolveAsync(
VulnResolutionRequest request,
ResolutionOptions? options = null,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(request);
var effectiveOptions = options ?? new ResolutionOptions();
if (!effectiveOptions.BypassCache)
{
var cacheKey = _cache.GenerateCacheKey(request);
var cached = await _cache.GetAsync(cacheKey, ct).ConfigureAwait(false);
if (cached is not null)
{
return FromCached(request, cached);
}
}
var response = await _inner.ResolveAsync(request, effectiveOptions, ct).ConfigureAwait(false);
if (!effectiveOptions.BypassCache)
{
var cacheKey = _cache.GenerateCacheKey(request);
var ttl = effectiveOptions.CacheTtl ?? GetCacheTtl(response.Status);
await _cache.SetAsync(cacheKey, ToCached(response), ttl, ct).ConfigureAwait(false);
}
return response with { FromCache = false };
}
public async Task<BatchVulnResolutionResponse> ResolveBatchAsync(
BatchVulnResolutionRequest request,
ResolutionOptions? options = null,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(request);
var sw = Stopwatch.StartNew();
var effectiveOptions = options ?? new ResolutionOptions();
if (request.Options is not null)
{
effectiveOptions = effectiveOptions with
{
BypassCache = request.Options.BypassCache,
IncludeDsseAttestation = request.Options.IncludeDsseAttestation
};
}
var items = request.Items;
if (items.Count > _serviceOptions.MaxBatchSize)
{
_logger.LogWarning("Batch size {Count} exceeds maximum {Max}, truncating", items.Count, _serviceOptions.MaxBatchSize);
items = items.Take(_serviceOptions.MaxBatchSize).ToList();
}
var results = new List<VulnResolutionResponse>(items.Count);
var cacheHits = 0;
foreach (var item in items)
{
ct.ThrowIfCancellationRequested();
if (!effectiveOptions.BypassCache)
{
var cacheKey = _cache.GenerateCacheKey(item);
var cached = await _cache.GetAsync(cacheKey, ct).ConfigureAwait(false);
if (cached is not null)
{
results.Add(FromCached(item, cached));
cacheHits++;
continue;
}
var result = await _inner.ResolveAsync(item, effectiveOptions, ct).ConfigureAwait(false);
results.Add(result with { FromCache = false });
var ttl = effectiveOptions.CacheTtl ?? GetCacheTtl(result.Status);
await _cache.SetAsync(cacheKey, ToCached(result), ttl, ct).ConfigureAwait(false);
continue;
}
var uncached = await _inner.ResolveAsync(item, effectiveOptions, ct).ConfigureAwait(false);
results.Add(uncached with { FromCache = false });
}
return new BatchVulnResolutionResponse
{
Results = results,
TotalCount = results.Count,
CacheHits = cacheHits,
ProcessingTimeMs = sw.ElapsedMilliseconds
};
}
private VulnResolutionResponse FromCached(VulnResolutionRequest request, CachedResolution cached)
{
var evidence = BuildEvidence(cached);
return new VulnResolutionResponse
{
Package = request.Package,
Status = cached.Status,
FixedVersion = cached.FixedVersion,
Evidence = evidence,
ResolvedAt = cached.CachedAt,
FromCache = true,
CveId = request.CveId,
AttestationDsse = null
};
}
private CachedResolution ToCached(VulnResolutionResponse response)
{
return new CachedResolution
{
Status = response.Status,
FixedVersion = response.FixedVersion,
EvidenceRef = null,
CachedAt = _timeProvider.GetUtcNow(),
VersionKey = null,
Confidence = response.Evidence?.Confidence ?? 0m,
MatchType = response.Evidence?.MatchType ?? ResolutionMatchTypes.Unknown
};
}
private static ResolutionEvidence? BuildEvidence(CachedResolution cached)
{
if (string.IsNullOrWhiteSpace(cached.MatchType) && cached.Confidence <= 0m)
{
return null;
}
return new ResolutionEvidence
{
MatchType = string.IsNullOrWhiteSpace(cached.MatchType)
? ResolutionMatchTypes.Unknown
: cached.MatchType,
Confidence = cached.Confidence
};
}
private TimeSpan GetCacheTtl(ResolutionStatus status)
{
return status switch
{
ResolutionStatus.Fixed => _cacheOptions.FixedTtl,
ResolutionStatus.NotAffected => _cacheOptions.FixedTtl,
ResolutionStatus.Vulnerable => _cacheOptions.VulnerableTtl,
_ => _cacheOptions.UnknownTtl
};
}
}

View File

@@ -5,7 +5,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Description>BinaryIndex WebService - Resolution API for binary vulnerability lookup</Description>
</PropertyGroup>

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0129-M | DONE | Maintainability audit for StellaOps.BinaryIndex.WebService. |
| AUDIT-0129-T | DONE | Test coverage audit for StellaOps.BinaryIndex.WebService. |
| AUDIT-0129-A | TODO | Pending approval for changes. |
| AUDIT-0129-A | DONE | Cache wiring, rate limiting, telemetry, TimeProvider, controller fixes, and tests applied. |

View File

@@ -1,7 +1,7 @@
// -----------------------------------------------------------------------------
// ResolutionTelemetry.cs
// Sprint: SPRINT_1227_0001_0002_BE_resolution_api
// Task: T11 Telemetry for resolution API
// Task: T11 - Telemetry for resolution API
// -----------------------------------------------------------------------------
using System.Diagnostics;

View File

@@ -175,6 +175,84 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TestKit", "E:\dev
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Disassembly", "__Libraries\StellaOps.BinaryIndex.Disassembly\StellaOps.BinaryIndex.Disassembly.csproj", "{409497C7-2EDE-4DC8-B749-17BCE479102A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Cache.Tests", "__Tests\StellaOps.BinaryIndex.Cache.Tests\StellaOps.BinaryIndex.Cache.Tests.csproj", "{4E1D1B54-CDF1-4F5C-8189-731E71E0DF19}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Testing", "..\__Tests\__Libraries\StellaOps.Concelier.Testing\StellaOps.Concelier.Testing.csproj", "{1E4075BB-34CC-4BB4-8FCC-0F14E7C742D7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Connector.Common", "..\Concelier\__Libraries\StellaOps.Concelier.Connector.Common\StellaOps.Concelier.Connector.Common.csproj", "{7DC2B4F7-4030-4A6E-935F-BA0EBAF8641B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Normalization", "..\Concelier\__Libraries\StellaOps.Concelier.Normalization\StellaOps.Concelier.Normalization.csproj", "{11F82773-8D9F-416A-8232-38F8986AF9F7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Models", "..\Concelier\__Libraries\StellaOps.Concelier.Models\StellaOps.Concelier.Models.csproj", "{409A8978-55FB-4CBF-82FE-0BE3192284E1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.RawModels", "..\Concelier\__Libraries\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj", "{3986C8D7-3EB0-4EDF-9E0F-D833AF50B3AD}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Plugin", "..\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj", "{C632D90B-673B-4F8E-9287-CA7561B79C48}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{A9F4D7D9-042A-44AE-8201-BBF48DA22661}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{DE94C81C-7699-4E92-82AE-D811F77ED7DC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Core", "..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj", "{439BCE02-2B9E-4B00-879B-329F06C987D5}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Ingestion.Telemetry", "..\__Libraries\StellaOps.Ingestion.Telemetry\StellaOps.Ingestion.Telemetry.csproj", "{885E394D-7FC9-4F5E-BE67-3B7C164B2846}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Provenance", "..\__Libraries\StellaOps.Provenance\StellaOps.Provenance.csproj", "{9F1BC667-7A66-4B26-AEC0-11ABFB8015D2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Aoc", "..\Aoc\__Libraries\StellaOps.Aoc\StellaOps.Aoc.csproj", "{4A709A7B-8A79-40BE-93F3-9D8037E4CC3C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Persistence", "..\Concelier\__Libraries\StellaOps.Concelier.Persistence\StellaOps.Concelier.Persistence.csproj", "{40440CD8-2B06-49A5-9F01-89EC02F40885}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Infrastructure.Postgres", "..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj", "{F030414A-B815-4067-854A-D66E88AA7D91}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Infrastructure.EfCore", "..\__Libraries\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj", "{0582E2E0-EEC4-43D8-99C7-ADE2F34CED4F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Interest", "..\Concelier\__Libraries\StellaOps.Concelier.Interest\StellaOps.Concelier.Interest.csproj", "{9A09E7B5-58EA-40E0-AD5B-BC75881AFE8B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Cache.Valkey", "..\Concelier\__Libraries\StellaOps.Concelier.Cache.Valkey\StellaOps.Concelier.Cache.Valkey.csproj", "{D0540A18-8D36-4992-B51C-A60208BFD4BA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.SbomIntegration", "..\Concelier\__Libraries\StellaOps.Concelier.SbomIntegration\StellaOps.Concelier.SbomIntegration.csproj", "{9EB9C719-16C3-4AD9-B7B3-65EDD4BEDFA7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Messaging", "..\Router\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj", "{0DD5DA24-98ED-4DC0-94E9-BB854A319C1A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Merge", "..\Concelier\__Libraries\StellaOps.Concelier.Merge\StellaOps.Concelier.Merge.csproj", "{71707641-92FB-4359-BEC1-46F36928DF56}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.ProofService", "..\Concelier\__Libraries\StellaOps.Concelier.ProofService\StellaOps.Concelier.ProofService.csproj", "{98FE445B-1C5F-40BB-93C3-494CFD6EB2A9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.SourceIntel", "..\Concelier\__Libraries\StellaOps.Concelier.SourceIntel\StellaOps.Concelier.SourceIntel.csproj", "{E42F789A-1AE9-4A39-A598-F2372F11231A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Core", "..\Feedser\StellaOps.Feedser.Core\StellaOps.Feedser.Core.csproj", "{5A79046F-D7A9-47D0-B7A7-F608509EB094}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.BinaryAnalysis", "..\Feedser\StellaOps.Feedser.BinaryAnalysis\StellaOps.Feedser.BinaryAnalysis.csproj", "{A2061AB8-4E75-4D90-8702-B30E9087DC73}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.ProofChain", "..\Attestor\__Libraries\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj", "{896F054B-6B0D-458E-9A86-010AE62BD199}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Canonical.Json", "..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj", "{8243922C-3720-49F1-8CBF-C7B5F9F7A143}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Envelope", "..\Attestor\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj", "{AF5ECA13-B6FC-4CBF-B38E-7049BC59F0C8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Provcache", "..\__Libraries\StellaOps.Provcache\StellaOps.Provcache.csproj", "{BF06778E-0C1A-44B3-A608-95C4605FE7FE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Provenance.Attestation", "..\Provenance\StellaOps.Provenance.Attestation\StellaOps.Provenance.Attestation.csproj", "{D7938493-65EE-4A6A-B9E3-904C1587A4DD}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.VersionComparison", "..\__Libraries\StellaOps.VersionComparison\StellaOps.VersionComparison.csproj", "{DFB96B1D-D5C2-4775-ADEB-A302BAE5A099}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Infrastructure.Postgres.Testing", "..\__Tests\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj", "{15CA713E-DFC3-4A9F-B623-614C46C40ABE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Contracts.Tests", "__Tests\StellaOps.BinaryIndex.Contracts.Tests\StellaOps.BinaryIndex.Contracts.Tests.csproj", "{D5CA3FC2-CC92-4CB6-A894-7BA83A25E7C6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Corpus.Tests", "__Tests\StellaOps.BinaryIndex.Corpus.Tests\StellaOps.BinaryIndex.Corpus.Tests.csproj", "{76B3C1EC-565B-4424-B242-DCAB40C7BD21}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Corpus.Alpine.Tests", "__Tests\StellaOps.BinaryIndex.Corpus.Alpine.Tests\StellaOps.BinaryIndex.Corpus.Alpine.Tests.csproj", "{28F5E1F1-291F-469A-BCA3-AA1458C85570}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Corpus.Debian.Tests", "__Tests\StellaOps.BinaryIndex.Corpus.Debian.Tests\StellaOps.BinaryIndex.Corpus.Debian.Tests.csproj", "{5D4B3AEE-D534-45A2-AF40-B09ACD4D0F13}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Corpus.Rpm.Tests", "__Tests\StellaOps.BinaryIndex.Corpus.Rpm.Tests\StellaOps.BinaryIndex.Corpus.Rpm.Tests.csproj", "{FB127279-C17B-40DC-AC68-320B7CE85E76}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.FixIndex.Tests", "__Tests\StellaOps.BinaryIndex.FixIndex.Tests\StellaOps.BinaryIndex.FixIndex.Tests.csproj", "{AAE98543-46B4-4707-AD1F-CCC9142F8712}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.WebService.Tests", "__Tests\StellaOps.BinaryIndex.WebService.Tests\StellaOps.BinaryIndex.WebService.Tests.csproj", "{C12D06F8-7B69-4A24-B206-C47326778F2E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -605,6 +683,474 @@ Global
{409497C7-2EDE-4DC8-B749-17BCE479102A}.Release|x64.Build.0 = Release|Any CPU
{409497C7-2EDE-4DC8-B749-17BCE479102A}.Release|x86.ActiveCfg = Release|Any CPU
{409497C7-2EDE-4DC8-B749-17BCE479102A}.Release|x86.Build.0 = Release|Any CPU
{4E1D1B54-CDF1-4F5C-8189-731E71E0DF19}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4E1D1B54-CDF1-4F5C-8189-731E71E0DF19}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4E1D1B54-CDF1-4F5C-8189-731E71E0DF19}.Debug|x64.ActiveCfg = Debug|Any CPU
{4E1D1B54-CDF1-4F5C-8189-731E71E0DF19}.Debug|x64.Build.0 = Debug|Any CPU
{4E1D1B54-CDF1-4F5C-8189-731E71E0DF19}.Debug|x86.ActiveCfg = Debug|Any CPU
{4E1D1B54-CDF1-4F5C-8189-731E71E0DF19}.Debug|x86.Build.0 = Debug|Any CPU
{4E1D1B54-CDF1-4F5C-8189-731E71E0DF19}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4E1D1B54-CDF1-4F5C-8189-731E71E0DF19}.Release|Any CPU.Build.0 = Release|Any CPU
{4E1D1B54-CDF1-4F5C-8189-731E71E0DF19}.Release|x64.ActiveCfg = Release|Any CPU
{4E1D1B54-CDF1-4F5C-8189-731E71E0DF19}.Release|x64.Build.0 = Release|Any CPU
{4E1D1B54-CDF1-4F5C-8189-731E71E0DF19}.Release|x86.ActiveCfg = Release|Any CPU
{4E1D1B54-CDF1-4F5C-8189-731E71E0DF19}.Release|x86.Build.0 = Release|Any CPU
{1E4075BB-34CC-4BB4-8FCC-0F14E7C742D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1E4075BB-34CC-4BB4-8FCC-0F14E7C742D7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1E4075BB-34CC-4BB4-8FCC-0F14E7C742D7}.Debug|x64.ActiveCfg = Debug|Any CPU
{1E4075BB-34CC-4BB4-8FCC-0F14E7C742D7}.Debug|x64.Build.0 = Debug|Any CPU
{1E4075BB-34CC-4BB4-8FCC-0F14E7C742D7}.Debug|x86.ActiveCfg = Debug|Any CPU
{1E4075BB-34CC-4BB4-8FCC-0F14E7C742D7}.Debug|x86.Build.0 = Debug|Any CPU
{1E4075BB-34CC-4BB4-8FCC-0F14E7C742D7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1E4075BB-34CC-4BB4-8FCC-0F14E7C742D7}.Release|Any CPU.Build.0 = Release|Any CPU
{1E4075BB-34CC-4BB4-8FCC-0F14E7C742D7}.Release|x64.ActiveCfg = Release|Any CPU
{1E4075BB-34CC-4BB4-8FCC-0F14E7C742D7}.Release|x64.Build.0 = Release|Any CPU
{1E4075BB-34CC-4BB4-8FCC-0F14E7C742D7}.Release|x86.ActiveCfg = Release|Any CPU
{1E4075BB-34CC-4BB4-8FCC-0F14E7C742D7}.Release|x86.Build.0 = Release|Any CPU
{7DC2B4F7-4030-4A6E-935F-BA0EBAF8641B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7DC2B4F7-4030-4A6E-935F-BA0EBAF8641B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7DC2B4F7-4030-4A6E-935F-BA0EBAF8641B}.Debug|x64.ActiveCfg = Debug|Any CPU
{7DC2B4F7-4030-4A6E-935F-BA0EBAF8641B}.Debug|x64.Build.0 = Debug|Any CPU
{7DC2B4F7-4030-4A6E-935F-BA0EBAF8641B}.Debug|x86.ActiveCfg = Debug|Any CPU
{7DC2B4F7-4030-4A6E-935F-BA0EBAF8641B}.Debug|x86.Build.0 = Debug|Any CPU
{7DC2B4F7-4030-4A6E-935F-BA0EBAF8641B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7DC2B4F7-4030-4A6E-935F-BA0EBAF8641B}.Release|Any CPU.Build.0 = Release|Any CPU
{7DC2B4F7-4030-4A6E-935F-BA0EBAF8641B}.Release|x64.ActiveCfg = Release|Any CPU
{7DC2B4F7-4030-4A6E-935F-BA0EBAF8641B}.Release|x64.Build.0 = Release|Any CPU
{7DC2B4F7-4030-4A6E-935F-BA0EBAF8641B}.Release|x86.ActiveCfg = Release|Any CPU
{7DC2B4F7-4030-4A6E-935F-BA0EBAF8641B}.Release|x86.Build.0 = Release|Any CPU
{11F82773-8D9F-416A-8232-38F8986AF9F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{11F82773-8D9F-416A-8232-38F8986AF9F7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{11F82773-8D9F-416A-8232-38F8986AF9F7}.Debug|x64.ActiveCfg = Debug|Any CPU
{11F82773-8D9F-416A-8232-38F8986AF9F7}.Debug|x64.Build.0 = Debug|Any CPU
{11F82773-8D9F-416A-8232-38F8986AF9F7}.Debug|x86.ActiveCfg = Debug|Any CPU
{11F82773-8D9F-416A-8232-38F8986AF9F7}.Debug|x86.Build.0 = Debug|Any CPU
{11F82773-8D9F-416A-8232-38F8986AF9F7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{11F82773-8D9F-416A-8232-38F8986AF9F7}.Release|Any CPU.Build.0 = Release|Any CPU
{11F82773-8D9F-416A-8232-38F8986AF9F7}.Release|x64.ActiveCfg = Release|Any CPU
{11F82773-8D9F-416A-8232-38F8986AF9F7}.Release|x64.Build.0 = Release|Any CPU
{11F82773-8D9F-416A-8232-38F8986AF9F7}.Release|x86.ActiveCfg = Release|Any CPU
{11F82773-8D9F-416A-8232-38F8986AF9F7}.Release|x86.Build.0 = Release|Any CPU
{409A8978-55FB-4CBF-82FE-0BE3192284E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{409A8978-55FB-4CBF-82FE-0BE3192284E1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{409A8978-55FB-4CBF-82FE-0BE3192284E1}.Debug|x64.ActiveCfg = Debug|Any CPU
{409A8978-55FB-4CBF-82FE-0BE3192284E1}.Debug|x64.Build.0 = Debug|Any CPU
{409A8978-55FB-4CBF-82FE-0BE3192284E1}.Debug|x86.ActiveCfg = Debug|Any CPU
{409A8978-55FB-4CBF-82FE-0BE3192284E1}.Debug|x86.Build.0 = Debug|Any CPU
{409A8978-55FB-4CBF-82FE-0BE3192284E1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{409A8978-55FB-4CBF-82FE-0BE3192284E1}.Release|Any CPU.Build.0 = Release|Any CPU
{409A8978-55FB-4CBF-82FE-0BE3192284E1}.Release|x64.ActiveCfg = Release|Any CPU
{409A8978-55FB-4CBF-82FE-0BE3192284E1}.Release|x64.Build.0 = Release|Any CPU
{409A8978-55FB-4CBF-82FE-0BE3192284E1}.Release|x86.ActiveCfg = Release|Any CPU
{409A8978-55FB-4CBF-82FE-0BE3192284E1}.Release|x86.Build.0 = Release|Any CPU
{3986C8D7-3EB0-4EDF-9E0F-D833AF50B3AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3986C8D7-3EB0-4EDF-9E0F-D833AF50B3AD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3986C8D7-3EB0-4EDF-9E0F-D833AF50B3AD}.Debug|x64.ActiveCfg = Debug|Any CPU
{3986C8D7-3EB0-4EDF-9E0F-D833AF50B3AD}.Debug|x64.Build.0 = Debug|Any CPU
{3986C8D7-3EB0-4EDF-9E0F-D833AF50B3AD}.Debug|x86.ActiveCfg = Debug|Any CPU
{3986C8D7-3EB0-4EDF-9E0F-D833AF50B3AD}.Debug|x86.Build.0 = Debug|Any CPU
{3986C8D7-3EB0-4EDF-9E0F-D833AF50B3AD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3986C8D7-3EB0-4EDF-9E0F-D833AF50B3AD}.Release|Any CPU.Build.0 = Release|Any CPU
{3986C8D7-3EB0-4EDF-9E0F-D833AF50B3AD}.Release|x64.ActiveCfg = Release|Any CPU
{3986C8D7-3EB0-4EDF-9E0F-D833AF50B3AD}.Release|x64.Build.0 = Release|Any CPU
{3986C8D7-3EB0-4EDF-9E0F-D833AF50B3AD}.Release|x86.ActiveCfg = Release|Any CPU
{3986C8D7-3EB0-4EDF-9E0F-D833AF50B3AD}.Release|x86.Build.0 = Release|Any CPU
{C632D90B-673B-4F8E-9287-CA7561B79C48}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C632D90B-673B-4F8E-9287-CA7561B79C48}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C632D90B-673B-4F8E-9287-CA7561B79C48}.Debug|x64.ActiveCfg = Debug|Any CPU
{C632D90B-673B-4F8E-9287-CA7561B79C48}.Debug|x64.Build.0 = Debug|Any CPU
{C632D90B-673B-4F8E-9287-CA7561B79C48}.Debug|x86.ActiveCfg = Debug|Any CPU
{C632D90B-673B-4F8E-9287-CA7561B79C48}.Debug|x86.Build.0 = Debug|Any CPU
{C632D90B-673B-4F8E-9287-CA7561B79C48}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C632D90B-673B-4F8E-9287-CA7561B79C48}.Release|Any CPU.Build.0 = Release|Any CPU
{C632D90B-673B-4F8E-9287-CA7561B79C48}.Release|x64.ActiveCfg = Release|Any CPU
{C632D90B-673B-4F8E-9287-CA7561B79C48}.Release|x64.Build.0 = Release|Any CPU
{C632D90B-673B-4F8E-9287-CA7561B79C48}.Release|x86.ActiveCfg = Release|Any CPU
{C632D90B-673B-4F8E-9287-CA7561B79C48}.Release|x86.Build.0 = Release|Any CPU
{A9F4D7D9-042A-44AE-8201-BBF48DA22661}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A9F4D7D9-042A-44AE-8201-BBF48DA22661}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A9F4D7D9-042A-44AE-8201-BBF48DA22661}.Debug|x64.ActiveCfg = Debug|Any CPU
{A9F4D7D9-042A-44AE-8201-BBF48DA22661}.Debug|x64.Build.0 = Debug|Any CPU
{A9F4D7D9-042A-44AE-8201-BBF48DA22661}.Debug|x86.ActiveCfg = Debug|Any CPU
{A9F4D7D9-042A-44AE-8201-BBF48DA22661}.Debug|x86.Build.0 = Debug|Any CPU
{A9F4D7D9-042A-44AE-8201-BBF48DA22661}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A9F4D7D9-042A-44AE-8201-BBF48DA22661}.Release|Any CPU.Build.0 = Release|Any CPU
{A9F4D7D9-042A-44AE-8201-BBF48DA22661}.Release|x64.ActiveCfg = Release|Any CPU
{A9F4D7D9-042A-44AE-8201-BBF48DA22661}.Release|x64.Build.0 = Release|Any CPU
{A9F4D7D9-042A-44AE-8201-BBF48DA22661}.Release|x86.ActiveCfg = Release|Any CPU
{A9F4D7D9-042A-44AE-8201-BBF48DA22661}.Release|x86.Build.0 = Release|Any CPU
{DE94C81C-7699-4E92-82AE-D811F77ED7DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DE94C81C-7699-4E92-82AE-D811F77ED7DC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DE94C81C-7699-4E92-82AE-D811F77ED7DC}.Debug|x64.ActiveCfg = Debug|Any CPU
{DE94C81C-7699-4E92-82AE-D811F77ED7DC}.Debug|x64.Build.0 = Debug|Any CPU
{DE94C81C-7699-4E92-82AE-D811F77ED7DC}.Debug|x86.ActiveCfg = Debug|Any CPU
{DE94C81C-7699-4E92-82AE-D811F77ED7DC}.Debug|x86.Build.0 = Debug|Any CPU
{DE94C81C-7699-4E92-82AE-D811F77ED7DC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DE94C81C-7699-4E92-82AE-D811F77ED7DC}.Release|Any CPU.Build.0 = Release|Any CPU
{DE94C81C-7699-4E92-82AE-D811F77ED7DC}.Release|x64.ActiveCfg = Release|Any CPU
{DE94C81C-7699-4E92-82AE-D811F77ED7DC}.Release|x64.Build.0 = Release|Any CPU
{DE94C81C-7699-4E92-82AE-D811F77ED7DC}.Release|x86.ActiveCfg = Release|Any CPU
{DE94C81C-7699-4E92-82AE-D811F77ED7DC}.Release|x86.Build.0 = Release|Any CPU
{439BCE02-2B9E-4B00-879B-329F06C987D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{439BCE02-2B9E-4B00-879B-329F06C987D5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{439BCE02-2B9E-4B00-879B-329F06C987D5}.Debug|x64.ActiveCfg = Debug|Any CPU
{439BCE02-2B9E-4B00-879B-329F06C987D5}.Debug|x64.Build.0 = Debug|Any CPU
{439BCE02-2B9E-4B00-879B-329F06C987D5}.Debug|x86.ActiveCfg = Debug|Any CPU
{439BCE02-2B9E-4B00-879B-329F06C987D5}.Debug|x86.Build.0 = Debug|Any CPU
{439BCE02-2B9E-4B00-879B-329F06C987D5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{439BCE02-2B9E-4B00-879B-329F06C987D5}.Release|Any CPU.Build.0 = Release|Any CPU
{439BCE02-2B9E-4B00-879B-329F06C987D5}.Release|x64.ActiveCfg = Release|Any CPU
{439BCE02-2B9E-4B00-879B-329F06C987D5}.Release|x64.Build.0 = Release|Any CPU
{439BCE02-2B9E-4B00-879B-329F06C987D5}.Release|x86.ActiveCfg = Release|Any CPU
{439BCE02-2B9E-4B00-879B-329F06C987D5}.Release|x86.Build.0 = Release|Any CPU
{885E394D-7FC9-4F5E-BE67-3B7C164B2846}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{885E394D-7FC9-4F5E-BE67-3B7C164B2846}.Debug|Any CPU.Build.0 = Debug|Any CPU
{885E394D-7FC9-4F5E-BE67-3B7C164B2846}.Debug|x64.ActiveCfg = Debug|Any CPU
{885E394D-7FC9-4F5E-BE67-3B7C164B2846}.Debug|x64.Build.0 = Debug|Any CPU
{885E394D-7FC9-4F5E-BE67-3B7C164B2846}.Debug|x86.ActiveCfg = Debug|Any CPU
{885E394D-7FC9-4F5E-BE67-3B7C164B2846}.Debug|x86.Build.0 = Debug|Any CPU
{885E394D-7FC9-4F5E-BE67-3B7C164B2846}.Release|Any CPU.ActiveCfg = Release|Any CPU
{885E394D-7FC9-4F5E-BE67-3B7C164B2846}.Release|Any CPU.Build.0 = Release|Any CPU
{885E394D-7FC9-4F5E-BE67-3B7C164B2846}.Release|x64.ActiveCfg = Release|Any CPU
{885E394D-7FC9-4F5E-BE67-3B7C164B2846}.Release|x64.Build.0 = Release|Any CPU
{885E394D-7FC9-4F5E-BE67-3B7C164B2846}.Release|x86.ActiveCfg = Release|Any CPU
{885E394D-7FC9-4F5E-BE67-3B7C164B2846}.Release|x86.Build.0 = Release|Any CPU
{9F1BC667-7A66-4B26-AEC0-11ABFB8015D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9F1BC667-7A66-4B26-AEC0-11ABFB8015D2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9F1BC667-7A66-4B26-AEC0-11ABFB8015D2}.Debug|x64.ActiveCfg = Debug|Any CPU
{9F1BC667-7A66-4B26-AEC0-11ABFB8015D2}.Debug|x64.Build.0 = Debug|Any CPU
{9F1BC667-7A66-4B26-AEC0-11ABFB8015D2}.Debug|x86.ActiveCfg = Debug|Any CPU
{9F1BC667-7A66-4B26-AEC0-11ABFB8015D2}.Debug|x86.Build.0 = Debug|Any CPU
{9F1BC667-7A66-4B26-AEC0-11ABFB8015D2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9F1BC667-7A66-4B26-AEC0-11ABFB8015D2}.Release|Any CPU.Build.0 = Release|Any CPU
{9F1BC667-7A66-4B26-AEC0-11ABFB8015D2}.Release|x64.ActiveCfg = Release|Any CPU
{9F1BC667-7A66-4B26-AEC0-11ABFB8015D2}.Release|x64.Build.0 = Release|Any CPU
{9F1BC667-7A66-4B26-AEC0-11ABFB8015D2}.Release|x86.ActiveCfg = Release|Any CPU
{9F1BC667-7A66-4B26-AEC0-11ABFB8015D2}.Release|x86.Build.0 = Release|Any CPU
{4A709A7B-8A79-40BE-93F3-9D8037E4CC3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4A709A7B-8A79-40BE-93F3-9D8037E4CC3C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4A709A7B-8A79-40BE-93F3-9D8037E4CC3C}.Debug|x64.ActiveCfg = Debug|Any CPU
{4A709A7B-8A79-40BE-93F3-9D8037E4CC3C}.Debug|x64.Build.0 = Debug|Any CPU
{4A709A7B-8A79-40BE-93F3-9D8037E4CC3C}.Debug|x86.ActiveCfg = Debug|Any CPU
{4A709A7B-8A79-40BE-93F3-9D8037E4CC3C}.Debug|x86.Build.0 = Debug|Any CPU
{4A709A7B-8A79-40BE-93F3-9D8037E4CC3C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4A709A7B-8A79-40BE-93F3-9D8037E4CC3C}.Release|Any CPU.Build.0 = Release|Any CPU
{4A709A7B-8A79-40BE-93F3-9D8037E4CC3C}.Release|x64.ActiveCfg = Release|Any CPU
{4A709A7B-8A79-40BE-93F3-9D8037E4CC3C}.Release|x64.Build.0 = Release|Any CPU
{4A709A7B-8A79-40BE-93F3-9D8037E4CC3C}.Release|x86.ActiveCfg = Release|Any CPU
{4A709A7B-8A79-40BE-93F3-9D8037E4CC3C}.Release|x86.Build.0 = Release|Any CPU
{40440CD8-2B06-49A5-9F01-89EC02F40885}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{40440CD8-2B06-49A5-9F01-89EC02F40885}.Debug|Any CPU.Build.0 = Debug|Any CPU
{40440CD8-2B06-49A5-9F01-89EC02F40885}.Debug|x64.ActiveCfg = Debug|Any CPU
{40440CD8-2B06-49A5-9F01-89EC02F40885}.Debug|x64.Build.0 = Debug|Any CPU
{40440CD8-2B06-49A5-9F01-89EC02F40885}.Debug|x86.ActiveCfg = Debug|Any CPU
{40440CD8-2B06-49A5-9F01-89EC02F40885}.Debug|x86.Build.0 = Debug|Any CPU
{40440CD8-2B06-49A5-9F01-89EC02F40885}.Release|Any CPU.ActiveCfg = Release|Any CPU
{40440CD8-2B06-49A5-9F01-89EC02F40885}.Release|Any CPU.Build.0 = Release|Any CPU
{40440CD8-2B06-49A5-9F01-89EC02F40885}.Release|x64.ActiveCfg = Release|Any CPU
{40440CD8-2B06-49A5-9F01-89EC02F40885}.Release|x64.Build.0 = Release|Any CPU
{40440CD8-2B06-49A5-9F01-89EC02F40885}.Release|x86.ActiveCfg = Release|Any CPU
{40440CD8-2B06-49A5-9F01-89EC02F40885}.Release|x86.Build.0 = Release|Any CPU
{F030414A-B815-4067-854A-D66E88AA7D91}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F030414A-B815-4067-854A-D66E88AA7D91}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F030414A-B815-4067-854A-D66E88AA7D91}.Debug|x64.ActiveCfg = Debug|Any CPU
{F030414A-B815-4067-854A-D66E88AA7D91}.Debug|x64.Build.0 = Debug|Any CPU
{F030414A-B815-4067-854A-D66E88AA7D91}.Debug|x86.ActiveCfg = Debug|Any CPU
{F030414A-B815-4067-854A-D66E88AA7D91}.Debug|x86.Build.0 = Debug|Any CPU
{F030414A-B815-4067-854A-D66E88AA7D91}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F030414A-B815-4067-854A-D66E88AA7D91}.Release|Any CPU.Build.0 = Release|Any CPU
{F030414A-B815-4067-854A-D66E88AA7D91}.Release|x64.ActiveCfg = Release|Any CPU
{F030414A-B815-4067-854A-D66E88AA7D91}.Release|x64.Build.0 = Release|Any CPU
{F030414A-B815-4067-854A-D66E88AA7D91}.Release|x86.ActiveCfg = Release|Any CPU
{F030414A-B815-4067-854A-D66E88AA7D91}.Release|x86.Build.0 = Release|Any CPU
{0582E2E0-EEC4-43D8-99C7-ADE2F34CED4F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0582E2E0-EEC4-43D8-99C7-ADE2F34CED4F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0582E2E0-EEC4-43D8-99C7-ADE2F34CED4F}.Debug|x64.ActiveCfg = Debug|Any CPU
{0582E2E0-EEC4-43D8-99C7-ADE2F34CED4F}.Debug|x64.Build.0 = Debug|Any CPU
{0582E2E0-EEC4-43D8-99C7-ADE2F34CED4F}.Debug|x86.ActiveCfg = Debug|Any CPU
{0582E2E0-EEC4-43D8-99C7-ADE2F34CED4F}.Debug|x86.Build.0 = Debug|Any CPU
{0582E2E0-EEC4-43D8-99C7-ADE2F34CED4F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0582E2E0-EEC4-43D8-99C7-ADE2F34CED4F}.Release|Any CPU.Build.0 = Release|Any CPU
{0582E2E0-EEC4-43D8-99C7-ADE2F34CED4F}.Release|x64.ActiveCfg = Release|Any CPU
{0582E2E0-EEC4-43D8-99C7-ADE2F34CED4F}.Release|x64.Build.0 = Release|Any CPU
{0582E2E0-EEC4-43D8-99C7-ADE2F34CED4F}.Release|x86.ActiveCfg = Release|Any CPU
{0582E2E0-EEC4-43D8-99C7-ADE2F34CED4F}.Release|x86.Build.0 = Release|Any CPU
{9A09E7B5-58EA-40E0-AD5B-BC75881AFE8B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9A09E7B5-58EA-40E0-AD5B-BC75881AFE8B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9A09E7B5-58EA-40E0-AD5B-BC75881AFE8B}.Debug|x64.ActiveCfg = Debug|Any CPU
{9A09E7B5-58EA-40E0-AD5B-BC75881AFE8B}.Debug|x64.Build.0 = Debug|Any CPU
{9A09E7B5-58EA-40E0-AD5B-BC75881AFE8B}.Debug|x86.ActiveCfg = Debug|Any CPU
{9A09E7B5-58EA-40E0-AD5B-BC75881AFE8B}.Debug|x86.Build.0 = Debug|Any CPU
{9A09E7B5-58EA-40E0-AD5B-BC75881AFE8B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9A09E7B5-58EA-40E0-AD5B-BC75881AFE8B}.Release|Any CPU.Build.0 = Release|Any CPU
{9A09E7B5-58EA-40E0-AD5B-BC75881AFE8B}.Release|x64.ActiveCfg = Release|Any CPU
{9A09E7B5-58EA-40E0-AD5B-BC75881AFE8B}.Release|x64.Build.0 = Release|Any CPU
{9A09E7B5-58EA-40E0-AD5B-BC75881AFE8B}.Release|x86.ActiveCfg = Release|Any CPU
{9A09E7B5-58EA-40E0-AD5B-BC75881AFE8B}.Release|x86.Build.0 = Release|Any CPU
{D0540A18-8D36-4992-B51C-A60208BFD4BA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D0540A18-8D36-4992-B51C-A60208BFD4BA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D0540A18-8D36-4992-B51C-A60208BFD4BA}.Debug|x64.ActiveCfg = Debug|Any CPU
{D0540A18-8D36-4992-B51C-A60208BFD4BA}.Debug|x64.Build.0 = Debug|Any CPU
{D0540A18-8D36-4992-B51C-A60208BFD4BA}.Debug|x86.ActiveCfg = Debug|Any CPU
{D0540A18-8D36-4992-B51C-A60208BFD4BA}.Debug|x86.Build.0 = Debug|Any CPU
{D0540A18-8D36-4992-B51C-A60208BFD4BA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D0540A18-8D36-4992-B51C-A60208BFD4BA}.Release|Any CPU.Build.0 = Release|Any CPU
{D0540A18-8D36-4992-B51C-A60208BFD4BA}.Release|x64.ActiveCfg = Release|Any CPU
{D0540A18-8D36-4992-B51C-A60208BFD4BA}.Release|x64.Build.0 = Release|Any CPU
{D0540A18-8D36-4992-B51C-A60208BFD4BA}.Release|x86.ActiveCfg = Release|Any CPU
{D0540A18-8D36-4992-B51C-A60208BFD4BA}.Release|x86.Build.0 = Release|Any CPU
{9EB9C719-16C3-4AD9-B7B3-65EDD4BEDFA7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9EB9C719-16C3-4AD9-B7B3-65EDD4BEDFA7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9EB9C719-16C3-4AD9-B7B3-65EDD4BEDFA7}.Debug|x64.ActiveCfg = Debug|Any CPU
{9EB9C719-16C3-4AD9-B7B3-65EDD4BEDFA7}.Debug|x64.Build.0 = Debug|Any CPU
{9EB9C719-16C3-4AD9-B7B3-65EDD4BEDFA7}.Debug|x86.ActiveCfg = Debug|Any CPU
{9EB9C719-16C3-4AD9-B7B3-65EDD4BEDFA7}.Debug|x86.Build.0 = Debug|Any CPU
{9EB9C719-16C3-4AD9-B7B3-65EDD4BEDFA7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9EB9C719-16C3-4AD9-B7B3-65EDD4BEDFA7}.Release|Any CPU.Build.0 = Release|Any CPU
{9EB9C719-16C3-4AD9-B7B3-65EDD4BEDFA7}.Release|x64.ActiveCfg = Release|Any CPU
{9EB9C719-16C3-4AD9-B7B3-65EDD4BEDFA7}.Release|x64.Build.0 = Release|Any CPU
{9EB9C719-16C3-4AD9-B7B3-65EDD4BEDFA7}.Release|x86.ActiveCfg = Release|Any CPU
{9EB9C719-16C3-4AD9-B7B3-65EDD4BEDFA7}.Release|x86.Build.0 = Release|Any CPU
{0DD5DA24-98ED-4DC0-94E9-BB854A319C1A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0DD5DA24-98ED-4DC0-94E9-BB854A319C1A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0DD5DA24-98ED-4DC0-94E9-BB854A319C1A}.Debug|x64.ActiveCfg = Debug|Any CPU
{0DD5DA24-98ED-4DC0-94E9-BB854A319C1A}.Debug|x64.Build.0 = Debug|Any CPU
{0DD5DA24-98ED-4DC0-94E9-BB854A319C1A}.Debug|x86.ActiveCfg = Debug|Any CPU
{0DD5DA24-98ED-4DC0-94E9-BB854A319C1A}.Debug|x86.Build.0 = Debug|Any CPU
{0DD5DA24-98ED-4DC0-94E9-BB854A319C1A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0DD5DA24-98ED-4DC0-94E9-BB854A319C1A}.Release|Any CPU.Build.0 = Release|Any CPU
{0DD5DA24-98ED-4DC0-94E9-BB854A319C1A}.Release|x64.ActiveCfg = Release|Any CPU
{0DD5DA24-98ED-4DC0-94E9-BB854A319C1A}.Release|x64.Build.0 = Release|Any CPU
{0DD5DA24-98ED-4DC0-94E9-BB854A319C1A}.Release|x86.ActiveCfg = Release|Any CPU
{0DD5DA24-98ED-4DC0-94E9-BB854A319C1A}.Release|x86.Build.0 = Release|Any CPU
{71707641-92FB-4359-BEC1-46F36928DF56}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{71707641-92FB-4359-BEC1-46F36928DF56}.Debug|Any CPU.Build.0 = Debug|Any CPU
{71707641-92FB-4359-BEC1-46F36928DF56}.Debug|x64.ActiveCfg = Debug|Any CPU
{71707641-92FB-4359-BEC1-46F36928DF56}.Debug|x64.Build.0 = Debug|Any CPU
{71707641-92FB-4359-BEC1-46F36928DF56}.Debug|x86.ActiveCfg = Debug|Any CPU
{71707641-92FB-4359-BEC1-46F36928DF56}.Debug|x86.Build.0 = Debug|Any CPU
{71707641-92FB-4359-BEC1-46F36928DF56}.Release|Any CPU.ActiveCfg = Release|Any CPU
{71707641-92FB-4359-BEC1-46F36928DF56}.Release|Any CPU.Build.0 = Release|Any CPU
{71707641-92FB-4359-BEC1-46F36928DF56}.Release|x64.ActiveCfg = Release|Any CPU
{71707641-92FB-4359-BEC1-46F36928DF56}.Release|x64.Build.0 = Release|Any CPU
{71707641-92FB-4359-BEC1-46F36928DF56}.Release|x86.ActiveCfg = Release|Any CPU
{71707641-92FB-4359-BEC1-46F36928DF56}.Release|x86.Build.0 = Release|Any CPU
{98FE445B-1C5F-40BB-93C3-494CFD6EB2A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{98FE445B-1C5F-40BB-93C3-494CFD6EB2A9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{98FE445B-1C5F-40BB-93C3-494CFD6EB2A9}.Debug|x64.ActiveCfg = Debug|Any CPU
{98FE445B-1C5F-40BB-93C3-494CFD6EB2A9}.Debug|x64.Build.0 = Debug|Any CPU
{98FE445B-1C5F-40BB-93C3-494CFD6EB2A9}.Debug|x86.ActiveCfg = Debug|Any CPU
{98FE445B-1C5F-40BB-93C3-494CFD6EB2A9}.Debug|x86.Build.0 = Debug|Any CPU
{98FE445B-1C5F-40BB-93C3-494CFD6EB2A9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{98FE445B-1C5F-40BB-93C3-494CFD6EB2A9}.Release|Any CPU.Build.0 = Release|Any CPU
{98FE445B-1C5F-40BB-93C3-494CFD6EB2A9}.Release|x64.ActiveCfg = Release|Any CPU
{98FE445B-1C5F-40BB-93C3-494CFD6EB2A9}.Release|x64.Build.0 = Release|Any CPU
{98FE445B-1C5F-40BB-93C3-494CFD6EB2A9}.Release|x86.ActiveCfg = Release|Any CPU
{98FE445B-1C5F-40BB-93C3-494CFD6EB2A9}.Release|x86.Build.0 = Release|Any CPU
{E42F789A-1AE9-4A39-A598-F2372F11231A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E42F789A-1AE9-4A39-A598-F2372F11231A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E42F789A-1AE9-4A39-A598-F2372F11231A}.Debug|x64.ActiveCfg = Debug|Any CPU
{E42F789A-1AE9-4A39-A598-F2372F11231A}.Debug|x64.Build.0 = Debug|Any CPU
{E42F789A-1AE9-4A39-A598-F2372F11231A}.Debug|x86.ActiveCfg = Debug|Any CPU
{E42F789A-1AE9-4A39-A598-F2372F11231A}.Debug|x86.Build.0 = Debug|Any CPU
{E42F789A-1AE9-4A39-A598-F2372F11231A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E42F789A-1AE9-4A39-A598-F2372F11231A}.Release|Any CPU.Build.0 = Release|Any CPU
{E42F789A-1AE9-4A39-A598-F2372F11231A}.Release|x64.ActiveCfg = Release|Any CPU
{E42F789A-1AE9-4A39-A598-F2372F11231A}.Release|x64.Build.0 = Release|Any CPU
{E42F789A-1AE9-4A39-A598-F2372F11231A}.Release|x86.ActiveCfg = Release|Any CPU
{E42F789A-1AE9-4A39-A598-F2372F11231A}.Release|x86.Build.0 = Release|Any CPU
{5A79046F-D7A9-47D0-B7A7-F608509EB094}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5A79046F-D7A9-47D0-B7A7-F608509EB094}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5A79046F-D7A9-47D0-B7A7-F608509EB094}.Debug|x64.ActiveCfg = Debug|Any CPU
{5A79046F-D7A9-47D0-B7A7-F608509EB094}.Debug|x64.Build.0 = Debug|Any CPU
{5A79046F-D7A9-47D0-B7A7-F608509EB094}.Debug|x86.ActiveCfg = Debug|Any CPU
{5A79046F-D7A9-47D0-B7A7-F608509EB094}.Debug|x86.Build.0 = Debug|Any CPU
{5A79046F-D7A9-47D0-B7A7-F608509EB094}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5A79046F-D7A9-47D0-B7A7-F608509EB094}.Release|Any CPU.Build.0 = Release|Any CPU
{5A79046F-D7A9-47D0-B7A7-F608509EB094}.Release|x64.ActiveCfg = Release|Any CPU
{5A79046F-D7A9-47D0-B7A7-F608509EB094}.Release|x64.Build.0 = Release|Any CPU
{5A79046F-D7A9-47D0-B7A7-F608509EB094}.Release|x86.ActiveCfg = Release|Any CPU
{5A79046F-D7A9-47D0-B7A7-F608509EB094}.Release|x86.Build.0 = Release|Any CPU
{A2061AB8-4E75-4D90-8702-B30E9087DC73}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A2061AB8-4E75-4D90-8702-B30E9087DC73}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A2061AB8-4E75-4D90-8702-B30E9087DC73}.Debug|x64.ActiveCfg = Debug|Any CPU
{A2061AB8-4E75-4D90-8702-B30E9087DC73}.Debug|x64.Build.0 = Debug|Any CPU
{A2061AB8-4E75-4D90-8702-B30E9087DC73}.Debug|x86.ActiveCfg = Debug|Any CPU
{A2061AB8-4E75-4D90-8702-B30E9087DC73}.Debug|x86.Build.0 = Debug|Any CPU
{A2061AB8-4E75-4D90-8702-B30E9087DC73}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A2061AB8-4E75-4D90-8702-B30E9087DC73}.Release|Any CPU.Build.0 = Release|Any CPU
{A2061AB8-4E75-4D90-8702-B30E9087DC73}.Release|x64.ActiveCfg = Release|Any CPU
{A2061AB8-4E75-4D90-8702-B30E9087DC73}.Release|x64.Build.0 = Release|Any CPU
{A2061AB8-4E75-4D90-8702-B30E9087DC73}.Release|x86.ActiveCfg = Release|Any CPU
{A2061AB8-4E75-4D90-8702-B30E9087DC73}.Release|x86.Build.0 = Release|Any CPU
{896F054B-6B0D-458E-9A86-010AE62BD199}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{896F054B-6B0D-458E-9A86-010AE62BD199}.Debug|Any CPU.Build.0 = Debug|Any CPU
{896F054B-6B0D-458E-9A86-010AE62BD199}.Debug|x64.ActiveCfg = Debug|Any CPU
{896F054B-6B0D-458E-9A86-010AE62BD199}.Debug|x64.Build.0 = Debug|Any CPU
{896F054B-6B0D-458E-9A86-010AE62BD199}.Debug|x86.ActiveCfg = Debug|Any CPU
{896F054B-6B0D-458E-9A86-010AE62BD199}.Debug|x86.Build.0 = Debug|Any CPU
{896F054B-6B0D-458E-9A86-010AE62BD199}.Release|Any CPU.ActiveCfg = Release|Any CPU
{896F054B-6B0D-458E-9A86-010AE62BD199}.Release|Any CPU.Build.0 = Release|Any CPU
{896F054B-6B0D-458E-9A86-010AE62BD199}.Release|x64.ActiveCfg = Release|Any CPU
{896F054B-6B0D-458E-9A86-010AE62BD199}.Release|x64.Build.0 = Release|Any CPU
{896F054B-6B0D-458E-9A86-010AE62BD199}.Release|x86.ActiveCfg = Release|Any CPU
{896F054B-6B0D-458E-9A86-010AE62BD199}.Release|x86.Build.0 = Release|Any CPU
{8243922C-3720-49F1-8CBF-C7B5F9F7A143}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8243922C-3720-49F1-8CBF-C7B5F9F7A143}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8243922C-3720-49F1-8CBF-C7B5F9F7A143}.Debug|x64.ActiveCfg = Debug|Any CPU
{8243922C-3720-49F1-8CBF-C7B5F9F7A143}.Debug|x64.Build.0 = Debug|Any CPU
{8243922C-3720-49F1-8CBF-C7B5F9F7A143}.Debug|x86.ActiveCfg = Debug|Any CPU
{8243922C-3720-49F1-8CBF-C7B5F9F7A143}.Debug|x86.Build.0 = Debug|Any CPU
{8243922C-3720-49F1-8CBF-C7B5F9F7A143}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8243922C-3720-49F1-8CBF-C7B5F9F7A143}.Release|Any CPU.Build.0 = Release|Any CPU
{8243922C-3720-49F1-8CBF-C7B5F9F7A143}.Release|x64.ActiveCfg = Release|Any CPU
{8243922C-3720-49F1-8CBF-C7B5F9F7A143}.Release|x64.Build.0 = Release|Any CPU
{8243922C-3720-49F1-8CBF-C7B5F9F7A143}.Release|x86.ActiveCfg = Release|Any CPU
{8243922C-3720-49F1-8CBF-C7B5F9F7A143}.Release|x86.Build.0 = Release|Any CPU
{AF5ECA13-B6FC-4CBF-B38E-7049BC59F0C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AF5ECA13-B6FC-4CBF-B38E-7049BC59F0C8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AF5ECA13-B6FC-4CBF-B38E-7049BC59F0C8}.Debug|x64.ActiveCfg = Debug|Any CPU
{AF5ECA13-B6FC-4CBF-B38E-7049BC59F0C8}.Debug|x64.Build.0 = Debug|Any CPU
{AF5ECA13-B6FC-4CBF-B38E-7049BC59F0C8}.Debug|x86.ActiveCfg = Debug|Any CPU
{AF5ECA13-B6FC-4CBF-B38E-7049BC59F0C8}.Debug|x86.Build.0 = Debug|Any CPU
{AF5ECA13-B6FC-4CBF-B38E-7049BC59F0C8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AF5ECA13-B6FC-4CBF-B38E-7049BC59F0C8}.Release|Any CPU.Build.0 = Release|Any CPU
{AF5ECA13-B6FC-4CBF-B38E-7049BC59F0C8}.Release|x64.ActiveCfg = Release|Any CPU
{AF5ECA13-B6FC-4CBF-B38E-7049BC59F0C8}.Release|x64.Build.0 = Release|Any CPU
{AF5ECA13-B6FC-4CBF-B38E-7049BC59F0C8}.Release|x86.ActiveCfg = Release|Any CPU
{AF5ECA13-B6FC-4CBF-B38E-7049BC59F0C8}.Release|x86.Build.0 = Release|Any CPU
{BF06778E-0C1A-44B3-A608-95C4605FE7FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BF06778E-0C1A-44B3-A608-95C4605FE7FE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BF06778E-0C1A-44B3-A608-95C4605FE7FE}.Debug|x64.ActiveCfg = Debug|Any CPU
{BF06778E-0C1A-44B3-A608-95C4605FE7FE}.Debug|x64.Build.0 = Debug|Any CPU
{BF06778E-0C1A-44B3-A608-95C4605FE7FE}.Debug|x86.ActiveCfg = Debug|Any CPU
{BF06778E-0C1A-44B3-A608-95C4605FE7FE}.Debug|x86.Build.0 = Debug|Any CPU
{BF06778E-0C1A-44B3-A608-95C4605FE7FE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BF06778E-0C1A-44B3-A608-95C4605FE7FE}.Release|Any CPU.Build.0 = Release|Any CPU
{BF06778E-0C1A-44B3-A608-95C4605FE7FE}.Release|x64.ActiveCfg = Release|Any CPU
{BF06778E-0C1A-44B3-A608-95C4605FE7FE}.Release|x64.Build.0 = Release|Any CPU
{BF06778E-0C1A-44B3-A608-95C4605FE7FE}.Release|x86.ActiveCfg = Release|Any CPU
{BF06778E-0C1A-44B3-A608-95C4605FE7FE}.Release|x86.Build.0 = Release|Any CPU
{D7938493-65EE-4A6A-B9E3-904C1587A4DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D7938493-65EE-4A6A-B9E3-904C1587A4DD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D7938493-65EE-4A6A-B9E3-904C1587A4DD}.Debug|x64.ActiveCfg = Debug|Any CPU
{D7938493-65EE-4A6A-B9E3-904C1587A4DD}.Debug|x64.Build.0 = Debug|Any CPU
{D7938493-65EE-4A6A-B9E3-904C1587A4DD}.Debug|x86.ActiveCfg = Debug|Any CPU
{D7938493-65EE-4A6A-B9E3-904C1587A4DD}.Debug|x86.Build.0 = Debug|Any CPU
{D7938493-65EE-4A6A-B9E3-904C1587A4DD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D7938493-65EE-4A6A-B9E3-904C1587A4DD}.Release|Any CPU.Build.0 = Release|Any CPU
{D7938493-65EE-4A6A-B9E3-904C1587A4DD}.Release|x64.ActiveCfg = Release|Any CPU
{D7938493-65EE-4A6A-B9E3-904C1587A4DD}.Release|x64.Build.0 = Release|Any CPU
{D7938493-65EE-4A6A-B9E3-904C1587A4DD}.Release|x86.ActiveCfg = Release|Any CPU
{D7938493-65EE-4A6A-B9E3-904C1587A4DD}.Release|x86.Build.0 = Release|Any CPU
{DFB96B1D-D5C2-4775-ADEB-A302BAE5A099}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DFB96B1D-D5C2-4775-ADEB-A302BAE5A099}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DFB96B1D-D5C2-4775-ADEB-A302BAE5A099}.Debug|x64.ActiveCfg = Debug|Any CPU
{DFB96B1D-D5C2-4775-ADEB-A302BAE5A099}.Debug|x64.Build.0 = Debug|Any CPU
{DFB96B1D-D5C2-4775-ADEB-A302BAE5A099}.Debug|x86.ActiveCfg = Debug|Any CPU
{DFB96B1D-D5C2-4775-ADEB-A302BAE5A099}.Debug|x86.Build.0 = Debug|Any CPU
{DFB96B1D-D5C2-4775-ADEB-A302BAE5A099}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DFB96B1D-D5C2-4775-ADEB-A302BAE5A099}.Release|Any CPU.Build.0 = Release|Any CPU
{DFB96B1D-D5C2-4775-ADEB-A302BAE5A099}.Release|x64.ActiveCfg = Release|Any CPU
{DFB96B1D-D5C2-4775-ADEB-A302BAE5A099}.Release|x64.Build.0 = Release|Any CPU
{DFB96B1D-D5C2-4775-ADEB-A302BAE5A099}.Release|x86.ActiveCfg = Release|Any CPU
{DFB96B1D-D5C2-4775-ADEB-A302BAE5A099}.Release|x86.Build.0 = Release|Any CPU
{15CA713E-DFC3-4A9F-B623-614C46C40ABE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{15CA713E-DFC3-4A9F-B623-614C46C40ABE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{15CA713E-DFC3-4A9F-B623-614C46C40ABE}.Debug|x64.ActiveCfg = Debug|Any CPU
{15CA713E-DFC3-4A9F-B623-614C46C40ABE}.Debug|x64.Build.0 = Debug|Any CPU
{15CA713E-DFC3-4A9F-B623-614C46C40ABE}.Debug|x86.ActiveCfg = Debug|Any CPU
{15CA713E-DFC3-4A9F-B623-614C46C40ABE}.Debug|x86.Build.0 = Debug|Any CPU
{15CA713E-DFC3-4A9F-B623-614C46C40ABE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{15CA713E-DFC3-4A9F-B623-614C46C40ABE}.Release|Any CPU.Build.0 = Release|Any CPU
{15CA713E-DFC3-4A9F-B623-614C46C40ABE}.Release|x64.ActiveCfg = Release|Any CPU
{15CA713E-DFC3-4A9F-B623-614C46C40ABE}.Release|x64.Build.0 = Release|Any CPU
{15CA713E-DFC3-4A9F-B623-614C46C40ABE}.Release|x86.ActiveCfg = Release|Any CPU
{15CA713E-DFC3-4A9F-B623-614C46C40ABE}.Release|x86.Build.0 = Release|Any CPU
{D5CA3FC2-CC92-4CB6-A894-7BA83A25E7C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D5CA3FC2-CC92-4CB6-A894-7BA83A25E7C6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D5CA3FC2-CC92-4CB6-A894-7BA83A25E7C6}.Debug|x64.ActiveCfg = Debug|Any CPU
{D5CA3FC2-CC92-4CB6-A894-7BA83A25E7C6}.Debug|x64.Build.0 = Debug|Any CPU
{D5CA3FC2-CC92-4CB6-A894-7BA83A25E7C6}.Debug|x86.ActiveCfg = Debug|Any CPU
{D5CA3FC2-CC92-4CB6-A894-7BA83A25E7C6}.Debug|x86.Build.0 = Debug|Any CPU
{D5CA3FC2-CC92-4CB6-A894-7BA83A25E7C6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D5CA3FC2-CC92-4CB6-A894-7BA83A25E7C6}.Release|Any CPU.Build.0 = Release|Any CPU
{D5CA3FC2-CC92-4CB6-A894-7BA83A25E7C6}.Release|x64.ActiveCfg = Release|Any CPU
{D5CA3FC2-CC92-4CB6-A894-7BA83A25E7C6}.Release|x64.Build.0 = Release|Any CPU
{D5CA3FC2-CC92-4CB6-A894-7BA83A25E7C6}.Release|x86.ActiveCfg = Release|Any CPU
{D5CA3FC2-CC92-4CB6-A894-7BA83A25E7C6}.Release|x86.Build.0 = Release|Any CPU
{76B3C1EC-565B-4424-B242-DCAB40C7BD21}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{76B3C1EC-565B-4424-B242-DCAB40C7BD21}.Debug|Any CPU.Build.0 = Debug|Any CPU
{76B3C1EC-565B-4424-B242-DCAB40C7BD21}.Debug|x64.ActiveCfg = Debug|Any CPU
{76B3C1EC-565B-4424-B242-DCAB40C7BD21}.Debug|x64.Build.0 = Debug|Any CPU
{76B3C1EC-565B-4424-B242-DCAB40C7BD21}.Debug|x86.ActiveCfg = Debug|Any CPU
{76B3C1EC-565B-4424-B242-DCAB40C7BD21}.Debug|x86.Build.0 = Debug|Any CPU
{76B3C1EC-565B-4424-B242-DCAB40C7BD21}.Release|Any CPU.ActiveCfg = Release|Any CPU
{76B3C1EC-565B-4424-B242-DCAB40C7BD21}.Release|Any CPU.Build.0 = Release|Any CPU
{76B3C1EC-565B-4424-B242-DCAB40C7BD21}.Release|x64.ActiveCfg = Release|Any CPU
{76B3C1EC-565B-4424-B242-DCAB40C7BD21}.Release|x64.Build.0 = Release|Any CPU
{76B3C1EC-565B-4424-B242-DCAB40C7BD21}.Release|x86.ActiveCfg = Release|Any CPU
{76B3C1EC-565B-4424-B242-DCAB40C7BD21}.Release|x86.Build.0 = Release|Any CPU
{28F5E1F1-291F-469A-BCA3-AA1458C85570}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{28F5E1F1-291F-469A-BCA3-AA1458C85570}.Debug|Any CPU.Build.0 = Debug|Any CPU
{28F5E1F1-291F-469A-BCA3-AA1458C85570}.Debug|x64.ActiveCfg = Debug|Any CPU
{28F5E1F1-291F-469A-BCA3-AA1458C85570}.Debug|x64.Build.0 = Debug|Any CPU
{28F5E1F1-291F-469A-BCA3-AA1458C85570}.Debug|x86.ActiveCfg = Debug|Any CPU
{28F5E1F1-291F-469A-BCA3-AA1458C85570}.Debug|x86.Build.0 = Debug|Any CPU
{28F5E1F1-291F-469A-BCA3-AA1458C85570}.Release|Any CPU.ActiveCfg = Release|Any CPU
{28F5E1F1-291F-469A-BCA3-AA1458C85570}.Release|Any CPU.Build.0 = Release|Any CPU
{28F5E1F1-291F-469A-BCA3-AA1458C85570}.Release|x64.ActiveCfg = Release|Any CPU
{28F5E1F1-291F-469A-BCA3-AA1458C85570}.Release|x64.Build.0 = Release|Any CPU
{28F5E1F1-291F-469A-BCA3-AA1458C85570}.Release|x86.ActiveCfg = Release|Any CPU
{28F5E1F1-291F-469A-BCA3-AA1458C85570}.Release|x86.Build.0 = Release|Any CPU
{5D4B3AEE-D534-45A2-AF40-B09ACD4D0F13}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5D4B3AEE-D534-45A2-AF40-B09ACD4D0F13}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5D4B3AEE-D534-45A2-AF40-B09ACD4D0F13}.Debug|x64.ActiveCfg = Debug|Any CPU
{5D4B3AEE-D534-45A2-AF40-B09ACD4D0F13}.Debug|x64.Build.0 = Debug|Any CPU
{5D4B3AEE-D534-45A2-AF40-B09ACD4D0F13}.Debug|x86.ActiveCfg = Debug|Any CPU
{5D4B3AEE-D534-45A2-AF40-B09ACD4D0F13}.Debug|x86.Build.0 = Debug|Any CPU
{5D4B3AEE-D534-45A2-AF40-B09ACD4D0F13}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5D4B3AEE-D534-45A2-AF40-B09ACD4D0F13}.Release|Any CPU.Build.0 = Release|Any CPU
{5D4B3AEE-D534-45A2-AF40-B09ACD4D0F13}.Release|x64.ActiveCfg = Release|Any CPU
{5D4B3AEE-D534-45A2-AF40-B09ACD4D0F13}.Release|x64.Build.0 = Release|Any CPU
{5D4B3AEE-D534-45A2-AF40-B09ACD4D0F13}.Release|x86.ActiveCfg = Release|Any CPU
{5D4B3AEE-D534-45A2-AF40-B09ACD4D0F13}.Release|x86.Build.0 = Release|Any CPU
{FB127279-C17B-40DC-AC68-320B7CE85E76}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FB127279-C17B-40DC-AC68-320B7CE85E76}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FB127279-C17B-40DC-AC68-320B7CE85E76}.Debug|x64.ActiveCfg = Debug|Any CPU
{FB127279-C17B-40DC-AC68-320B7CE85E76}.Debug|x64.Build.0 = Debug|Any CPU
{FB127279-C17B-40DC-AC68-320B7CE85E76}.Debug|x86.ActiveCfg = Debug|Any CPU
{FB127279-C17B-40DC-AC68-320B7CE85E76}.Debug|x86.Build.0 = Debug|Any CPU
{FB127279-C17B-40DC-AC68-320B7CE85E76}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FB127279-C17B-40DC-AC68-320B7CE85E76}.Release|Any CPU.Build.0 = Release|Any CPU
{FB127279-C17B-40DC-AC68-320B7CE85E76}.Release|x64.ActiveCfg = Release|Any CPU
{FB127279-C17B-40DC-AC68-320B7CE85E76}.Release|x64.Build.0 = Release|Any CPU
{FB127279-C17B-40DC-AC68-320B7CE85E76}.Release|x86.ActiveCfg = Release|Any CPU
{FB127279-C17B-40DC-AC68-320B7CE85E76}.Release|x86.Build.0 = Release|Any CPU
{AAE98543-46B4-4707-AD1F-CCC9142F8712}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AAE98543-46B4-4707-AD1F-CCC9142F8712}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AAE98543-46B4-4707-AD1F-CCC9142F8712}.Debug|x64.ActiveCfg = Debug|Any CPU
{AAE98543-46B4-4707-AD1F-CCC9142F8712}.Debug|x64.Build.0 = Debug|Any CPU
{AAE98543-46B4-4707-AD1F-CCC9142F8712}.Debug|x86.ActiveCfg = Debug|Any CPU
{AAE98543-46B4-4707-AD1F-CCC9142F8712}.Debug|x86.Build.0 = Debug|Any CPU
{AAE98543-46B4-4707-AD1F-CCC9142F8712}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AAE98543-46B4-4707-AD1F-CCC9142F8712}.Release|Any CPU.Build.0 = Release|Any CPU
{AAE98543-46B4-4707-AD1F-CCC9142F8712}.Release|x64.ActiveCfg = Release|Any CPU
{AAE98543-46B4-4707-AD1F-CCC9142F8712}.Release|x64.Build.0 = Release|Any CPU
{AAE98543-46B4-4707-AD1F-CCC9142F8712}.Release|x86.ActiveCfg = Release|Any CPU
{AAE98543-46B4-4707-AD1F-CCC9142F8712}.Release|x86.Build.0 = Release|Any CPU
{C12D06F8-7B69-4A24-B206-C47326778F2E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C12D06F8-7B69-4A24-B206-C47326778F2E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C12D06F8-7B69-4A24-B206-C47326778F2E}.Debug|x64.ActiveCfg = Debug|Any CPU
{C12D06F8-7B69-4A24-B206-C47326778F2E}.Debug|x64.Build.0 = Debug|Any CPU
{C12D06F8-7B69-4A24-B206-C47326778F2E}.Debug|x86.ActiveCfg = Debug|Any CPU
{C12D06F8-7B69-4A24-B206-C47326778F2E}.Debug|x86.Build.0 = Debug|Any CPU
{C12D06F8-7B69-4A24-B206-C47326778F2E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C12D06F8-7B69-4A24-B206-C47326778F2E}.Release|Any CPU.Build.0 = Release|Any CPU
{C12D06F8-7B69-4A24-B206-C47326778F2E}.Release|x64.ActiveCfg = Release|Any CPU
{C12D06F8-7B69-4A24-B206-C47326778F2E}.Release|x64.Build.0 = Release|Any CPU
{C12D06F8-7B69-4A24-B206-C47326778F2E}.Release|x86.ActiveCfg = Release|Any CPU
{C12D06F8-7B69-4A24-B206-C47326778F2E}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -692,6 +1238,14 @@ Global
{CC319FC5-F4B1-C3DD-7310-4DAD343E0125} = {BC12ED55-6015-7C8B-8384-B39CE93C76D6}
{AF043113-CCE3-59C1-DF71-9804155F26A8} = {8380A20C-A5B8-EE91-1A58-270323688CB9}
{409497C7-2EDE-4DC8-B749-17BCE479102A} = {A5C98087-E847-D2C4-2143-20869479839D}
{4E1D1B54-CDF1-4F5C-8189-731E71E0DF19} = {BB76B5A5-14BA-E317-828D-110B711D71F5}
{D5CA3FC2-CC92-4CB6-A894-7BA83A25E7C6} = {BB76B5A5-14BA-E317-828D-110B711D71F5}
{76B3C1EC-565B-4424-B242-DCAB40C7BD21} = {BB76B5A5-14BA-E317-828D-110B711D71F5}
{28F5E1F1-291F-469A-BCA3-AA1458C85570} = {BB76B5A5-14BA-E317-828D-110B711D71F5}
{5D4B3AEE-D534-45A2-AF40-B09ACD4D0F13} = {BB76B5A5-14BA-E317-828D-110B711D71F5}
{FB127279-C17B-40DC-AC68-320B7CE85E76} = {BB76B5A5-14BA-E317-828D-110B711D71F5}
{AAE98543-46B4-4707-AD1F-CCC9142F8712} = {BB76B5A5-14BA-E317-828D-110B711D71F5}
{C12D06F8-7B69-4A24-B206-C47326778F2E} = {BB76B5A5-14BA-E317-828D-110B711D71F5}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {21B6BF22-3A64-CD15-49B3-21A490AAD068}

View File

@@ -97,7 +97,7 @@ public sealed record FingerprintClaimEvidence
public required IReadOnlyList<string> ChangedFunctions { get; init; }
/// <summary>
/// Similarity scores for modified functions (function name score).
/// Similarity scores for modified functions (function name -> score).
/// </summary>
public IReadOnlyDictionary<string, decimal>? FunctionSimilarities { get; init; }

View File

@@ -0,0 +1,17 @@
namespace StellaOps.BinaryIndex.Builders;
/// <summary>
/// Provides GUIDs for deterministic testing.
/// </summary>
public interface IGuidProvider
{
Guid NewGuid();
}
/// <summary>
/// Default GUID provider using <see cref="Guid.NewGuid"/>.
/// </summary>
public sealed class GuidProvider : IGuidProvider
{
public Guid NewGuid() => Guid.NewGuid();
}

View File

@@ -1,4 +1,3 @@
using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
namespace StellaOps.BinaryIndex.Builders;
@@ -31,10 +30,13 @@ public sealed class PatchDiffEngine : IPatchDiffEngine
vulnerable.Count, patched.Count);
var changes = new List<FunctionChange>();
var weights = GetEffectiveWeights(options.Weights);
// Index by name for quick lookup
var vulnerableByName = vulnerable.ToDictionary(f => f.Name, f => f);
var patchedByName = patched.ToDictionary(f => f.Name, f => f);
var patchedByNormalizedName = options.FuzzyNameMatching
? BuildNormalizedNameIndex(patched)
: null;
// Track processed functions to find additions
var processedPatched = new HashSet<string>();
@@ -46,7 +48,7 @@ public sealed class PatchDiffEngine : IPatchDiffEngine
{
processedPatched.Add(vulnFunc.Name);
var similarity = ComputeSimilarity(vulnFunc, patchedFunc);
var similarity = ComputeSimilarity(vulnFunc, patchedFunc, weights);
if (similarity >= 1.0m)
{
@@ -86,17 +88,34 @@ public sealed class PatchDiffEngine : IPatchDiffEngine
}
else
{
if (options.FuzzyNameMatching &&
TryGetFuzzyMatch(vulnFunc.Name, patchedByNormalizedName, processedPatched, out var fuzzyMatch))
{
processedPatched.Add(fuzzyMatch.Name);
var similarity = ComputeSimilarity(vulnFunc, fuzzyMatch, weights);
changes.Add(new FunctionChange
{
FunctionName = vulnFunc.Name,
Type = similarity >= options.SimilarityThreshold ? ChangeType.Modified : ChangeType.SignatureChanged,
VulnerableFingerprint = vulnFunc,
PatchedFingerprint = fuzzyMatch,
SimilarityScore = similarity,
DifferingHashes = GetDifferingHashes(vulnFunc, fuzzyMatch)
});
continue;
}
// Not found by name - check if renamed
if (options.DetectRenames)
{
var bestMatch = FindBestMatch(vulnFunc, patched, processedPatched, options.RenameThreshold);
var bestMatch = FindBestMatch(vulnFunc, patched, processedPatched, options.RenameThreshold, weights);
if (bestMatch != null)
{
processedPatched.Add(bestMatch.Name);
var similarity = ComputeSimilarity(vulnFunc, bestMatch);
var similarity = ComputeSimilarity(vulnFunc, bestMatch, weights);
changes.Add(new FunctionChange
{
FunctionName = $"{vulnFunc.Name} {bestMatch.Name}",
FunctionName = $"{vulnFunc.Name} -> {bestMatch.Name}",
Type = ChangeType.Modified,
VulnerableFingerprint = vulnFunc,
PatchedFingerprint = bestMatch,
@@ -156,32 +175,31 @@ public sealed class PatchDiffEngine : IPatchDiffEngine
ArgumentNullException.ThrowIfNull(a);
ArgumentNullException.ThrowIfNull(b);
return ComputeSimilarity(a, b, HashWeights.Default);
}
private static decimal ComputeSimilarity(FunctionFingerprint a, FunctionFingerprint b, HashWeights weights)
{
// Compute weighted similarity based on hash matches
decimal totalWeight = 0m;
decimal matchedWeight = 0m;
// Basic block hash (weight: 0.5)
const decimal bbWeight = 0.5m;
totalWeight += bbWeight;
totalWeight += weights.BasicBlockWeight;
if (HashesEqual(a.BasicBlockHash, b.BasicBlockHash))
{
matchedWeight += bbWeight;
matchedWeight += weights.BasicBlockWeight;
}
// CFG hash (weight: 0.3)
const decimal cfgWeight = 0.3m;
totalWeight += cfgWeight;
totalWeight += weights.CfgWeight;
if (HashesEqual(a.CfgHash, b.CfgHash))
{
matchedWeight += cfgWeight;
matchedWeight += weights.CfgWeight;
}
// String refs hash (weight: 0.2)
const decimal strWeight = 0.2m;
totalWeight += strWeight;
totalWeight += weights.StringRefsWeight;
if (HashesEqual(a.StringRefsHash, b.StringRefsHash))
{
matchedWeight += strWeight;
matchedWeight += weights.StringRefsWeight;
}
// Size similarity bonus (if sizes are within 10%, add small bonus)
@@ -207,7 +225,8 @@ public sealed class PatchDiffEngine : IPatchDiffEngine
ArgumentNullException.ThrowIfNull(vulnerable);
ArgumentNullException.ThrowIfNull(patched);
var mappings = new Dictionary<string, string>();
var mappings = new Dictionary<string, string>(StringComparer.Ordinal);
var patchedByNormalizedName = BuildNormalizedNameIndex(patched);
var usedPatched = new HashSet<string>();
// First pass: exact name matches
@@ -218,6 +237,13 @@ public sealed class PatchDiffEngine : IPatchDiffEngine
{
mappings[vulnFunc.Name] = match.Name;
usedPatched.Add(match.Name);
continue;
}
if (TryGetFuzzyMatch(vulnFunc.Name, patchedByNormalizedName, usedPatched, out var fuzzyMatch))
{
mappings[vulnFunc.Name] = fuzzyMatch.Name;
usedPatched.Add(fuzzyMatch.Name);
}
}
@@ -227,7 +253,7 @@ public sealed class PatchDiffEngine : IPatchDiffEngine
foreach (var vulnFunc in unmatchedVulnerable)
{
var bestMatch = FindBestMatch(vulnFunc, unmatchedPatched, usedPatched, threshold);
var bestMatch = FindBestMatch(vulnFunc, unmatchedPatched, usedPatched, threshold, HashWeights.Default);
if (bestMatch != null)
{
mappings[vulnFunc.Name] = bestMatch.Name;
@@ -242,7 +268,8 @@ public sealed class PatchDiffEngine : IPatchDiffEngine
FunctionFingerprint target,
IReadOnlyList<FunctionFingerprint> candidates,
HashSet<string> excludeNames,
decimal threshold)
decimal threshold,
HashWeights weights)
{
FunctionFingerprint? bestMatch = null;
var bestScore = threshold - 0.001m; // Must exceed threshold
@@ -252,7 +279,7 @@ public sealed class PatchDiffEngine : IPatchDiffEngine
if (excludeNames.Contains(candidate.Name))
continue;
var score = ComputeSimilarity(target, candidate);
var score = ComputeSimilarity(target, candidate, weights);
if (score > bestScore)
{
bestScore = score;
@@ -263,6 +290,88 @@ public sealed class PatchDiffEngine : IPatchDiffEngine
return bestMatch;
}
private HashWeights GetEffectiveWeights(HashWeights weights)
{
if (!weights.IsValid)
{
_logger.LogWarning("Invalid diff weights supplied; using defaults.");
return HashWeights.Default;
}
return weights;
}
private static Dictionary<string, List<FunctionFingerprint>> BuildNormalizedNameIndex(
IReadOnlyList<FunctionFingerprint> fingerprints)
{
var index = new Dictionary<string, List<FunctionFingerprint>>(StringComparer.Ordinal);
foreach (var fingerprint in fingerprints)
{
var key = NormalizeName(fingerprint.Name);
if (!index.TryGetValue(key, out var bucket))
{
bucket = new List<FunctionFingerprint>();
index[key] = bucket;
}
bucket.Add(fingerprint);
}
return index;
}
private static bool TryGetFuzzyMatch(
string name,
Dictionary<string, List<FunctionFingerprint>>? index,
HashSet<string> usedNames,
out FunctionFingerprint match)
{
match = null!;
if (index is null)
{
return false;
}
var normalized = NormalizeName(name);
if (!index.TryGetValue(normalized, out var candidates))
{
return false;
}
foreach (var candidate in candidates)
{
if (usedNames.Contains(candidate.Name))
{
continue;
}
match = candidate;
return true;
}
return false;
}
private static string NormalizeName(string name)
{
if (string.IsNullOrWhiteSpace(name))
{
return string.Empty;
}
var buffer = new char[name.Length];
var index = 0;
foreach (var ch in name)
{
if (char.IsLetterOrDigit(ch))
{
buffer[index++] = char.ToLowerInvariant(ch);
}
}
return new string(buffer, 0, index);
}
private IReadOnlyList<string> GetDifferingHashes(FunctionFingerprint a, FunctionFingerprint b)
{
var differing = new List<string>();

View File

@@ -130,6 +130,8 @@ public sealed class ReproducibleBuildJob : IReproducibleBuildJob
private readonly IPatchDiffEngine _diffEngine;
private readonly IFingerprintClaimRepository _claimRepository;
private readonly IAdvisoryFeedMonitor _advisoryMonitor;
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
/// <summary>
/// Initializes a new instance of <see cref="ReproducibleBuildJob"/>.
@@ -141,7 +143,9 @@ public sealed class ReproducibleBuildJob : IReproducibleBuildJob
IFunctionFingerprintExtractor fingerprintExtractor,
IPatchDiffEngine diffEngine,
IFingerprintClaimRepository claimRepository,
IAdvisoryFeedMonitor advisoryMonitor)
IAdvisoryFeedMonitor advisoryMonitor,
TimeProvider? timeProvider = null,
IGuidProvider? guidProvider = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
@@ -150,6 +154,8 @@ public sealed class ReproducibleBuildJob : IReproducibleBuildJob
_diffEngine = diffEngine ?? throw new ArgumentNullException(nameof(diffEngine));
_claimRepository = claimRepository ?? throw new ArgumentNullException(nameof(claimRepository));
_advisoryMonitor = advisoryMonitor ?? throw new ArgumentNullException(nameof(advisoryMonitor));
_timeProvider = timeProvider ?? TimeProvider.System;
_guidProvider = guidProvider ?? new GuidProvider();
}
/// <inheritdoc />
@@ -308,9 +314,17 @@ public sealed class ReproducibleBuildJob : IReproducibleBuildJob
{
var claims = new List<FingerprintClaim>();
var now = _timeProvider.GetUtcNow();
// Create "fixed" claims for patched binaries
foreach (var binary in patchedBuild.Binaries ?? [])
{
if (!TryGetFingerprintId(binary.BuildId, out var fingerprintId))
{
_logger.LogWarning("Skipping patched claim for {CveId}: build id '{BuildId}' is not a GUID.", cve.CveId, binary.BuildId);
continue;
}
var changedFunctions = diff.Changes
.Where(c => c.Type is ChangeType.Modified or ChangeType.Added)
.Select(c => c.FunctionName)
@@ -318,8 +332,8 @@ public sealed class ReproducibleBuildJob : IReproducibleBuildJob
var claim = new FingerprintClaim
{
Id = Guid.NewGuid(),
FingerprintId = Guid.Parse(binary.BuildId), // Assuming BuildId is GUID-like
Id = _guidProvider.NewGuid(),
FingerprintId = fingerprintId,
CveId = cve.CveId,
Verdict = ClaimVerdict.Fixed,
Evidence = new FingerprintClaimEvidence
@@ -332,7 +346,7 @@ public sealed class ReproducibleBuildJob : IReproducibleBuildJob
VulnerableBuildRef = vulnerableBuild.BuildLogRef,
PatchedBuildRef = patchedBuild.BuildLogRef
},
CreatedAt = DateTimeOffset.UtcNow
CreatedAt = now
};
claims.Add(claim);
@@ -341,10 +355,16 @@ public sealed class ReproducibleBuildJob : IReproducibleBuildJob
// Create "vulnerable" claims for vulnerable binaries
foreach (var binary in vulnerableBuild.Binaries ?? [])
{
if (!TryGetFingerprintId(binary.BuildId, out var fingerprintId))
{
_logger.LogWarning("Skipping vulnerable claim for {CveId}: build id '{BuildId}' is not a GUID.", cve.CveId, binary.BuildId);
continue;
}
var claim = new FingerprintClaim
{
Id = Guid.NewGuid(),
FingerprintId = Guid.Parse(binary.BuildId),
Id = _guidProvider.NewGuid(),
FingerprintId = fingerprintId,
CveId = cve.CveId,
Verdict = ClaimVerdict.Vulnerable,
Evidence = new FingerprintClaimEvidence
@@ -356,16 +376,54 @@ public sealed class ReproducibleBuildJob : IReproducibleBuildJob
.ToList(),
VulnerableBuildRef = vulnerableBuild.BuildLogRef
},
CreatedAt = DateTimeOffset.UtcNow
CreatedAt = now
};
claims.Add(claim);
}
if (claims.Count == 0)
{
_logger.LogWarning("No fingerprint claims created for CVE {CveId}; no valid build IDs were available.", cve.CveId);
return;
}
await _claimRepository.CreateClaimsBatchAsync(claims, ct);
_logger.LogDebug(
"Created {Count} fingerprint claims for CVE {CveId}",
claims.Count, cve.CveId);
}
private static bool TryGetFingerprintId(string buildId, out Guid fingerprintId)
{
if (Guid.TryParse(buildId, out fingerprintId))
{
return true;
}
if (buildId.Length == 32 && IsHex(buildId))
{
return Guid.TryParseExact(buildId, "N", out fingerprintId);
}
fingerprintId = Guid.Empty;
return false;
}
private static bool IsHex(string value)
{
foreach (var ch in value)
{
var isHex = ch is >= '0' and <= '9'
or >= 'a' and <= 'f'
or >= 'A' and <= 'F';
if (!isHex)
{
return false;
}
}
return true;
}
}

View File

@@ -23,12 +23,16 @@ public static class ServiceCollectionExtensions
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
// Configuration - register options with defaults (configuration binding happens via host)
services.Configure<BuilderServiceOptions>(options => { });
services.Configure<FunctionExtractionOptions>(options => { });
// Configuration - bind options from configuration
services.AddOptions<BuilderServiceOptions>()
.Bind(configuration.GetSection(BuilderServiceOptions.SectionName));
services.AddOptions<FunctionExtractionOptions>()
.Bind(configuration.GetSection(FunctionExtractionOptions.SectionName));
// Core services
services.TryAddSingleton<IPatchDiffEngine, PatchDiffEngine>();
services.TryAddSingleton<IGuidProvider, GuidProvider>();
services.TryAddSingleton(TimeProvider.System);
// Builders will be added as they are implemented
// services.TryAddSingleton<IReproducibleBuilder, AlpineBuilder>();
@@ -56,6 +60,8 @@ public static class ServiceCollectionExtensions
services.Configure(configureOptions);
services.TryAddSingleton<IPatchDiffEngine, PatchDiffEngine>();
services.TryAddSingleton<IGuidProvider, GuidProvider>();
services.TryAddSingleton(TimeProvider.System);
return services;
}

View File

@@ -5,7 +5,7 @@
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Description>Reproducible distro builders and function-level fingerprinting for StellaOps BinaryIndex.</Description>
</PropertyGroup>

View File

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

View File

@@ -35,6 +35,13 @@ public sealed class BinaryCacheOptions
/// </summary>
public TimeSpan FingerprintTtl { get; init; } = TimeSpan.FromMinutes(30);
/// <summary>
/// Optional fingerprint hash length for cache keys.
/// Set to 0 to use the full fingerprint hash.
/// Default: 0 (full hash).
/// </summary>
public int FingerprintHashLength { get; init; } = 0;
/// <summary>
/// Maximum TTL for any cache entry.
/// Default: 24 hours

View File

@@ -7,6 +7,7 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.BinaryIndex.Core.Services;
namespace StellaOps.BinaryIndex.Cache;
@@ -27,9 +28,12 @@ public static class BinaryCacheServiceExtensions
this IServiceCollection services,
IConfiguration configuration)
{
services.TryAddSingleton<IValidateOptions<BinaryCacheOptions>, BinaryCacheOptionsValidator>();
// Bind options
services.Configure<BinaryCacheOptions>(
configuration.GetSection("BinaryIndex:Cache"));
services.AddOptions<BinaryCacheOptions>()
.Bind(configuration.GetSection("BinaryIndex:Cache"))
.ValidateOnStart();
// Decorate the existing service with caching
services.Decorate<IBinaryVulnerabilityService, CachedBinaryVulnerabilityService>();
@@ -44,7 +48,10 @@ public static class BinaryCacheServiceExtensions
this IServiceCollection services,
Action<BinaryCacheOptions> configureOptions)
{
services.Configure(configureOptions);
services.TryAddSingleton<IValidateOptions<BinaryCacheOptions>, BinaryCacheOptionsValidator>();
services.AddOptions<BinaryCacheOptions>()
.Configure(configureOptions)
.ValidateOnStart();
services.Decorate<IBinaryVulnerabilityService, CachedBinaryVulnerabilityService>();
return services;

View File

@@ -0,0 +1,101 @@
using Microsoft.Extensions.Options;
namespace StellaOps.BinaryIndex.Cache;
public sealed class BinaryCacheOptionsValidator : IValidateOptions<BinaryCacheOptions>
{
public ValidateOptionsResult Validate(string? name, BinaryCacheOptions options)
{
if (options is null)
{
return ValidateOptionsResult.Fail("BinaryCacheOptions must be provided.");
}
var failures = new List<string>();
if (string.IsNullOrWhiteSpace(options.KeyPrefix))
{
failures.Add("BinaryCacheOptions.KeyPrefix must be set.");
}
if (options.MaxTtl <= TimeSpan.Zero)
{
failures.Add("BinaryCacheOptions.MaxTtl must be greater than zero.");
}
ValidateTtl(failures, options.IdentityTtl, options.MaxTtl, nameof(options.IdentityTtl));
ValidateTtl(failures, options.FixStatusTtl, options.MaxTtl, nameof(options.FixStatusTtl));
ValidateTtl(failures, options.FingerprintTtl, options.MaxTtl, nameof(options.FingerprintTtl));
if (options.TargetHitRate < 0 || options.TargetHitRate > 1)
{
failures.Add("BinaryCacheOptions.TargetHitRate must be between 0 and 1.");
}
if (options.FingerprintHashLength < 0)
{
failures.Add("BinaryCacheOptions.FingerprintHashLength must be zero or positive.");
}
return failures.Count > 0
? ValidateOptionsResult.Fail(failures)
: ValidateOptionsResult.Success;
}
private static void ValidateTtl(
ICollection<string> failures,
TimeSpan ttl,
TimeSpan maxTtl,
string name)
{
if (ttl <= TimeSpan.Zero)
{
failures.Add($"BinaryCacheOptions.{name} must be greater than zero.");
return;
}
if (maxTtl > TimeSpan.Zero && ttl > maxTtl)
{
failures.Add($"BinaryCacheOptions.{name} must be less than or equal to MaxTtl.");
}
}
}
public sealed class ResolutionCacheOptionsValidator : IValidateOptions<ResolutionCacheOptions>
{
public ValidateOptionsResult Validate(string? name, ResolutionCacheOptions options)
{
if (options is null)
{
return ValidateOptionsResult.Fail("ResolutionCacheOptions must be provided.");
}
var failures = new List<string>();
if (string.IsNullOrWhiteSpace(options.KeyPrefix))
{
failures.Add("ResolutionCacheOptions.KeyPrefix must be set.");
}
ValidateTtl(failures, options.FixedTtl, nameof(options.FixedTtl));
ValidateTtl(failures, options.VulnerableTtl, nameof(options.VulnerableTtl));
ValidateTtl(failures, options.UnknownTtl, nameof(options.UnknownTtl));
if (options.EarlyExpiryFactor < 0 || options.EarlyExpiryFactor > 1)
{
failures.Add("ResolutionCacheOptions.EarlyExpiryFactor must be between 0 and 1.");
}
return failures.Count > 0
? ValidateOptionsResult.Fail(failures)
: ValidateOptionsResult.Success;
}
private static void ValidateTtl(ICollection<string> failures, TimeSpan ttl, string name)
{
if (ttl <= TimeSpan.Zero)
{
failures.Add($"ResolutionCacheOptions.{name} must be greater than zero.");
}
}
}

View File

@@ -97,7 +97,7 @@ public sealed class CachedBinaryVulnerabilityService : IBinaryVulnerabilityServi
}
var sw = Stopwatch.StartNew();
var db = await GetDatabaseAsync().ConfigureAwait(false);
var db = await GetDatabaseAsync(ct).ConfigureAwait(false);
// Build cache keys
var cacheKeys = identityList
@@ -106,9 +106,9 @@ public sealed class CachedBinaryVulnerabilityService : IBinaryVulnerabilityServi
// Batch get from cache
var redisKeys = cacheKeys.Select(k => (RedisKey)k.Key).ToArray();
var cachedValues = await db.StringGetAsync(redisKeys).ConfigureAwait(false);
var cachedValues = await db.StringGetAsync(redisKeys).WaitAsync(ct).ConfigureAwait(false);
var results = new Dictionary<string, ImmutableArray<BinaryVulnMatch>>();
var results = new Dictionary<string, ImmutableArray<BinaryVulnMatch>>(StringComparer.Ordinal);
var misses = new List<BinaryIdentity>();
for (int i = 0; i < cacheKeys.Count; i++)
@@ -134,9 +134,10 @@ public sealed class CachedBinaryVulnerabilityService : IBinaryVulnerabilityServi
misses.Add(identity);
}
var cacheHits = results.Count;
_logger.LogDebug(
"Batch lookup: {Hits} cache hits, {Misses} cache misses",
results.Count,
cacheHits,
misses.Count);
// Fetch misses from inner service
@@ -148,19 +149,33 @@ public sealed class CachedBinaryVulnerabilityService : IBinaryVulnerabilityServi
var batch = db.CreateBatch();
var tasks = new List<Task>();
var missLookup = new Dictionary<string, BinaryIdentity>(StringComparer.Ordinal);
foreach (var miss in misses)
{
missLookup[miss.BinaryKey] = miss;
}
foreach (var (binaryKey, matches) in fetchedResults)
{
results[binaryKey] = matches;
var identity = misses.First(i => i.BinaryKey == binaryKey);
var cacheKey = BuildIdentityKey(identity, options);
var value = JsonSerializer.Serialize(matches, _jsonOptions);
if (missLookup.TryGetValue(binaryKey, out var identity))
{
var cacheKey = BuildIdentityKey(identity, options);
var value = JsonSerializer.Serialize(matches, _jsonOptions);
tasks.Add(batch.StringSetAsync(cacheKey, value, _options.IdentityTtl));
tasks.Add(batch.StringSetAsync(cacheKey, value, _options.IdentityTtl));
}
else
{
_logger.LogWarning(
"Lookup batch returned unexpected key {BinaryKey} not requested for cache fill",
binaryKey);
}
}
batch.Execute();
await Task.WhenAll(tasks).ConfigureAwait(false);
await Task.WhenAll(tasks).WaitAsync(ct).ConfigureAwait(false);
}
sw.Stop();
@@ -168,7 +183,7 @@ public sealed class CachedBinaryVulnerabilityService : IBinaryVulnerabilityServi
"Batch lookup completed in {ElapsedMs}ms: {Total} total, {Hits} hits, {Misses} misses",
sw.Elapsed.TotalMilliseconds,
identityList.Count,
results.Count - misses.Count,
cacheHits,
misses.Count);
return results.ToImmutableDictionary();
@@ -220,7 +235,7 @@ public sealed class CachedBinaryVulnerabilityService : IBinaryVulnerabilityServi
return ImmutableDictionary<string, FixStatusResult>.Empty;
}
var db = await GetDatabaseAsync().ConfigureAwait(false);
var db = await GetDatabaseAsync(ct).ConfigureAwait(false);
// Build cache keys
var cacheKeys = cveList
@@ -229,7 +244,7 @@ public sealed class CachedBinaryVulnerabilityService : IBinaryVulnerabilityServi
// Batch get from cache
var redisKeys = cacheKeys.Select(k => (RedisKey)k.Key).ToArray();
var cachedValues = await db.StringGetAsync(redisKeys).ConfigureAwait(false);
var cachedValues = await db.StringGetAsync(redisKeys).WaitAsync(ct).ConfigureAwait(false);
var results = new Dictionary<string, FixStatusResult>();
var misses = new List<string>();
@@ -279,7 +294,7 @@ public sealed class CachedBinaryVulnerabilityService : IBinaryVulnerabilityServi
}
batch.Execute();
await Task.WhenAll(tasks).ConfigureAwait(false);
await Task.WhenAll(tasks).WaitAsync(ct).ConfigureAwait(false);
}
return results.ToImmutableDictionary();
@@ -355,20 +370,56 @@ public sealed class CachedBinaryVulnerabilityService : IBinaryVulnerabilityServi
{
try
{
var db = await GetDatabaseAsync().ConfigureAwait(false);
var server = _connectionMultiplexer.GetServer(_connectionMultiplexer.GetEndPoints().First());
var db = await GetDatabaseAsync(ct).ConfigureAwait(false);
var endpoints = _connectionMultiplexer.GetEndPoints();
if (endpoints.Length == 0)
{
_logger.LogWarning("No Redis endpoints available for cache invalidation");
return;
}
var pattern = $"{_options.KeyPrefix}fix:{distro}:{release}:*";
var keys = server.Keys(pattern: pattern).ToArray();
const int batchSize = 500;
long totalDeleted = 0;
if (keys.Length > 0)
foreach (var endpoint in endpoints)
{
ct.ThrowIfCancellationRequested();
var server = _connectionMultiplexer.GetServer(endpoint);
if (!server.IsConnected)
{
continue;
}
var buffer = new List<RedisKey>(batchSize);
foreach (var key in server.Keys(pattern: pattern, pageSize: batchSize))
{
ct.ThrowIfCancellationRequested();
buffer.Add(key);
if (buffer.Count >= batchSize)
{
totalDeleted += await db.KeyDeleteAsync(buffer.ToArray()).WaitAsync(ct).ConfigureAwait(false);
buffer.Clear();
}
}
if (buffer.Count > 0)
{
totalDeleted += await db.KeyDeleteAsync(buffer.ToArray()).WaitAsync(ct).ConfigureAwait(false);
}
}
if (totalDeleted > 0)
{
var deleted = await db.KeyDeleteAsync(keys).ConfigureAwait(false);
_logger.LogInformation(
"Invalidated {Count} cache entries for {Distro}:{Release}",
deleted, distro, release);
totalDeleted, distro, release);
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error invalidating cache for {Distro}:{Release}", distro, release);
@@ -390,15 +441,20 @@ public sealed class CachedBinaryVulnerabilityService : IBinaryVulnerabilityServi
{
var hash = Convert.ToHexString(fingerprint).ToLowerInvariant();
var algo = options?.Algorithm ?? "combined";
return $"{_options.KeyPrefix}fp:{algo}:{hash[..Math.Min(32, hash.Length)]}";
if (_options.FingerprintHashLength > 0 && _options.FingerprintHashLength < hash.Length)
{
hash = hash[.._options.FingerprintHashLength];
}
return $"{_options.KeyPrefix}fp:{algo}:{hash}";
}
private async Task<T?> GetFromCacheAsync<T>(string key, CancellationToken ct)
{
try
{
var db = await GetDatabaseAsync().ConfigureAwait(false);
var value = await db.StringGetAsync(key).ConfigureAwait(false);
var db = await GetDatabaseAsync(ct).ConfigureAwait(false);
var value = await db.StringGetAsync(key).WaitAsync(ct).ConfigureAwait(false);
if (value.IsNullOrEmpty)
{
@@ -407,6 +463,10 @@ public sealed class CachedBinaryVulnerabilityService : IBinaryVulnerabilityServi
return JsonSerializer.Deserialize<T>((string)value!, _jsonOptions);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error getting cache entry for key {Key}", key);
@@ -418,10 +478,14 @@ public sealed class CachedBinaryVulnerabilityService : IBinaryVulnerabilityServi
{
try
{
var db = await GetDatabaseAsync().ConfigureAwait(false);
var db = await GetDatabaseAsync(ct).ConfigureAwait(false);
var serialized = JsonSerializer.Serialize(value, _jsonOptions);
await db.StringSetAsync(key, serialized, ttl).ConfigureAwait(false);
await db.StringSetAsync(key, serialized, ttl).WaitAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
@@ -429,12 +493,12 @@ public sealed class CachedBinaryVulnerabilityService : IBinaryVulnerabilityServi
}
}
private async Task<IDatabase> GetDatabaseAsync()
private async Task<IDatabase> GetDatabaseAsync(CancellationToken ct)
{
if (_database is not null)
return _database;
await _connectionLock.WaitAsync().ConfigureAwait(false);
await _connectionLock.WaitAsync(ct).ConfigureAwait(false);
try
{
_database ??= _connectionMultiplexer.GetDatabase();

View File

@@ -0,0 +1,23 @@
namespace StellaOps.BinaryIndex.Cache;
public interface IRandomSource
{
double NextDouble();
}
public sealed class SystemRandomSource : IRandomSource
{
private readonly Random _random;
public SystemRandomSource()
: this(Random.Shared)
{
}
public SystemRandomSource(Random random)
{
_random = random ?? throw new ArgumentNullException(nameof(random));
}
public double NextDouble() => _random.NextDouble();
}

View File

@@ -107,15 +107,18 @@ public sealed class ResolutionCacheService : IResolutionCacheService
private readonly ResolutionCacheOptions _options;
private readonly ILogger<ResolutionCacheService> _logger;
private readonly JsonSerializerOptions _jsonOptions;
private readonly IRandomSource _random;
public ResolutionCacheService(
IConnectionMultiplexer redis,
IOptions<ResolutionCacheOptions> options,
ILogger<ResolutionCacheService> logger)
ILogger<ResolutionCacheService> logger,
IRandomSource random)
{
_redis = redis ?? throw new ArgumentNullException(nameof(redis));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_random = random ?? throw new ArgumentNullException(nameof(random));
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
@@ -129,7 +132,7 @@ public sealed class ResolutionCacheService : IResolutionCacheService
try
{
var db = _redis.GetDatabase();
var value = await db.StringGetAsync(cacheKey);
var value = await db.StringGetAsync(cacheKey).WaitAsync(ct).ConfigureAwait(false);
if (value.IsNullOrEmpty)
{
@@ -142,7 +145,7 @@ public sealed class ResolutionCacheService : IResolutionCacheService
// Check for probabilistic early expiry
if (_options.EnableEarlyExpiry && cached is not null)
{
var ttl = await db.KeyTimeToLiveAsync(cacheKey);
var ttl = await db.KeyTimeToLiveAsync(cacheKey).WaitAsync(ct).ConfigureAwait(false);
if (ShouldExpireEarly(ttl))
{
_logger.LogDebug("Early expiry triggered for key {CacheKey}", cacheKey);
@@ -153,6 +156,10 @@ public sealed class ResolutionCacheService : IResolutionCacheService
_logger.LogDebug("Cache hit for key {CacheKey}", cacheKey);
return cached;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get cache entry for key {CacheKey}", cacheKey);
@@ -168,9 +175,13 @@ public sealed class ResolutionCacheService : IResolutionCacheService
var db = _redis.GetDatabase();
var value = JsonSerializer.Serialize(result, _jsonOptions);
await db.StringSetAsync(cacheKey, value, ttl);
await db.StringSetAsync(cacheKey, value, ttl).WaitAsync(ct).ConfigureAwait(false);
_logger.LogDebug("Cached resolution for key {CacheKey} with TTL {Ttl}", cacheKey, ttl);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to cache resolution for key {CacheKey}", cacheKey);
@@ -182,17 +193,55 @@ public sealed class ResolutionCacheService : IResolutionCacheService
{
try
{
var server = _redis.GetServer(_redis.GetEndPoints().First());
var db = _redis.GetDatabase();
var keys = server.Keys(pattern: pattern).ToArray();
if (keys.Length > 0)
var endpoints = _redis.GetEndPoints();
if (endpoints.Length == 0)
{
await db.KeyDeleteAsync(keys);
_logger.LogInformation("Invalidated {Count} cache entries matching pattern {Pattern}",
keys.Length, pattern);
_logger.LogWarning("No Redis endpoints available for pattern invalidation");
return;
}
const int batchSize = 500;
long totalDeleted = 0;
foreach (var endpoint in endpoints)
{
ct.ThrowIfCancellationRequested();
var server = _redis.GetServer(endpoint);
if (!server.IsConnected)
{
continue;
}
var buffer = new List<RedisKey>(batchSize);
foreach (var key in server.Keys(pattern: pattern, pageSize: batchSize))
{
ct.ThrowIfCancellationRequested();
buffer.Add(key);
if (buffer.Count >= batchSize)
{
totalDeleted += await db.KeyDeleteAsync(buffer.ToArray()).WaitAsync(ct).ConfigureAwait(false);
buffer.Clear();
}
}
if (buffer.Count > 0)
{
totalDeleted += await db.KeyDeleteAsync(buffer.ToArray()).WaitAsync(ct).ConfigureAwait(false);
}
}
if (totalDeleted > 0)
{
_logger.LogInformation(
"Invalidated {Count} cache entries matching pattern {Pattern}",
totalDeleted,
pattern);
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
@@ -271,7 +320,7 @@ public sealed class ResolutionCacheService : IResolutionCacheService
return true;
// Probabilistic early expiry using exponential decay
var random = Random.Shared.NextDouble();
var random = _random.NextDouble();
var threshold = _options.EarlyExpiryFactor * Math.Exp(-remainingTtl.Value.TotalSeconds / 3600);
return random < threshold;

View File

@@ -6,7 +6,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.BinaryIndex.Cache</RootNamespace>
<AssemblyName>StellaOps.BinaryIndex.Cache</AssemblyName>
<Description>Valkey/Redis cache layer for BinaryIndex vulnerability lookups</Description>

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0114-M | DONE | Maintainability audit for StellaOps.BinaryIndex.Cache. |
| AUDIT-0114-T | DONE | Test coverage audit for StellaOps.BinaryIndex.Cache. |
| AUDIT-0114-A | TODO | Pending approval for changes. |
| AUDIT-0114-A | DONE | Applied cache fixes + tests. |

View File

@@ -5,7 +5,7 @@ namespace StellaOps.BinaryIndex.Contracts.Resolution;
/// <summary>
/// Request to resolve vulnerability status for a binary.
/// </summary>
public sealed record VulnResolutionRequest
public sealed record VulnResolutionRequest : IValidatableObject
{
/// <summary>
/// Package URL (PURL) or CPE identifier.
@@ -47,6 +47,25 @@ public sealed record VulnResolutionRequest
/// Distro hint for fix status lookup (e.g., "debian:bookworm").
/// </summary>
public string? DistroRelease { get; init; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (string.IsNullOrWhiteSpace(BuildId)
&& string.IsNullOrWhiteSpace(Fingerprint)
&& string.IsNullOrWhiteSpace(Hashes?.FileSha256)
&& string.IsNullOrWhiteSpace(Hashes?.TextSha256)
&& string.IsNullOrWhiteSpace(Hashes?.Blake3))
{
yield return new ValidationResult(
"At least one identifier is required (BuildId, Fingerprint, or Hashes).",
new[]
{
nameof(BuildId),
nameof(Fingerprint),
nameof(Hashes)
});
}
}
}
/// <summary>
@@ -67,7 +86,7 @@ public sealed record ResolutionHashes
/// <summary>
/// Response from vulnerability resolution.
/// </summary>
public sealed record VulnResolutionResponse
public sealed record VulnResolutionResponse : IValidatableObject
{
/// <summary>Package identifier from request.</summary>
public required string Package { get; init; }
@@ -92,6 +111,16 @@ public sealed record VulnResolutionResponse
/// <summary>CVE ID if a specific CVE was queried.</summary>
public string? CveId { get; init; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (ResolvedAt == default)
{
yield return new ValidationResult(
"ResolvedAt must be set to a valid timestamp.",
new[] { nameof(ResolvedAt) });
}
}
}
/// <summary>
@@ -142,17 +171,50 @@ public sealed record ResolutionEvidence
public string? FixMethod { get; init; }
}
public static class ResolutionMatchTypes
{
public const string BuildId = "build_id";
public const string Fingerprint = "fingerprint";
public const string HashExact = "hash_exact";
public const string Package = "package";
public const string RangeMatch = "range_match";
public const string DeltaSignature = "delta_signature";
public const string FixStatus = "fix_status";
public const string Unknown = "unknown";
}
public static class ResolutionFixMethods
{
public const string SecurityFeed = "security_feed";
public const string Changelog = "changelog";
public const string PatchHeader = "patch_header";
public const string DeltaSignature = "delta_signature";
public const string UpstreamPatchMatch = "upstream_patch_match";
public const string Unknown = "unknown";
}
/// <summary>
/// Batch request for resolving multiple vulnerabilities.
/// </summary>
public sealed record BatchVulnResolutionRequest
public sealed record BatchVulnResolutionRequest : IValidatableObject
{
/// <summary>List of resolution requests.</summary>
[Required]
[MinLength(1)]
public required IReadOnlyList<VulnResolutionRequest> Items { get; init; }
/// <summary>Resolution options.</summary>
public BatchResolutionOptions? Options { get; init; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (Items is null || Items.Count == 0)
{
yield return new ValidationResult(
"Items must contain at least one request.",
new[] { nameof(Items) });
}
}
}
/// <summary>

View File

@@ -2,6 +2,7 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0115-M | DONE | Maintainability audit for StellaOps.BinaryIndex.Contracts. |
| AUDIT-0115-T | DONE | Test coverage audit for StellaOps.BinaryIndex.Contracts. |
| AUDIT-0115-A | TODO | Pending approval for changes. |
| AUDIT-0115-A | DONE | Applied contract fixes + tests. |

View File

@@ -4,6 +4,8 @@ using Microsoft.Extensions.Options;
using StellaOps.BinaryIndex.Contracts.Resolution;
using StellaOps.BinaryIndex.Core.Models;
using StellaOps.BinaryIndex.Core.Services;
using ResolutionFixMethods = StellaOps.BinaryIndex.Contracts.Resolution.ResolutionFixMethods;
using ResolutionMatchTypes = StellaOps.BinaryIndex.Contracts.Resolution.ResolutionMatchTypes;
namespace StellaOps.BinaryIndex.Core.Resolution;
@@ -76,15 +78,18 @@ public sealed class ResolutionService : IResolutionService
private readonly IBinaryVulnerabilityService _vulnerabilityService;
private readonly ResolutionServiceOptions _options;
private readonly ILogger<ResolutionService> _logger;
private readonly TimeProvider _timeProvider;
public ResolutionService(
IBinaryVulnerabilityService vulnerabilityService,
IOptions<ResolutionServiceOptions> options,
ILogger<ResolutionService> logger)
ILogger<ResolutionService> logger,
TimeProvider timeProvider)
{
_vulnerabilityService = vulnerabilityService ?? throw new ArgumentNullException(nameof(vulnerabilityService));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
/// <inheritdoc />
@@ -95,15 +100,13 @@ public sealed class ResolutionService : IResolutionService
{
ArgumentNullException.ThrowIfNull(request);
var sw = Stopwatch.StartNew();
var effectiveOptions = options ?? new ResolutionOptions();
var resolvedAt = _timeProvider.GetUtcNow();
_logger.LogDebug("Resolving vulnerability for package {Package}", request.Package);
// Build binary identity from request
var identity = BuildBinaryIdentity(request);
EnsureIdentifiersPresent(request);
// Perform lookup
var lookupOptions = new LookupOptions
{
DistroHint = ExtractDistro(request.DistroRelease),
@@ -114,11 +117,18 @@ public sealed class ResolutionService : IResolutionService
// Check if specific CVE requested
if (!string.IsNullOrEmpty(request.CveId))
{
return await ResolveSingleCveAsync(request, identity, lookupOptions, effectiveOptions, sw, ct);
return await ResolveSingleCveAsync(request, resolvedAt, ct);
}
if (HasFingerprintOnly(request))
{
return await ResolveByFingerprintAsync(request, lookupOptions, resolvedAt, ct);
}
var identity = BuildBinaryIdentity(request, resolvedAt);
// Full lookup - all CVEs
return await ResolveAllCvesAsync(request, identity, lookupOptions, effectiveOptions, sw, ct);
return await ResolveAllCvesAsync(request, identity, lookupOptions, resolvedAt, ct);
}
/// <inheritdoc />
@@ -174,7 +184,7 @@ public sealed class ResolutionService : IResolutionService
{
Package = item.Package,
Status = ResolutionStatus.Unknown,
ResolvedAt = DateTimeOffset.UtcNow,
ResolvedAt = _timeProvider.GetUtcNow(),
FromCache = false
});
}
@@ -191,10 +201,7 @@ public sealed class ResolutionService : IResolutionService
private async Task<VulnResolutionResponse> ResolveSingleCveAsync(
VulnResolutionRequest request,
BinaryIdentity identity,
LookupOptions lookupOptions,
ResolutionOptions options,
Stopwatch sw,
DateTimeOffset resolvedAt,
CancellationToken ct)
{
// Check fix status for specific CVE
@@ -214,7 +221,7 @@ public sealed class ResolutionService : IResolutionService
FixedVersion = fixStatus?.FixedVersion,
Evidence = evidence,
CveId = request.CveId,
ResolvedAt = DateTimeOffset.UtcNow,
ResolvedAt = resolvedAt,
FromCache = false
};
}
@@ -223,8 +230,7 @@ public sealed class ResolutionService : IResolutionService
VulnResolutionRequest request,
BinaryIdentity identity,
LookupOptions lookupOptions,
ResolutionOptions options,
Stopwatch sw,
DateTimeOffset resolvedAt,
CancellationToken ct)
{
// Perform full binary lookup
@@ -238,7 +244,7 @@ public sealed class ResolutionService : IResolutionService
{
Package = request.Package,
Status = ResolutionStatus.NotAffected,
ResolvedAt = DateTimeOffset.UtcNow,
ResolvedAt = resolvedAt,
FromCache = false
};
}
@@ -248,7 +254,7 @@ public sealed class ResolutionService : IResolutionService
var evidence = new ResolutionEvidence
{
MatchType = primaryMatch.Method.ToString().ToLowerInvariant(),
MatchType = MapMatchType(primaryMatch.Method),
Confidence = primaryMatch.Confidence,
MatchedFingerprintIds = matches.Select(m => m.CveId).ToList()
};
@@ -267,26 +273,82 @@ public sealed class ResolutionService : IResolutionService
Package = request.Package,
Status = status,
Evidence = evidence,
ResolvedAt = DateTimeOffset.UtcNow,
ResolvedAt = resolvedAt,
FromCache = false
};
}
private static BinaryIdentity BuildBinaryIdentity(VulnResolutionRequest request)
private async Task<VulnResolutionResponse> ResolveByFingerprintAsync(
VulnResolutionRequest request,
LookupOptions lookupOptions,
DateTimeOffset resolvedAt,
CancellationToken ct)
{
var binaryKey = request.BuildId
?? request.Hashes?.FileSha256
?? request.Package;
var fingerprintBytes = Convert.FromBase64String(request.Fingerprint!);
var matches = await _vulnerabilityService.LookupByFingerprintAsync(
fingerprintBytes,
new FingerprintLookupOptions
{
Algorithm = request.FingerprintAlgorithm,
DistroHint = lookupOptions.DistroHint,
ReleaseHint = lookupOptions.ReleaseHint,
CheckFixIndex = true
},
ct);
if (matches.IsEmpty)
{
return new VulnResolutionResponse
{
Package = request.Package,
Status = ResolutionStatus.NotAffected,
ResolvedAt = resolvedAt,
FromCache = false
};
}
var primaryMatch = matches.OrderByDescending(m => m.Confidence).First();
var evidence = new ResolutionEvidence
{
MatchType = ResolutionMatchTypes.Fingerprint,
Confidence = primaryMatch.Confidence,
MatchedFingerprintIds = matches.Select(m => m.CveId).ToList()
};
var status = primaryMatch.Confidence >= _options.MinConfidenceThreshold
? ResolutionStatus.Fixed
: ResolutionStatus.Unknown;
return new VulnResolutionResponse
{
Package = request.Package,
Status = status,
Evidence = evidence,
ResolvedAt = resolvedAt,
FromCache = false
};
}
private BinaryIdentity BuildBinaryIdentity(VulnResolutionRequest request, DateTimeOffset resolvedAt)
{
var binaryKey = request.BuildId
?? request.Hashes?.FileSha256
?? request.Hashes?.TextSha256
?? request.Hashes?.Blake3
?? throw new ArgumentException("Binary identifier is required.");
return new BinaryIdentity
{
BinaryKey = binaryKey,
BuildId = request.BuildId,
FileSha256 = request.Hashes?.FileSha256 ?? "sha256:unknown",
FileSha256 = request.Hashes?.FileSha256 ?? string.Empty,
TextSha256 = request.Hashes?.TextSha256,
Blake3Hash = request.Hashes?.Blake3,
Format = BinaryFormat.Elf,
Architecture = "unknown"
Architecture = string.Empty,
CreatedAt = resolvedAt,
UpdatedAt = resolvedAt
};
}
@@ -309,9 +371,9 @@ public sealed class ResolutionService : IResolutionService
var evidence = new ResolutionEvidence
{
MatchType = "fix_status",
MatchType = ResolutionMatchTypes.FixStatus,
Confidence = fixStatus.Confidence,
FixMethod = fixStatus.Method.ToString().ToLowerInvariant()
FixMethod = MapFixMethod(fixStatus.Method)
};
return (status, evidence);
@@ -357,4 +419,45 @@ public sealed class ResolutionService : IResolutionService
return null;
}
private static string MapMatchType(MatchMethod method) => method switch
{
MatchMethod.BuildIdCatalog => ResolutionMatchTypes.BuildId,
MatchMethod.FingerprintMatch => ResolutionMatchTypes.Fingerprint,
MatchMethod.RangeMatch => ResolutionMatchTypes.RangeMatch,
MatchMethod.DeltaSignature => ResolutionMatchTypes.DeltaSignature,
_ => ResolutionMatchTypes.Unknown
};
private static string MapFixMethod(FixMethod method) => method switch
{
FixMethod.SecurityFeed => ResolutionFixMethods.SecurityFeed,
FixMethod.Changelog => ResolutionFixMethods.Changelog,
FixMethod.PatchHeader => ResolutionFixMethods.PatchHeader,
FixMethod.UpstreamPatchMatch => ResolutionFixMethods.UpstreamPatchMatch,
_ => ResolutionFixMethods.Unknown
};
private static void EnsureIdentifiersPresent(VulnResolutionRequest request)
{
if (!HasBuildIdOrHashes(request) && string.IsNullOrWhiteSpace(request.Fingerprint))
{
throw new ArgumentException(
"At least one identifier is required (BuildId, Fingerprint, or Hashes).",
nameof(request));
}
}
private static bool HasFingerprintOnly(VulnResolutionRequest request)
{
return !HasBuildIdOrHashes(request) && !string.IsNullOrWhiteSpace(request.Fingerprint);
}
private static bool HasBuildIdOrHashes(VulnResolutionRequest request)
{
return !string.IsNullOrWhiteSpace(request.BuildId)
|| !string.IsNullOrWhiteSpace(request.Hashes?.FileSha256)
|| !string.IsNullOrWhiteSpace(request.Hashes?.TextSha256)
|| !string.IsNullOrWhiteSpace(request.Hashes?.Blake3);
}
}

View File

@@ -57,6 +57,8 @@ public sealed class BinaryIdentityService
foreach (var (stream, path) in binaries)
{
ct.ThrowIfCancellationRequested();
try
{
var identity = await IndexBinaryAsync(stream, path, ct);

View File

@@ -10,9 +10,18 @@ namespace StellaOps.BinaryIndex.Core.Services;
public sealed class ElfFeatureExtractor : IBinaryFeatureExtractor
{
private static readonly byte[] ElfMagic = [0x7F, 0x45, 0x4C, 0x46]; // \x7fELF
private readonly TimeProvider _timeProvider;
public ElfFeatureExtractor(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public bool CanExtract(Stream stream)
{
if (stream is null || !stream.CanSeek || !stream.CanRead)
return false;
if (stream.Length < 4)
return false;
@@ -21,7 +30,7 @@ public sealed class ElfFeatureExtractor : IBinaryFeatureExtractor
{
Span<byte> magic = stackalloc byte[4];
stream.Position = 0;
var read = stream.Read(magic);
var read = stream.ReadAtLeast(magic, magic.Length, throwOnEndOfStream: false);
return read == 4 && magic.SequenceEqual(ElfMagic);
}
finally
@@ -32,6 +41,7 @@ public sealed class ElfFeatureExtractor : IBinaryFeatureExtractor
public async Task<BinaryIdentity> ExtractIdentityAsync(Stream stream, CancellationToken ct = default)
{
StreamGuard.EnsureSeekable(stream, "ELF identity extraction");
var metadata = await ExtractMetadataAsync(stream, ct);
// Compute full file SHA-256
@@ -43,6 +53,7 @@ public sealed class ElfFeatureExtractor : IBinaryFeatureExtractor
? $"{metadata.BuildId}:{fileSha256}"
: fileSha256;
var now = _timeProvider.GetUtcNow();
return new BinaryIdentity
{
BinaryKey = binaryKey,
@@ -53,15 +64,18 @@ public sealed class ElfFeatureExtractor : IBinaryFeatureExtractor
Architecture = metadata.Architecture,
OsAbi = metadata.OsAbi,
Type = metadata.Type,
IsStripped = metadata.IsStripped
IsStripped = metadata.IsStripped,
CreatedAt = now,
UpdatedAt = now
};
}
public Task<BinaryMetadata> ExtractMetadataAsync(Stream stream, CancellationToken ct = default)
{
StreamGuard.EnsureSeekable(stream, "ELF metadata extraction");
stream.Position = 0;
Span<byte> header = stackalloc byte[64];
var read = stream.Read(header);
var read = stream.ReadAtLeast(header, header.Length, throwOnEndOfStream: false);
if (read < 20)
throw new InvalidDataException("Stream too short for ELF header");
@@ -76,7 +90,7 @@ public sealed class ElfFeatureExtractor : IBinaryFeatureExtractor
var architecture = MapArchitecture(eMachine);
var osAbiStr = MapOsAbi(osAbi);
var type = MapBinaryType(eType);
var buildId = ExtractBuildId(stream);
var buildId = ExtractBuildId(stream, ct);
return Task.FromResult(new BinaryMetadata
{
@@ -90,28 +104,62 @@ public sealed class ElfFeatureExtractor : IBinaryFeatureExtractor
});
}
private static string? ExtractBuildId(Stream stream)
private static string? ExtractBuildId(Stream stream, CancellationToken ct)
{
StreamGuard.EnsureSeekable(stream, "ELF build-id scan");
// Simplified: scan for .note.gnu.build-id section
// In production, parse program headers properly
stream.Position = 0;
var buffer = new byte[stream.Length];
stream.Read(buffer);
// Look for NT_GNU_BUILD_ID note (type 3)
var buildIdPattern = Encoding.ASCII.GetBytes(".note.gnu.build-id");
for (var i = 0; i < buffer.Length - buildIdPattern.Length; i++)
var buffer = new byte[64 * 1024];
var carry = new byte[buildIdPattern.Length - 1];
var carryCount = 0;
long offset = 0;
while (true)
{
if (buffer.AsSpan(i, buildIdPattern.Length).SequenceEqual(buildIdPattern))
ct.ThrowIfCancellationRequested();
var read = stream.Read(buffer, 0, buffer.Length);
if (read == 0)
break;
var combined = new byte[carryCount + read];
if (carryCount > 0)
{
// Found build-id section, extract it
// This is simplified; real implementation would parse note structure
var noteStart = i + buildIdPattern.Length + 16;
if (noteStart + 20 < buffer.Length)
Buffer.BlockCopy(carry, 0, combined, 0, carryCount);
}
Buffer.BlockCopy(buffer, 0, combined, carryCount, read);
for (var i = 0; i <= combined.Length - buildIdPattern.Length; i++)
{
if (combined.AsSpan(i, buildIdPattern.Length).SequenceEqual(buildIdPattern))
{
return Convert.ToHexString(buffer.AsSpan(noteStart, 20)).ToLowerInvariant();
var matchOffset = offset - carryCount + i;
var noteStart = matchOffset + buildIdPattern.Length + 16;
if (noteStart + 20 <= stream.Length)
{
stream.Position = noteStart;
Span<byte> buildId = stackalloc byte[20];
var buildIdRead = stream.ReadAtLeast(buildId, buildId.Length, throwOnEndOfStream: false);
if (buildIdRead == 20)
{
return Convert.ToHexString(buildId).ToLowerInvariant();
}
}
return null;
}
}
carryCount = Math.Min(carry.Length, combined.Length);
if (carryCount > 0)
{
Buffer.BlockCopy(combined, combined.Length - carryCount, carry, 0, carryCount);
}
offset += read;
}
return null;
@@ -119,11 +167,12 @@ public sealed class ElfFeatureExtractor : IBinaryFeatureExtractor
private static bool HasSymbolTable(Stream stream)
{
StreamGuard.EnsureSeekable(stream, "ELF symbol table scan");
// Simplified: check for .symtab section
stream.Position = 0;
var buffer = new byte[Math.Min(8192, stream.Length)];
stream.Read(buffer);
return Encoding.ASCII.GetString(buffer).Contains(".symtab");
var read = stream.Read(buffer, 0, buffer.Length);
return Encoding.ASCII.GetString(buffer, 0, read).Contains(".symtab");
}
private static string MapArchitecture(ushort eMachine) => eMachine switch

View File

@@ -200,6 +200,9 @@ public sealed record MatchEvidence
/// <summary>Package PURL from the delta signature.</summary>
public string? SignaturePackagePurl { get; init; }
/// <summary>Fingerprint algorithm used for matching when available.</summary>
public string? FingerprintAlgorithm { get; init; }
}
/// <summary>

View File

@@ -6,6 +6,8 @@
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.BinaryIndex.Core.Models;
namespace StellaOps.BinaryIndex.Core.Services;
@@ -27,9 +29,22 @@ public sealed class MachoFeatureExtractor : IBinaryFeatureExtractor
// Load command types
private const uint LC_UUID = 0x1B; // UUID load command
private const uint LC_ID_DYLIB = 0x0D; // Dylib identification
private readonly TimeProvider _timeProvider;
private readonly ILogger<MachoFeatureExtractor> _logger;
public MachoFeatureExtractor(
TimeProvider? timeProvider = null,
ILogger<MachoFeatureExtractor>? logger = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? NullLogger<MachoFeatureExtractor>.Instance;
}
public bool CanExtract(Stream stream)
{
if (stream is null || !stream.CanSeek || !stream.CanRead)
return false;
if (stream.Length < 4)
return false;
@@ -38,7 +53,7 @@ public sealed class MachoFeatureExtractor : IBinaryFeatureExtractor
{
Span<byte> magic = stackalloc byte[4];
stream.Position = 0;
var read = stream.Read(magic);
var read = stream.ReadAtLeast(magic, magic.Length, throwOnEndOfStream: false);
if (read < 4)
return false;
@@ -53,6 +68,7 @@ public sealed class MachoFeatureExtractor : IBinaryFeatureExtractor
public async Task<BinaryIdentity> ExtractIdentityAsync(Stream stream, CancellationToken ct = default)
{
StreamGuard.EnsureSeekable(stream, "Mach-O identity extraction");
var metadata = await ExtractMetadataAsync(stream, ct);
// Compute full file SHA-256
@@ -64,6 +80,7 @@ public sealed class MachoFeatureExtractor : IBinaryFeatureExtractor
? $"macho-uuid:{metadata.BuildId}:{fileSha256}"
: fileSha256;
var now = _timeProvider.GetUtcNow();
return new BinaryIdentity
{
BinaryKey = binaryKey,
@@ -73,16 +90,19 @@ public sealed class MachoFeatureExtractor : IBinaryFeatureExtractor
Format = metadata.Format,
Architecture = metadata.Architecture,
Type = metadata.Type,
IsStripped = metadata.IsStripped
IsStripped = metadata.IsStripped,
CreatedAt = now,
UpdatedAt = now
};
}
public Task<BinaryMetadata> ExtractMetadataAsync(Stream stream, CancellationToken ct = default)
{
StreamGuard.EnsureSeekable(stream, "Mach-O metadata extraction");
stream.Position = 0;
Span<byte> header = stackalloc byte[32];
var read = stream.Read(header);
var read = stream.ReadAtLeast(header, header.Length, throwOnEndOfStream: false);
if (read < 4)
throw new InvalidDataException("Stream too short for Mach-O header");
@@ -97,7 +117,15 @@ public sealed class MachoFeatureExtractor : IBinaryFeatureExtractor
var needsSwap = magicValue is MH_CIGAM or MH_CIGAM_64;
var is64Bit = magicValue is MH_MAGIC_64 or MH_CIGAM_64;
return Task.FromResult(ParseMachHeader(stream, header, is64Bit, needsSwap));
try
{
return Task.FromResult(ParseMachHeader(stream, header, is64Bit, needsSwap));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse Mach-O header.");
throw;
}
}
private static BinaryMetadata ParseMachHeader(Stream stream, ReadOnlySpan<byte> header, bool is64Bit, bool needsSwap)
@@ -127,7 +155,11 @@ public sealed class MachoFeatureExtractor : IBinaryFeatureExtractor
stream.Position = headerSize;
var cmdBuffer = new byte[sizeOfCmds];
stream.Read(cmdBuffer);
var cmdRead = stream.Read(cmdBuffer, 0, cmdBuffer.Length);
if (cmdRead < cmdBuffer.Length)
{
throw new InvalidDataException("Stream too short for Mach-O load commands");
}
var offset = 0;
for (var i = 0; i < ncmds && offset < cmdBuffer.Length - 8; i++)
@@ -170,7 +202,9 @@ public sealed class MachoFeatureExtractor : IBinaryFeatureExtractor
// 4-8: nfat_arch
stream.Position = 4;
Span<byte> nArchBytes = stackalloc byte[4];
stream.Read(nArchBytes);
var nArchRead = stream.ReadAtLeast(nArchBytes, nArchBytes.Length, throwOnEndOfStream: false);
if (nArchRead < nArchBytes.Length)
throw new InvalidDataException("Stream too short for Mach-O fat header");
var nArch = ReadUInt32(nArchBytes, needsSwap);
if (nArch == 0)
@@ -179,7 +213,9 @@ public sealed class MachoFeatureExtractor : IBinaryFeatureExtractor
// Read first fat_arch entry to get offset to first slice
// fat_arch: cputype(4), cpusubtype(4), offset(4), size(4), align(4)
Span<byte> fatArch = stackalloc byte[20];
stream.Read(fatArch);
var fatArchRead = stream.ReadAtLeast(fatArch, fatArch.Length, throwOnEndOfStream: false);
if (fatArchRead < fatArch.Length)
throw new InvalidDataException("Stream too short for Mach-O fat arch");
var sliceOffset = ReadUInt32(fatArch[8..12], needsSwap);
var sliceSize = ReadUInt32(fatArch[12..16], needsSwap);
@@ -187,7 +223,9 @@ public sealed class MachoFeatureExtractor : IBinaryFeatureExtractor
// Read the Mach-O header from the first slice
stream.Position = sliceOffset;
Span<byte> sliceHeader = stackalloc byte[32];
stream.Read(sliceHeader);
var sliceHeaderRead = stream.ReadAtLeast(sliceHeader, sliceHeader.Length, throwOnEndOfStream: false);
if (sliceHeaderRead < sliceHeader.Length)
throw new InvalidDataException("Stream too short for Mach-O slice header");
var sliceMagic = BitConverter.ToUInt32(sliceHeader[..4]);
var sliceNeedsSwap = sliceMagic is MH_CIGAM or MH_CIGAM_64;

View File

@@ -6,7 +6,8 @@
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.BinaryIndex.Core.Models;
namespace StellaOps.BinaryIndex.Core.Services;
@@ -22,9 +23,22 @@ public sealed class PeFeatureExtractor : IBinaryFeatureExtractor
// PE signature: PE\0\0
private static readonly byte[] PeSignature = [0x50, 0x45, 0x00, 0x00];
private readonly TimeProvider _timeProvider;
private readonly ILogger<PeFeatureExtractor> _logger;
public PeFeatureExtractor(
TimeProvider? timeProvider = null,
ILogger<PeFeatureExtractor>? logger = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? NullLogger<PeFeatureExtractor>.Instance;
}
public bool CanExtract(Stream stream)
{
if (stream is null || !stream.CanSeek || !stream.CanRead)
return false;
if (stream.Length < 64) // Minimum DOS header size
return false;
@@ -33,7 +47,7 @@ public sealed class PeFeatureExtractor : IBinaryFeatureExtractor
{
Span<byte> magic = stackalloc byte[2];
stream.Position = 0;
var read = stream.Read(magic);
var read = stream.ReadAtLeast(magic, magic.Length, throwOnEndOfStream: false);
return read == 2 && magic.SequenceEqual(DosMagic);
}
finally
@@ -44,6 +58,7 @@ public sealed class PeFeatureExtractor : IBinaryFeatureExtractor
public async Task<BinaryIdentity> ExtractIdentityAsync(Stream stream, CancellationToken ct = default)
{
StreamGuard.EnsureSeekable(stream, "PE identity extraction");
var metadata = await ExtractMetadataAsync(stream, ct);
// Compute full file SHA-256
@@ -55,6 +70,7 @@ public sealed class PeFeatureExtractor : IBinaryFeatureExtractor
? $"pe-cv:{metadata.BuildId}:{fileSha256}"
: fileSha256;
var now = _timeProvider.GetUtcNow();
return new BinaryIdentity
{
BinaryKey = binaryKey,
@@ -64,17 +80,20 @@ public sealed class PeFeatureExtractor : IBinaryFeatureExtractor
Format = metadata.Format,
Architecture = metadata.Architecture,
Type = metadata.Type,
IsStripped = metadata.IsStripped
IsStripped = metadata.IsStripped,
CreatedAt = now,
UpdatedAt = now
};
}
public Task<BinaryMetadata> ExtractMetadataAsync(Stream stream, CancellationToken ct = default)
{
StreamGuard.EnsureSeekable(stream, "PE metadata extraction");
stream.Position = 0;
// Read DOS header to get PE header offset
Span<byte> dosHeader = stackalloc byte[64];
var read = stream.Read(dosHeader);
var read = stream.ReadAtLeast(dosHeader, dosHeader.Length, throwOnEndOfStream: false);
if (read < 64)
throw new InvalidDataException("Stream too short for DOS header");
@@ -86,7 +105,7 @@ public sealed class PeFeatureExtractor : IBinaryFeatureExtractor
// Read PE signature and COFF header
stream.Position = peOffset;
Span<byte> peHeader = stackalloc byte[24];
read = stream.Read(peHeader);
read = stream.ReadAtLeast(peHeader, peHeader.Length, throwOnEndOfStream: false);
if (read < 24)
throw new InvalidDataException("Stream too short for PE header");
@@ -102,7 +121,9 @@ public sealed class PeFeatureExtractor : IBinaryFeatureExtractor
// Read optional header to determine PE32 vs PE32+
Span<byte> optionalMagic = stackalloc byte[2];
stream.Read(optionalMagic);
var optionalRead = stream.ReadAtLeast(optionalMagic, optionalMagic.Length, throwOnEndOfStream: false);
if (optionalRead < optionalMagic.Length)
throw new InvalidDataException("Stream too short for optional header magic");
var isPe32Plus = BitConverter.ToUInt16(optionalMagic) == 0x20B;
var architecture = MapMachine(machine);
@@ -125,14 +146,16 @@ public sealed class PeFeatureExtractor : IBinaryFeatureExtractor
/// <summary>
/// Extract CodeView GUID from PE debug directory.
/// </summary>
private static string? ExtractCodeViewGuid(Stream stream, int peOffset, bool isPe32Plus)
private string? ExtractCodeViewGuid(Stream stream, int peOffset, bool isPe32Plus)
{
try
{
// Calculate optional header size offset
stream.Position = peOffset + 20; // After COFF header
Span<byte> sizeOfOptionalHeader = stackalloc byte[2];
stream.Read(sizeOfOptionalHeader);
var optionalHeaderRead = stream.ReadAtLeast(sizeOfOptionalHeader, sizeOfOptionalHeader.Length, throwOnEndOfStream: false);
if (optionalHeaderRead < sizeOfOptionalHeader.Length)
return null;
var optionalHeaderSize = BitConverter.ToUInt16(sizeOfOptionalHeader);
if (optionalHeaderSize < 128)
@@ -148,7 +171,9 @@ public sealed class PeFeatureExtractor : IBinaryFeatureExtractor
stream.Position = debugDirectoryRva;
Span<byte> debugDir = stackalloc byte[8];
stream.Read(debugDir);
var debugDirRead = stream.ReadAtLeast(debugDir, debugDir.Length, throwOnEndOfStream: false);
if (debugDirRead < debugDir.Length)
return null;
var debugRva = BitConverter.ToUInt32(debugDir[..4]);
var debugSize = BitConverter.ToUInt32(debugDir[4..8]);
@@ -163,7 +188,7 @@ public sealed class PeFeatureExtractor : IBinaryFeatureExtractor
stream.Position = debugRva;
Span<byte> debugEntry = stackalloc byte[28];
var read = stream.Read(debugEntry);
var read = stream.ReadAtLeast(debugEntry, debugEntry.Length, throwOnEndOfStream: false);
if (read < 28)
return null;
@@ -178,7 +203,7 @@ public sealed class PeFeatureExtractor : IBinaryFeatureExtractor
// Read CodeView header
stream.Position = pointerToRawData;
Span<byte> cvHeader = stackalloc byte[24];
read = stream.Read(cvHeader);
read = stream.ReadAtLeast(cvHeader, cvHeader.Length, throwOnEndOfStream: false);
if (read < 24)
return null;
@@ -196,8 +221,9 @@ public sealed class PeFeatureExtractor : IBinaryFeatureExtractor
return null;
}
catch
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse CodeView GUID from PE image.");
return null;
}
}
@@ -214,7 +240,9 @@ public sealed class PeFeatureExtractor : IBinaryFeatureExtractor
stream.Position = debugDirectoryRva;
Span<byte> debugDir = stackalloc byte[8];
stream.Read(debugDir);
var debugDirRead = stream.ReadAtLeast(debugDir, debugDir.Length, throwOnEndOfStream: false);
if (debugDirRead < debugDir.Length)
return false;
var debugRva = BitConverter.ToUInt32(debugDir[..4]);
return debugRva != 0;

View File

@@ -0,0 +1,18 @@
namespace StellaOps.BinaryIndex.Core.Services;
internal static class StreamGuard
{
public static void EnsureSeekable(Stream stream, string operation)
{
if (stream is null)
{
throw new ArgumentNullException(nameof(stream));
}
if (!stream.CanSeek || !stream.CanRead)
{
throw new InvalidOperationException(
$"Stream must be seekable and readable for {operation}.");
}
}
}

View File

@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>

View File

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

View File

@@ -1,13 +1,13 @@
// -----------------------------------------------------------------------------
// AlpineCorpusConnector.cs
// Sprint: SPRINT_20251226_012_BINIDX_backport_handling
// Task: BACKPORT-16 Create AlpineCorpusConnector for Alpine APK
// Task: BACKPORT-16 - Create AlpineCorpusConnector for Alpine APK
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging;
using StellaOps.BinaryIndex.Core.Models;
using StellaOps.BinaryIndex.Core.Services;
using StellaOps.BinaryIndex.Corpus;
namespace StellaOps.BinaryIndex.Corpus.Alpine;
@@ -20,27 +20,28 @@ public sealed class AlpineCorpusConnector : IBinaryCorpusConnector
{
private readonly IAlpinePackageSource _packageSource;
private readonly AlpinePackageExtractor _extractor;
private readonly IBinaryFeatureExtractor _featureExtractor;
private readonly ICorpusSnapshotRepository _snapshotRepo;
private readonly ILogger<AlpineCorpusConnector> _logger;
private const string DefaultMirror = "https://dl-cdn.alpinelinux.org/alpine";
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
public string ConnectorId => "alpine";
public string[] SupportedDistros => ["alpine"];
public ImmutableArray<string> SupportedDistros { get; } = ImmutableArray.Create("alpine");
public AlpineCorpusConnector(
IAlpinePackageSource packageSource,
AlpinePackageExtractor extractor,
IBinaryFeatureExtractor featureExtractor,
ICorpusSnapshotRepository snapshotRepo,
ILogger<AlpineCorpusConnector> logger)
ILogger<AlpineCorpusConnector> logger,
TimeProvider? timeProvider = null,
IGuidProvider? guidProvider = null)
{
_packageSource = packageSource;
_extractor = extractor;
_featureExtractor = featureExtractor;
_snapshotRepo = snapshotRepo;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
_guidProvider = guidProvider ?? new SystemGuidProvider();
}
public async Task<CorpusSnapshot> FetchSnapshotAsync(CorpusQuery query, CancellationToken ct = default)
@@ -71,13 +72,15 @@ public sealed class AlpineCorpusConnector : IBinaryCorpusConnector
var packageList = packages.ToList();
var metadataDigest = ComputeMetadataDigest(packageList);
var snapshot = new CorpusSnapshot(
Id: Guid.NewGuid(),
Distro: "alpine",
Release: query.Release,
Architecture: query.Architecture,
MetadataDigest: metadataDigest,
CapturedAt: DateTimeOffset.UtcNow);
var snapshot = new CorpusSnapshot
{
Id = _guidProvider.NewGuid(),
Distro = query.Distro,
Release = query.Release,
Architecture = query.Architecture,
MetadataDigest = metadataDigest,
CapturedAt = _timeProvider.GetUtcNow()
};
await _snapshotRepo.CreateAsync(snapshot, ct);
@@ -101,14 +104,16 @@ public sealed class AlpineCorpusConnector : IBinaryCorpusConnector
foreach (var pkg in packages)
{
yield return new PackageInfo(
Name: pkg.PackageName,
Version: pkg.Version,
SourcePackage: pkg.Origin ?? pkg.PackageName,
Architecture: pkg.Architecture,
Filename: pkg.Filename,
Size: pkg.Size,
Sha256: pkg.Checksum);
yield return new PackageInfo
{
Name = pkg.PackageName,
Version = pkg.Version,
SourcePackage = pkg.Origin ?? pkg.PackageName,
Architecture = pkg.Architecture,
Filename = pkg.Filename,
Size = pkg.Size,
Sha256 = pkg.Checksum
};
}
}

View File

@@ -1,13 +1,12 @@
// -----------------------------------------------------------------------------
// AlpinePackageExtractor.cs
// Sprint: SPRINT_20251226_012_BINIDX_backport_handling
// Task: BACKPORT-16 Create AlpineCorpusConnector for Alpine APK
// Task: BACKPORT-16 - Create AlpineCorpusConnector for Alpine APK
// -----------------------------------------------------------------------------
using System.IO.Compression;
using Microsoft.Extensions.Logging;
using SharpCompress.Archives;
using SharpCompress.Archives.Tar;
using SharpCompress.Compressors.Deflate;
using StellaOps.BinaryIndex.Core.Models;
using StellaOps.BinaryIndex.Core.Services;
using StellaOps.BinaryIndex.Corpus;
@@ -24,6 +23,8 @@ public sealed class AlpinePackageExtractor
// ELF magic bytes
private static readonly byte[] ElfMagic = [0x7F, 0x45, 0x4C, 0x46];
private const long MaxEntrySizeBytes = 64L * 1024 * 1024;
private const long MaxSegmentSizeBytes = 256L * 1024 * 1024;
public AlpinePackageExtractor(
IBinaryFeatureExtractor featureExtractor,
@@ -46,45 +47,71 @@ public sealed class AlpinePackageExtractor
CancellationToken ct = default)
{
var results = new List<ExtractedBinaryInfo>();
var seekableStream = await EnsureSeekableStreamAsync(apkStream, ct);
var disposeSeekable = !ReferenceEquals(seekableStream, apkStream);
// APK is gzipped tar: signature.tar.gz + control.tar.gz + data.tar.gz
// We need to extract data.tar.gz which contains the actual files
try
{
var dataTar = await ExtractDataTarAsync(apkStream, ct);
if (dataTar == null)
{
_logger.LogWarning("Could not find data.tar in {Package}", pkg.Name);
return results;
}
using var archive = TarArchive.Open(dataTar);
foreach (var entry in archive.Entries.Where(e => !e.IsDirectory))
while (seekableStream.Position < seekableStream.Length)
{
ct.ThrowIfCancellationRequested();
var startPosition = seekableStream.Position;
// Check if this is an ELF binary
using var entryStream = entry.OpenEntryStream();
using var ms = new MemoryStream();
await entryStream.CopyToAsync(ms, ct);
ms.Position = 0;
if (!IsElfBinary(ms))
using var gzip = new GZipStream(
seekableStream,
CompressionMode.Decompress,
leaveOpen: true);
await using var segmentStream = await ExtractSegmentAsync(gzip, ct);
if (segmentStream is null)
{
continue;
break;
}
ms.Position = 0;
using var archive = TarArchive.Open(segmentStream);
try
foreach (var entry in archive.Entries.Where(e => !e.IsDirectory))
{
var identity = await _featureExtractor.ExtractIdentityAsync(ms, ct);
results.Add(new ExtractedBinaryInfo(identity, entry.Key ?? ""));
ct.ThrowIfCancellationRequested();
if (entry.Size <= 0 || entry.Size > MaxEntrySizeBytes)
{
_logger.LogWarning(
"Skipping entry {Entry} in {Package} due to size {Size} bytes",
entry.Key,
pkg.Name,
entry.Size);
continue;
}
using var entryStream = entry.OpenEntryStream();
using var ms = new MemoryStream((int)entry.Size);
await entryStream.CopyToAsync(ms, ct);
ms.Position = 0;
if (!IsElfBinary(ms))
{
continue;
}
ms.Position = 0;
try
{
var identity = await _featureExtractor.ExtractIdentityAsync(ms, ct);
results.Add(new ExtractedBinaryInfo(identity, entry.Key ?? ""));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to extract identity from {File} in {Package}",
entry.Key, pkg.Name);
}
}
catch (Exception ex)
if (seekableStream.Position <= startPosition)
{
_logger.LogWarning(ex, "Failed to extract identity from {File} in {Package}",
entry.Key, pkg.Name);
break;
}
}
}
@@ -92,24 +119,93 @@ public sealed class AlpinePackageExtractor
{
_logger.LogError(ex, "Failed to extract binaries from Alpine package {Package}", pkg.Name);
}
finally
{
if (disposeSeekable)
{
await seekableStream.DisposeAsync();
}
}
return results;
}
private static async Task<Stream?> ExtractDataTarAsync(Stream apkStream, CancellationToken ct)
private static async Task<Stream> EnsureSeekableStreamAsync(Stream apkStream, CancellationToken ct)
{
// APK packages contain multiple gzipped tar archives concatenated
// We need to skip to the data.tar.gz portion
// The structure is: signature.tar.gz + control.tar.gz + data.tar.gz
if (apkStream.CanSeek)
{
apkStream.Position = 0;
return apkStream;
}
using var gzip = new GZipStream(apkStream, SharpCompress.Compressors.CompressionMode.Decompress);
using var ms = new MemoryStream();
await gzip.CopyToAsync(ms, ct);
ms.Position = 0;
var tempPath = Path.GetTempFileName();
var tempStream = new FileStream(
tempPath,
FileMode.Create,
FileAccess.ReadWrite,
FileShare.None,
bufferSize: 81920,
FileOptions.DeleteOnClose);
// For simplicity, we'll just try to extract from the combined tar
// In a real implementation, we'd need to properly parse the multi-part structure
return ms;
await apkStream.CopyToAsync(tempStream, ct);
tempStream.Position = 0;
return tempStream;
}
private static async Task<Stream?> ExtractSegmentAsync(Stream gzipStream, CancellationToken ct)
{
var tempPath = Path.GetTempFileName();
var tempStream = new FileStream(
tempPath,
FileMode.Create,
FileAccess.ReadWrite,
FileShare.None,
bufferSize: 81920,
FileOptions.DeleteOnClose);
var totalCopied = await CopyToWithLimitAsync(
gzipStream,
tempStream,
MaxSegmentSizeBytes,
ct);
if (totalCopied == 0)
{
await tempStream.DisposeAsync();
return null;
}
tempStream.Position = 0;
return tempStream;
}
private static async Task<long> CopyToWithLimitAsync(
Stream source,
Stream destination,
long maxBytes,
CancellationToken ct)
{
var buffer = new byte[81920];
long total = 0;
while (true)
{
var read = await source.ReadAsync(buffer.AsMemory(0, buffer.Length), ct);
if (read == 0)
{
break;
}
total += read;
if (total > maxBytes)
{
throw new InvalidDataException("APK segment exceeds size limit.");
}
await destination.WriteAsync(buffer.AsMemory(0, read), ct);
}
return total;
}
private static bool IsElfBinary(Stream stream)

View File

@@ -1,9 +1,11 @@
// -----------------------------------------------------------------------------
// IAlpinePackageSource.cs
// Sprint: SPRINT_20251226_012_BINIDX_backport_handling
// Task: BACKPORT-16 Create AlpineCorpusConnector for Alpine APK
// Task: BACKPORT-16 - Create AlpineCorpusConnector for Alpine APK
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
namespace StellaOps.BinaryIndex.Corpus.Alpine;
/// <summary>
@@ -76,10 +78,10 @@ public sealed record AlpinePackageMetadata
public string? Maintainer { get; init; }
/// <summary>Dependencies (D:).</summary>
public string[]? Dependencies { get; init; }
public ImmutableArray<string> Dependencies { get; init; } = ImmutableArray<string>.Empty;
/// <summary>Provides (p:).</summary>
public string[]? Provides { get; init; }
public ImmutableArray<string> Provides { get; init; } = ImmutableArray<string>.Empty;
/// <summary>Build timestamp (t:).</summary>
public DateTimeOffset? BuildTime { get; init; }

Some files were not shown because too many files have changed in this diff Show More