up
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user