This commit is contained in:
StellaOps Bot
2025-12-15 09:23:28 +02:00
parent 505fe7a885
commit 8137503221
26 changed files with 1459 additions and 193 deletions

View File

@@ -201,6 +201,12 @@ builder.Services.AddScannerStorage(storageOptions =>
}
});
builder.Services.AddSingleton<IPostConfigureOptions<ScannerStorageOptions>, ScannerStorageOptionsPostConfigurator>();
builder.Services.AddOptions<StellaOps.Scanner.ProofSpine.Options.ProofSpineDsseSigningOptions>()
.Bind(builder.Configuration.GetSection(StellaOps.Scanner.ProofSpine.Options.ProofSpineDsseSigningOptions.SectionName));
builder.Services.AddSingleton<StellaOps.Scanner.ProofSpine.ICryptoProfile, StellaOps.Scanner.ProofSpine.DefaultCryptoProfile>();
builder.Services.AddSingleton<StellaOps.Scanner.ProofSpine.IDsseSigningService, StellaOps.Scanner.ProofSpine.HmacDsseSigningService>();
builder.Services.AddTransient<StellaOps.Scanner.ProofSpine.ProofSpineBuilder>();
builder.Services.AddSingleton<StellaOps.Scanner.ProofSpine.ProofSpineVerifier>();
builder.Services.AddSingleton<RuntimeEventRateLimiter>();
builder.Services.AddSingleton<IDeltaScanRequestHandler, DeltaScanRequestHandler>();
builder.Services.AddSingleton<IRuntimeEventIngestionService, RuntimeEventIngestionService>();
@@ -429,6 +435,7 @@ if (app.Environment.IsEnvironment("Testing"))
}
apiGroup.MapScanEndpoints(resolvedOptions.Api.ScansSegment);
apiGroup.MapProofSpineEndpoints(resolvedOptions.Api.SpinesSegment, resolvedOptions.Api.ScansSegment);
apiGroup.MapReplayEndpoints();
if (resolvedOptions.Features.EnablePolicyPreview)

View File

@@ -0,0 +1,88 @@
using System.Collections.Generic;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.Scanner.ProofSpine;
using StellaOps.Scanner.ProofSpine.Options;
using StellaOps.Scanner.Storage;
using StellaOps.Scanner.Storage.Postgres;
using StellaOps.Scanner.Storage.Repositories;
using Xunit;
namespace StellaOps.Scanner.ProofSpine.Tests;
[Collection("scanner-proofspine-postgres")]
public sealed class PostgresProofSpineRepositoryTests
{
private readonly ScannerProofSpinePostgresFixture _fixture;
public PostgresProofSpineRepositoryTests(ScannerProofSpinePostgresFixture fixture)
=> _fixture = fixture;
[Fact]
public async Task SaveAsync_ThenGetByIdAsync_RoundTripsSpine()
{
await _fixture.TruncateAllTablesAsync();
var spine = await BuildSampleSpineAsync(scanRunId: "scan-001");
var options = new ScannerStorageOptions
{
Postgres = new PostgresOptions
{
ConnectionString = _fixture.ConnectionString,
SchemaName = _fixture.SchemaName
}
};
await using var dataSource = new ScannerDataSource(Options.Create(options), NullLogger<ScannerDataSource>.Instance);
var repository = new PostgresProofSpineRepository(
dataSource,
NullLogger<PostgresProofSpineRepository>.Instance,
TimeProvider.System);
await repository.SaveAsync(spine);
var fetched = await repository.GetByIdAsync(spine.SpineId);
Assert.NotNull(fetched);
var segments = await repository.GetSegmentsAsync(spine.SpineId);
Assert.Equal(spine.Segments.Count, segments.Count);
var summaries = await repository.GetSummariesByScanRunAsync("scan-001");
Assert.Single(summaries);
Assert.Equal(spine.SpineId, summaries[0].SpineId);
Assert.Equal(spine.Segments.Count, summaries[0].SegmentCount);
}
private static async Task<ProofSpine> BuildSampleSpineAsync(string scanRunId)
{
var options = Options.Create(new ProofSpineDsseSigningOptions
{
Mode = "deterministic",
KeyId = "proofspine-test",
AllowDeterministicFallback = true
});
var cryptoHash = DefaultCryptoHash.CreateForTests();
var cryptoHmac = DefaultCryptoHmac.CreateForTests();
var signer = new HmacDsseSigningService(options, cryptoHmac, cryptoHash);
var profile = new DefaultCryptoProfile(options);
return await new ProofSpineBuilder(signer, profile, cryptoHash, TimeProvider.System)
.ForArtifact("sha256:feedface")
.ForVulnerability("CVE-2025-0001")
.WithPolicyProfile("default")
.WithScanRun(scanRunId)
.AddSbomSlice("sha256:sbom", new[] { "pkg:a" }, toolId: "sbom", toolVersion: "1.0.0")
.AddPolicyEval(
policyDigest: "sha256:policy",
factors: new Dictionary<string, string> { ["policy"] = "default" },
verdict: "not_affected",
verdictReason: "component_not_present",
toolId: "policy",
toolVersion: "1.0.0")
.BuildAsync();
}
}

View File

@@ -0,0 +1,122 @@
using System.Collections.Generic;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Scanner.ProofSpine;
using StellaOps.Scanner.ProofSpine.Options;
using Xunit;
namespace StellaOps.Scanner.ProofSpine.Tests;
public sealed class ProofSpineBuilderTests
{
[Fact]
public async Task BuildAsync_SameInputs_ProducesSameIds()
{
var options = Options.Create(new ProofSpineDsseSigningOptions
{
Mode = "deterministic",
KeyId = "proofspine-test",
AllowDeterministicFallback = true
});
var cryptoHash = DefaultCryptoHash.CreateForTests();
var cryptoHmac = DefaultCryptoHmac.CreateForTests();
var signer = new HmacDsseSigningService(options, cryptoHmac, cryptoHash);
var profile = new DefaultCryptoProfile(options);
var clock = new FixedTimeProvider(DateTimeOffset.Parse("2025-01-01T00:00:00Z"));
var spine1 = await new ProofSpineBuilder(signer, profile, cryptoHash, clock)
.ForArtifact("sha256:feedface")
.ForVulnerability("CVE-2025-0001")
.WithPolicyProfile("default")
.WithScanRun("scan-001")
.AddSbomSlice("sha256:sbom", new[] { "pkg:b", "pkg:a" }, toolId: "sbom", toolVersion: "1.0.0")
.AddPolicyEval(
policyDigest: "sha256:policy",
factors: new Dictionary<string, string> { ["policy"] = "default" },
verdict: "not_affected",
verdictReason: "component_not_present",
toolId: "policy",
toolVersion: "1.0.0")
.BuildAsync();
var spine2 = await new ProofSpineBuilder(signer, profile, cryptoHash, clock)
.ForArtifact("sha256:feedface")
.ForVulnerability("CVE-2025-0001")
.WithPolicyProfile("default")
.WithScanRun("scan-001")
.AddSbomSlice("sha256:sbom", new[] { "pkg:b", "pkg:a" }, toolId: "sbom", toolVersion: "1.0.0")
.AddPolicyEval(
policyDigest: "sha256:policy",
factors: new Dictionary<string, string> { ["policy"] = "default" },
verdict: "not_affected",
verdictReason: "component_not_present",
toolId: "policy",
toolVersion: "1.0.0")
.BuildAsync();
Assert.Equal(spine1.SpineId, spine2.SpineId);
Assert.Equal(spine1.RootHash, spine2.RootHash);
Assert.Equal(spine1.Segments.Count, spine2.Segments.Count);
Assert.Equal(spine1.Segments[0].SegmentId, spine2.Segments[0].SegmentId);
Assert.Equal(spine1.Segments[1].SegmentId, spine2.Segments[1].SegmentId);
}
[Fact]
public async Task VerifyAsync_DetectsTampering()
{
var options = Options.Create(new ProofSpineDsseSigningOptions
{
Mode = "deterministic",
KeyId = "proofspine-test",
AllowDeterministicFallback = true
});
var cryptoHash = DefaultCryptoHash.CreateForTests();
var cryptoHmac = DefaultCryptoHmac.CreateForTests();
var signer = new HmacDsseSigningService(options, cryptoHmac, cryptoHash);
var profile = new DefaultCryptoProfile(options);
var clock = new FixedTimeProvider(DateTimeOffset.Parse("2025-01-01T00:00:00Z"));
var spine = await new ProofSpineBuilder(signer, profile, cryptoHash, clock)
.ForArtifact("sha256:feedface")
.ForVulnerability("CVE-2025-0002")
.WithPolicyProfile("default")
.WithScanRun("scan-002")
.AddSbomSlice("sha256:sbom", new[] { "pkg:a" }, toolId: "sbom", toolVersion: "1.0.0")
.AddPolicyEval(
policyDigest: "sha256:policy",
factors: new Dictionary<string, string> { ["policy"] = "default" },
verdict: "affected",
verdictReason: "reachable",
toolId: "policy",
toolVersion: "1.0.0")
.BuildAsync();
var tampered = spine with
{
Segments = new[]
{
spine.Segments[0] with { ResultHash = spine.Segments[0].ResultHash + "00" },
spine.Segments[1]
}
};
var verifier = new ProofSpineVerifier(signer, cryptoHash);
var verification = await verifier.VerifyAsync(tampered);
Assert.False(verification.IsValid);
Assert.Contains("root_hash_mismatch", verification.Errors);
Assert.Equal(ProofSegmentStatus.Invalid, verification.Segments[0].Status);
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _fixed;
public FixedTimeProvider(DateTimeOffset fixedInstant)
=> _fixed = fixedInstant;
public override DateTimeOffset GetUtcNow() => _fixed;
}
}

View File

@@ -0,0 +1,19 @@
using System.Reflection;
using StellaOps.Infrastructure.Postgres.Testing;
using StellaOps.Scanner.Storage;
using Xunit;
namespace StellaOps.Scanner.ProofSpine.Tests;
public sealed class ScannerProofSpinePostgresFixture : PostgresIntegrationFixture, ICollectionFixture<ScannerProofSpinePostgresFixture>
{
protected override Assembly? GetMigrationAssembly() => typeof(ScannerStorageOptions).Assembly;
protected override string GetModuleName() => "Scanner.ProofSpine.Tests";
}
[CollectionDefinition("scanner-proofspine-postgres")]
public sealed class ScannerProofSpinePostgresCollection : ICollectionFixture<ScannerProofSpinePostgresFixture>
{
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Scanner.ProofSpine\StellaOps.Scanner.ProofSpine.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Scanner.Storage\StellaOps.Scanner.Storage.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,128 @@
using System.Collections.Generic;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Scanner.ProofSpine;
using Xunit;
namespace StellaOps.Scanner.WebService.Tests;
public sealed class ProofSpineEndpointsTests
{
[Fact]
public async Task GetSpine_ReturnsSpine_WithVerification()
{
await using var factory = new ScannerApplicationFactory();
using var scope = factory.Services.CreateScope();
var builder = scope.ServiceProvider.GetRequiredService<ProofSpineBuilder>();
var repository = scope.ServiceProvider.GetRequiredService<IProofSpineRepository>();
var spine = await builder
.ForArtifact("sha256:feedface")
.ForVulnerability("CVE-2025-0001")
.WithPolicyProfile("default")
.WithScanRun("scan-001")
.AddSbomSlice("sha256:sbom", new[] { "pkg:a", "pkg:b" }, toolId: "sbom", toolVersion: "1.0.0")
.AddPolicyEval(
policyDigest: "sha256:policy",
factors: new Dictionary<string, string> { ["policy"] = "default" },
verdict: "not_affected",
verdictReason: "component_not_present",
toolId: "policy",
toolVersion: "1.0.0")
.BuildAsync();
await repository.SaveAsync(spine);
var client = factory.CreateClient();
var response = await client.GetAsync($"/api/v1/spines/{spine.SpineId}");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(spine.SpineId, body.GetProperty("spineId").GetString());
var segments = body.GetProperty("segments");
Assert.True(segments.GetArrayLength() > 0);
Assert.True(body.TryGetProperty("verification", out _));
}
[Fact]
public async Task ListSpinesByScan_ReturnsSummaries_WithSegmentCount()
{
await using var factory = new ScannerApplicationFactory();
using var scope = factory.Services.CreateScope();
var builder = scope.ServiceProvider.GetRequiredService<ProofSpineBuilder>();
var repository = scope.ServiceProvider.GetRequiredService<IProofSpineRepository>();
var spine = await builder
.ForArtifact("sha256:feedface")
.ForVulnerability("CVE-2025-0002")
.WithPolicyProfile("default")
.WithScanRun("scan-002")
.AddSbomSlice("sha256:sbom", new[] { "pkg:a" }, toolId: "sbom", toolVersion: "1.0.0")
.AddPolicyEval(
policyDigest: "sha256:policy",
factors: new Dictionary<string, string> { ["policy"] = "default" },
verdict: "affected",
verdictReason: "reachable",
toolId: "policy",
toolVersion: "1.0.0")
.BuildAsync();
await repository.SaveAsync(spine);
var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/scans/scan-002/spines");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
var items = body.GetProperty("items");
Assert.Equal(1, items.GetArrayLength());
Assert.Equal(spine.SpineId, items[0].GetProperty("spineId").GetString());
Assert.True(items[0].GetProperty("segmentCount").GetInt32() > 0);
}
[Fact]
public async Task GetSpine_ReturnsInvalidStatus_WhenSegmentTampered()
{
await using var factory = new ScannerApplicationFactory();
using var scope = factory.Services.CreateScope();
var builder = scope.ServiceProvider.GetRequiredService<ProofSpineBuilder>();
var repository = scope.ServiceProvider.GetRequiredService<IProofSpineRepository>();
var spine = await builder
.ForArtifact("sha256:feedface")
.ForVulnerability("CVE-2025-0003")
.WithPolicyProfile("default")
.WithScanRun("scan-003")
.AddSbomSlice("sha256:sbom", new[] { "pkg:a" }, toolId: "sbom", toolVersion: "1.0.0")
.AddPolicyEval(
policyDigest: "sha256:policy",
factors: new Dictionary<string, string> { ["policy"] = "default" },
verdict: "affected",
verdictReason: "reachable",
toolId: "policy",
toolVersion: "1.0.0")
.BuildAsync();
var tamperedSegment = spine.Segments[0] with { ResultHash = spine.Segments[0].ResultHash + "00" };
var tampered = spine with { Segments = new[] { tamperedSegment, spine.Segments[1] } };
await repository.SaveAsync(tampered);
var client = factory.CreateClient();
var response = await client.GetAsync($"/api/v1/spines/{spine.SpineId}");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
var segments = body.GetProperty("segments");
Assert.Equal("invalid", segments[0].GetProperty("status").GetString());
}
}