chore(sprints): archive 20260226 advisories and expand deterministic tests

This commit is contained in:
master
2026-03-04 03:09:23 +02:00
parent 4fe8eb56ae
commit aaad8104cb
35 changed files with 4686 additions and 1 deletions

View File

@@ -8,6 +8,9 @@ using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Crypto.Signers;
using Org.BouncyCastle.X509;
using Xunit;
namespace StellaOps.Attestation.Tests;
@@ -44,6 +47,33 @@ public class DsseVerifierTests
result.Issues.Should().BeEmpty();
}
[Fact]
public async Task VerifyAsync_WithValidRsaSignature_ReturnsSuccess()
{
using var rsa = RSA.Create(2048);
var (envelope, publicKeyPem) = CreateSignedEnvelope(rsa);
var result = await _verifier.VerifyAsync(envelope, publicKeyPem, TestContext.Current.CancellationToken);
result.IsValid.Should().BeTrue();
result.ValidSignatureCount.Should().Be(1);
result.Issues.Should().BeEmpty();
}
[Fact]
public async Task VerifyAsync_WithValidEd25519Signature_ReturnsSuccess()
{
var seed = Enumerable.Range(0, 32).Select(static i => (byte)(i + 1)).ToArray();
var privateKey = new Ed25519PrivateKeyParameters(seed, 0);
var (envelope, publicKeyPem) = CreateSignedEnvelope(privateKey);
var result = await _verifier.VerifyAsync(envelope, publicKeyPem, TestContext.Current.CancellationToken);
result.IsValid.Should().BeTrue();
result.ValidSignatureCount.Should().Be(1);
result.Issues.Should().BeEmpty();
}
[Fact]
public async Task VerifyAsync_WithInvalidSignature_ReturnsFail()
{
@@ -64,6 +94,39 @@ public class DsseVerifierTests
result.Issues.Should().NotBeEmpty();
}
[Fact]
public async Task VerifyAsync_WithInvalidRsaSignature_ReturnsDeterministicReason()
{
using var signingKey = RSA.Create(2048);
var (envelope, _) = CreateSignedEnvelope(signingKey);
using var verifierKey = RSA.Create(2048);
var verifierPem = ExportPublicKeyPem(verifierKey);
var result = await _verifier.VerifyAsync(envelope, verifierPem, TestContext.Current.CancellationToken);
result.IsValid.Should().BeFalse();
result.Issues.Should().Contain(issue => issue.Contains("signature_mismatch", StringComparison.Ordinal));
}
[Fact]
public async Task VerifyAsync_WithInvalidEd25519Signature_ReturnsDeterministicReason()
{
var signingSeed = Enumerable.Range(0, 32).Select(static i => (byte)(i + 1)).ToArray();
var verifyingSeed = Enumerable.Range(0, 32).Select(static i => (byte)(i + 101)).ToArray();
var signingKey = new Ed25519PrivateKeyParameters(signingSeed, 0);
var verifyingKey = new Ed25519PrivateKeyParameters(verifyingSeed, 0);
var (envelope, _) = CreateSignedEnvelope(signingKey);
var verifyingPem = ExportPublicKeyPem(verifyingKey.GeneratePublicKey());
var result = await _verifier.VerifyAsync(envelope, verifyingPem, TestContext.Current.CancellationToken);
result.IsValid.Should().BeFalse();
result.Issues.Should().Contain(issue => issue.Contains("signature_mismatch", StringComparison.Ordinal));
}
[Fact]
public async Task VerifyAsync_WithMalformedJson_ReturnsParseError()
{
@@ -277,9 +340,77 @@ public class DsseVerifierTests
return (envelope, publicKeyPem);
}
private static (string EnvelopeJson, string PublicKeyPem) CreateSignedEnvelope(RSA signingKey)
{
var payloadType = "https://in-toto.io/Statement/v1";
var payloadContent = "{\"_type\":\"https://in-toto.io/Statement/v1\",\"subject\":[]}";
var payloadBytes = Encoding.UTF8.GetBytes(payloadContent);
var payloadBase64 = Convert.ToBase64String(payloadBytes);
var pae = DsseHelper.PreAuthenticationEncoding(payloadType, payloadBytes);
var signatureBytes = signingKey.SignData(pae, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
var signatureBase64 = Convert.ToBase64String(signatureBytes);
var envelope = JsonSerializer.Serialize(new
{
payloadType,
payload = payloadBase64,
signatures = new[]
{
new { keyId = "test-key-rsa-001", sig = signatureBase64 }
}
});
return (envelope, ExportPublicKeyPem(signingKey));
}
private static (string EnvelopeJson, string PublicKeyPem) CreateSignedEnvelope(Ed25519PrivateKeyParameters signingKey)
{
var payloadType = "https://in-toto.io/Statement/v1";
var payloadContent = "{\"_type\":\"https://in-toto.io/Statement/v1\",\"subject\":[]}";
var payloadBytes = Encoding.UTF8.GetBytes(payloadContent);
var payloadBase64 = Convert.ToBase64String(payloadBytes);
var pae = DsseHelper.PreAuthenticationEncoding(payloadType, payloadBytes);
var signer = new Ed25519Signer();
signer.Init(true, signingKey);
signer.BlockUpdate(pae, 0, pae.Length);
var signatureBytes = signer.GenerateSignature();
var signatureBase64 = Convert.ToBase64String(signatureBytes);
var envelope = JsonSerializer.Serialize(new
{
payloadType,
payload = payloadBase64,
signatures = new[]
{
new { keyId = "test-key-ed25519-001", sig = signatureBase64 }
}
});
var publicKeyPem = ExportPublicKeyPem(signingKey.GeneratePublicKey());
return (envelope, publicKeyPem);
}
private static string ExportPublicKeyPem(ECDsa key)
{
var publicKeyBytes = key.ExportSubjectPublicKeyInfo();
return ExportPublicKeyPem(key.ExportSubjectPublicKeyInfo());
}
private static string ExportPublicKeyPem(RSA key)
{
return ExportPublicKeyPem(key.ExportSubjectPublicKeyInfo());
}
private static string ExportPublicKeyPem(Ed25519PublicKeyParameters key)
{
var publicKeyBytes = SubjectPublicKeyInfoFactory.CreateSubjectPublicKeyInfo(key).GetDerEncoded();
return ExportPublicKeyPem(publicKeyBytes);
}
private static string ExportPublicKeyPem(byte[] publicKeyBytes)
{
var base64 = Convert.ToBase64String(publicKeyBytes);
var builder = new StringBuilder();
builder.AppendLine("-----BEGIN PUBLIC KEY-----");

View File

@@ -0,0 +1,327 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Attestor.Core.Signing;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Attestor.Tests.Fixtures;
using StellaOps.Attestor.WebService.Contracts;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Attestor.Tests;
public sealed class VerdictControllerSecurityTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CreateVerdict_WithTrustedRosterKey_ReturnsCreated_AndGetByHashResolves()
{
using var signingKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var publicKeyPem = ExportPublicKeyPem(signingKey);
using var factory = CreateFactory(
rosterEntries: new[]
{
new RosterEntry("trusted-key", "trusted", publicKeyPem)
},
new DeterministicSigningService(signingKey, keyId: "trusted-key"));
var client = factory.CreateClient();
var createResponse = await client.PostAsJsonAsync("/internal/api/v1/attestations/verdict", BuildRequest("trusted-key"));
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
var createPayload = await createResponse.Content.ReadFromJsonAsync<VerdictAttestationResponseDto>();
Assert.NotNull(createPayload);
Assert.False(string.IsNullOrWhiteSpace(createPayload!.VerdictId));
var getResponse = await client.GetAsync($"/api/v1/verdicts/{createPayload.VerdictId}");
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
var getPayload = await getResponse.Content.ReadFromJsonAsync<VerdictLookupResponseDto>();
Assert.NotNull(getPayload);
Assert.Equal(createPayload.VerdictId, getPayload!.VerdictId);
Assert.Equal("test-tenant", getPayload.TenantId);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CreateVerdict_WithUnknownRosterKey_ReturnsForbidden()
{
using var signingKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var publicKeyPem = ExportPublicKeyPem(signingKey);
using var factory = CreateFactory(
rosterEntries: new[]
{
new RosterEntry("trusted-key", "trusted", publicKeyPem)
},
new DeterministicSigningService(signingKey, keyId: "unknown-key"));
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/internal/api/v1/attestations/verdict", BuildRequest("unknown-key"));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Equal("authority_key_unknown", await ReadProblemCodeAsync(response));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CreateVerdict_WithRevokedRosterKey_ReturnsForbidden()
{
using var signingKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var publicKeyPem = ExportPublicKeyPem(signingKey);
using var factory = CreateFactory(
rosterEntries: new[]
{
new RosterEntry("revoked-key", "revoked", publicKeyPem)
},
new DeterministicSigningService(signingKey, keyId: "revoked-key"));
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/internal/api/v1/attestations/verdict", BuildRequest("revoked-key"));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Equal("authority_key_revoked", await ReadProblemCodeAsync(response));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CreateVerdict_WithRosterKeyMissingPublicMaterial_ReturnsDeterministicServerError()
{
using var signingKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using var factory = CreateFactory(
rosterEntries: new[]
{
new RosterEntry("trusted-key", "trusted", string.Empty)
},
new DeterministicSigningService(signingKey, keyId: "trusted-key"));
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/internal/api/v1/attestations/verdict", BuildRequest("trusted-key"));
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
Assert.Equal("authority_key_missing_public_key", await ReadProblemCodeAsync(response));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerdictEndpoints_WithTenantHeaderSpoofing_ReturnForbidden()
{
using var signingKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var publicKeyPem = ExportPublicKeyPem(signingKey);
using var factory = CreateFactory(
rosterEntries: new[]
{
new RosterEntry("trusted-key", "trusted", publicKeyPem)
},
new DeterministicSigningService(signingKey, keyId: "trusted-key"));
var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Tenant-Id", "spoofed-tenant");
var createResponse = await client.PostAsJsonAsync("/internal/api/v1/attestations/verdict", BuildRequest("trusted-key"));
Assert.Equal(HttpStatusCode.Forbidden, createResponse.StatusCode);
Assert.Equal("tenant_mismatch", await ReadProblemCodeAsync(createResponse));
var getResponse = await client.GetAsync("/api/v1/verdicts/verdict-missing");
Assert.Equal(HttpStatusCode.Forbidden, getResponse.StatusCode);
Assert.Equal("tenant_mismatch", await ReadProblemCodeAsync(getResponse));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetVerdictByHash_WhenMissing_ReturnsNotFound()
{
using var signingKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var publicKeyPem = ExportPublicKeyPem(signingKey);
using var factory = CreateFactory(
rosterEntries: new[]
{
new RosterEntry("trusted-key", "trusted", publicKeyPem)
},
new DeterministicSigningService(signingKey, keyId: "trusted-key"));
var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/verdicts/verdict-not-found");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.Equal("verdict_not_found", await ReadProblemCodeAsync(response));
}
private static VerdictAttestationRequestDto BuildRequest(string keyId) => new()
{
PredicateType = "https://stellaops.dev/predicates/policy-verdict@v1",
Predicate = "{\"verdict\":{\"status\":\"pass\",\"severity\":\"low\",\"score\":0.1},\"metadata\":{\"policyRunId\":\"run-1\",\"policyId\":\"policy-1\",\"policyVersion\":1,\"evaluatedAt\":\"2026-02-26T00:00:00Z\"}}",
Subject = new VerdictSubjectDto
{
Name = "finding-1"
},
KeyId = keyId
};
private static WebApplicationFactory<Program> CreateFactory(
IReadOnlyList<RosterEntry> rosterEntries,
IAttestationSigningService signingService)
{
var baseFactory = new AttestorTestWebApplicationFactory();
return baseFactory.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, configuration) =>
{
var settings = new Dictionary<string, string?>();
for (var i = 0; i < rosterEntries.Count; i++)
{
settings[$"attestor:verdictTrust:keys:{i}:keyId"] = rosterEntries[i].KeyId;
settings[$"attestor:verdictTrust:keys:{i}:status"] = rosterEntries[i].Status;
settings[$"attestor:verdictTrust:keys:{i}:publicKeyPem"] = rosterEntries[i].PublicKeyPem;
}
configuration.AddInMemoryCollection(settings);
});
builder.ConfigureServices(services =>
{
services.RemoveAll<IAttestationSigningService>();
services.RemoveAll<IHttpClientFactory>();
services.AddSingleton(signingService);
services.AddSingleton<IHttpClientFactory>(new StubEvidenceLockerHttpClientFactory());
});
});
}
private static async Task<string?> ReadProblemCodeAsync(HttpResponseMessage response)
{
var payload = await response.Content.ReadFromJsonAsync<JsonElement>();
if (payload.ValueKind != JsonValueKind.Object)
{
return null;
}
if (payload.TryGetProperty("code", out var directCode) && directCode.ValueKind == JsonValueKind.String)
{
return directCode.GetString();
}
return null;
}
private static string ExportPublicKeyPem(ECDsa key)
{
var publicKeyDer = key.ExportSubjectPublicKeyInfo();
var base64 = Convert.ToBase64String(publicKeyDer);
return $"-----BEGIN PUBLIC KEY-----\n{base64}\n-----END PUBLIC KEY-----";
}
private readonly record struct RosterEntry(string KeyId, string Status, string PublicKeyPem);
private sealed class DeterministicSigningService : IAttestationSigningService
{
private readonly ECDsa _signingKey;
private readonly string _keyId;
public DeterministicSigningService(ECDsa signingKey, string keyId)
{
_signingKey = signingKey;
_keyId = keyId;
}
public Task<AttestationSignResult> SignAsync(
AttestationSignRequest request,
SubmissionContext context,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var payloadBytes = Convert.FromBase64String(request.PayloadBase64);
var pae = DssePreAuthenticationEncoding.Compute(request.PayloadType, payloadBytes);
var signature = _signingKey.SignData(pae, HashAlgorithmName.SHA256);
var result = new AttestationSignResult
{
KeyId = _keyId,
Algorithm = "ES256",
Mode = "keyful",
Provider = "unit-test",
SignedAt = DateTimeOffset.UnixEpoch,
Bundle = new AttestorSubmissionRequest.SubmissionBundle
{
Dsse = new AttestorSubmissionRequest.DsseEnvelope
{
PayloadType = request.PayloadType,
PayloadBase64 = request.PayloadBase64,
Signatures = new List<AttestorSubmissionRequest.DsseSignature>
{
new()
{
KeyId = _keyId,
Signature = Convert.ToBase64String(signature)
}
}
},
Mode = "keyful",
CertificateChain = Array.Empty<string>()
},
Meta = new AttestorSubmissionRequest.SubmissionMeta
{
Artifact = new AttestorSubmissionRequest.ArtifactInfo
{
Sha256 = new string('a', 64),
Kind = "verdict"
},
BundleSha256 = Convert.ToHexString(SHA256.HashData(payloadBytes)).ToLowerInvariant(),
Archive = true,
LogPreference = "primary"
}
};
return Task.FromResult(result);
}
}
private sealed class StubEvidenceLockerHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client = new(new StubEvidenceLockerHandler())
{
BaseAddress = new Uri("http://localhost:5200", UriKind.Absolute),
Timeout = TimeSpan.FromSeconds(1)
};
public HttpClient CreateClient(string name) => _client;
}
private sealed class StubEvidenceLockerHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request.Method == HttpMethod.Post &&
request.RequestUri is { AbsolutePath: "/api/v1/verdicts" })
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
if (request.Method == HttpMethod.Get &&
request.RequestUri is { AbsolutePath: var path } &&
path.StartsWith("/api/v1/verdicts/", StringComparison.Ordinal))
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
}
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
}
}
}

View File

@@ -0,0 +1,346 @@
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Symbols.Bundle;
using StellaOps.Symbols.Bundle.Abstractions;
using StellaOps.Symbols.Core.Models;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using Xunit;
using BundleManifest = StellaOps.Symbols.Bundle.Models.BundleManifest;
using CoreSymbolEntry = StellaOps.Symbols.Core.Models.SymbolEntry;
namespace StellaOps.Symbols.Tests.Bundle;
public sealed class BundleBuilderVerificationTests
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
[Fact]
public async Task VerifyAsync_SignedAndRekorBundle_PassesWithRequiredGates()
{
var fixture = await CreateFixtureAsync(sign: true, submitRekor: true);
try
{
var verify = await fixture.Builder.VerifyAsync(
fixture.BundlePath,
new BundleVerifyOptions
{
RequireSignature = true,
RequireRekorProof = true,
VerifyRekorOffline = true
});
Assert.True(verify.Valid, string.Join("; ", verify.Errors));
Assert.Equal(SignatureStatus.Valid, verify.SignatureStatus);
Assert.Equal(RekorVerifyStatus.VerifiedOffline, verify.RekorStatus);
Assert.NotNull(verify.Manifest);
Assert.StartsWith("blake3:", verify.Manifest!.BundleId, StringComparison.Ordinal);
Assert.All(verify.Manifest.Entries, entry =>
{
Assert.StartsWith("blake3:", entry.ManifestHash, StringComparison.Ordinal);
Assert.StartsWith("blake3:", entry.BlobHash, StringComparison.Ordinal);
});
}
finally
{
fixture.Dispose();
}
}
[Fact]
public async Task VerifyAsync_TamperedSignature_FailsDeterministically()
{
var fixture = await CreateFixtureAsync(sign: true, submitRekor: false);
try
{
await RewriteManifestAsync(
fixture.BundlePath,
manifest =>
{
var signature = manifest.Signature!;
return manifest with
{
Signature = signature with
{
Signature = TamperBase64(signature.Signature!)
}
};
});
var verify = await fixture.Builder.VerifyAsync(
fixture.BundlePath,
new BundleVerifyOptions { RequireSignature = true });
Assert.False(verify.Valid);
Assert.Equal(SignatureStatus.Invalid, verify.SignatureStatus);
Assert.Contains(
verify.Errors,
error => error.StartsWith("signature_verification_failed:signature_mismatch", StringComparison.Ordinal));
}
finally
{
fixture.Dispose();
}
}
[Fact]
public async Task VerifyAsync_UnsignedBundle_WhenSignatureRequired_Fails()
{
var fixture = await CreateFixtureAsync(sign: false, submitRekor: false);
try
{
var verify = await fixture.Builder.VerifyAsync(
fixture.BundlePath,
new BundleVerifyOptions { RequireSignature = true });
Assert.False(verify.Valid);
Assert.Equal(SignatureStatus.Unsigned, verify.SignatureStatus);
Assert.Contains(
verify.Errors,
error => error.StartsWith("signature_verification_failed:signature_not_present", StringComparison.Ordinal));
}
finally
{
fixture.Dispose();
}
}
[Fact]
public async Task VerifyAsync_WhenRekorProofRequiredButCheckpointMissing_FailsDeterministically()
{
var fixture = await CreateFixtureAsync(sign: true, submitRekor: false);
try
{
var verify = await fixture.Builder.VerifyAsync(
fixture.BundlePath,
new BundleVerifyOptions
{
RequireSignature = true,
RequireRekorProof = true,
VerifyRekorOffline = true
});
Assert.False(verify.Valid);
Assert.Null(verify.RekorStatus);
Assert.Contains("rekor_proof_required:missing_checkpoint", verify.Errors);
}
finally
{
fixture.Dispose();
}
}
[Fact]
public async Task VerifyAsync_TruncatedInclusionProof_FailsDeterministically()
{
var fixture = await CreateFixtureAsync(sign: true, submitRekor: true);
try
{
await RewriteManifestAsync(
fixture.BundlePath,
manifest =>
{
var checkpoint = manifest.RekorCheckpoint!;
var proof = checkpoint.InclusionProof!;
return manifest with
{
RekorCheckpoint = checkpoint with
{
InclusionProof = proof with
{
Hashes = proof.Hashes.Take(1).ToArray()
}
}
};
});
var verify = await fixture.Builder.VerifyAsync(
fixture.BundlePath,
new BundleVerifyOptions
{
RequireSignature = true,
RequireRekorProof = true,
VerifyRekorOffline = true
});
Assert.False(verify.Valid);
Assert.Equal(RekorVerifyStatus.Invalid, verify.RekorStatus);
Assert.Contains(
verify.Errors,
error => error.StartsWith("rekor_inclusion_proof_failed:proof_nodes_truncated", StringComparison.Ordinal));
}
finally
{
fixture.Dispose();
}
}
[Fact]
public async Task VerifyAsync_CorruptedInclusionProofRoot_FailsDeterministically()
{
var fixture = await CreateFixtureAsync(sign: true, submitRekor: true);
try
{
await RewriteManifestAsync(
fixture.BundlePath,
manifest =>
{
var checkpoint = manifest.RekorCheckpoint!;
var proof = checkpoint.InclusionProof!;
return manifest with
{
RekorCheckpoint = checkpoint with
{
InclusionProof = proof with
{
RootHash = "blake3:" + new string('0', 64)
}
}
};
});
var verify = await fixture.Builder.VerifyAsync(
fixture.BundlePath,
new BundleVerifyOptions
{
RequireSignature = true,
RequireRekorProof = true,
VerifyRekorOffline = true
});
Assert.False(verify.Valid);
Assert.Equal(RekorVerifyStatus.Invalid, verify.RekorStatus);
Assert.Contains(
verify.Errors,
error => error.StartsWith("rekor_inclusion_proof_failed:proof_root_mismatch", StringComparison.Ordinal));
}
finally
{
fixture.Dispose();
}
}
private static async Task<TestFixture> CreateFixtureAsync(bool sign, bool submitRekor)
{
var rootDir = Path.Combine(Path.GetTempPath(), "stella-symbols-tests", Guid.NewGuid().ToString("N"));
var sourceDir = Path.Combine(rootDir, "source");
var outputDir = Path.Combine(rootDir, "out");
Directory.CreateDirectory(sourceDir);
Directory.CreateDirectory(outputDir);
const string debugId = "DBG001";
var manifest = new SymbolManifest
{
ManifestId = "blake3:manifest-dbg001",
DebugId = debugId,
CodeId = "code-001",
BinaryName = "libsample.so",
Platform = "linux-x64",
Format = BinaryFormat.Elf,
Symbols =
[
new CoreSymbolEntry
{
Address = 0x1000,
Size = 16,
MangledName = "_ZL6samplev",
DemangledName = "sample()"
}
],
TenantId = "tenant-default",
BlobUri = "cas://symbols/tenant-default/dbg001/blob",
CreatedAt = new DateTimeOffset(2026, 2, 26, 12, 0, 0, TimeSpan.Zero)
};
await File.WriteAllTextAsync(
Path.Combine(sourceDir, $"{debugId}.symbols.json"),
JsonSerializer.Serialize(manifest, JsonOptions));
await File.WriteAllBytesAsync(
Path.Combine(sourceDir, $"{debugId}.sym"),
Encoding.UTF8.GetBytes("deterministic-symbol-blob-content"));
string? signingKeyPath = null;
if (sign)
{
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
signingKeyPath = Path.Combine(rootDir, "signing-key.pem");
await File.WriteAllTextAsync(signingKeyPath, ecdsa.ExportECPrivateKeyPem());
}
var builder = new BundleBuilder(NullLogger<BundleBuilder>.Instance);
var build = await builder.BuildAsync(new BundleBuildOptions
{
Name = "symbols-fixture",
Version = "1.0.0",
SourceDir = sourceDir,
OutputDir = outputDir,
Sign = sign,
SigningKeyPath = signingKeyPath,
SubmitRekor = submitRekor,
RekorUrl = "https://rekor.example.test"
});
Assert.True(build.Success, build.Error);
Assert.NotNull(build.BundlePath);
return new TestFixture(builder, build.BundlePath!, rootDir);
}
private static async Task RewriteManifestAsync(
string bundlePath,
Func<BundleManifest, BundleManifest> rewrite)
{
var tempDir = Path.Combine(Path.GetTempPath(), "stella-symbols-mutate", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
ZipFile.ExtractToDirectory(bundlePath, tempDir);
var manifestPath = Path.Combine(tempDir, "manifest.json");
var manifest = JsonSerializer.Deserialize<BundleManifest>(await File.ReadAllTextAsync(manifestPath))
?? throw new InvalidOperationException("Bundle manifest is missing or invalid.");
var mutated = rewrite(manifest);
await File.WriteAllTextAsync(manifestPath, JsonSerializer.Serialize(mutated, JsonOptions));
File.Delete(bundlePath);
ZipFile.CreateFromDirectory(tempDir, bundlePath, CompressionLevel.NoCompression, includeBaseDirectory: false);
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
private static string TamperBase64(string input)
{
if (string.IsNullOrEmpty(input))
{
return input;
}
var replacement = input[0] == 'A' ? 'B' : 'A';
return replacement + input[1..];
}
private sealed record TestFixture(BundleBuilder Builder, string BundlePath, string RootDir) : IDisposable
{
public void Dispose()
{
if (Directory.Exists(RootDir))
{
Directory.Delete(RootDir, recursive: true);
}
}
}
}

View File

@@ -0,0 +1,359 @@
using System.CommandLine;
using System.Formats.Tar;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Attestor.Core.Verification;
using StellaOps.Cli.Commands;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Cli.Tests.Commands;
[Trait("Category", TestCategories.Unit)]
public sealed class Sprint222ProofVerificationTests : IDisposable
{
private readonly string _tempRoot;
public Sprint222ProofVerificationTests()
{
_tempRoot = Path.Combine(Path.GetTempPath(), $"stellaops-sprint222-{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempRoot);
}
public void Dispose()
{
try
{
if (Directory.Exists(_tempRoot))
{
Directory.Delete(_tempRoot, recursive: true);
}
}
catch
{
// Best effort cleanup.
}
}
[Fact]
public async Task SbomVerify_WithValidRsaSignature_ReportsVerified()
{
var (archivePath, trustRootPath) = CreateSignedSbomArchive(tamperSignature: false);
var verboseOption = new Option<bool>("--verbose");
var root = new RootCommand { SbomCommandGroup.BuildSbomCommand(verboseOption, CancellationToken.None) };
var result = await InvokeAsync(root, $"sbom verify --archive \"{archivePath}\" --trust-root \"{trustRootPath}\" --format json");
result.ExitCode.Should().Be(0, $"stdout: {result.StdOut}\nstderr: {result.StdErr}");
using var json = JsonDocument.Parse(result.StdOut);
var dsseCheck = FindCheck(json.RootElement, "DSSE envelope signature");
dsseCheck.GetProperty("passed").GetBoolean().Should().BeTrue();
dsseCheck.GetProperty("details").GetString().Should().Contain("dsse-signature-verified");
}
[Fact]
public async Task SbomVerify_WithTamperedSignature_ReturnsDeterministicFailureReason()
{
var (archivePath, trustRootPath) = CreateSignedSbomArchive(tamperSignature: true);
var verboseOption = new Option<bool>("--verbose");
var root = new RootCommand { SbomCommandGroup.BuildSbomCommand(verboseOption, CancellationToken.None) };
var result = await InvokeAsync(root, $"sbom verify --archive \"{archivePath}\" --trust-root \"{trustRootPath}\" --format json");
result.ExitCode.Should().Be(1, $"stdout: {result.StdOut}\nstderr: {result.StdErr}");
using var json = JsonDocument.Parse(result.StdOut);
var dsseCheck = FindCheck(json.RootElement, "DSSE envelope signature");
dsseCheck.GetProperty("passed").GetBoolean().Should().BeFalse();
dsseCheck.GetProperty("details").GetString().Should().Contain("dsse-signature-verification-failed");
}
[Fact]
public async Task SbomVerify_WithoutTrustRoot_ReturnsDeterministicTrustRootMissingReason()
{
var (archivePath, _) = CreateSignedSbomArchive(tamperSignature: false);
var verboseOption = new Option<bool>("--verbose");
var root = new RootCommand { SbomCommandGroup.BuildSbomCommand(verboseOption, CancellationToken.None) };
var result = await InvokeAsync(root, $"sbom verify --archive \"{archivePath}\" --format json");
result.ExitCode.Should().Be(1, $"stdout: {result.StdOut}\nstderr: {result.StdErr}");
using var json = JsonDocument.Parse(result.StdOut);
var dsseCheck = FindCheck(json.RootElement, "DSSE envelope signature");
dsseCheck.GetProperty("passed").GetBoolean().Should().BeFalse();
dsseCheck.GetProperty("details").GetString().Should().Be(
"trust-root-missing: supply --trust-root with trusted key/certificate material");
}
[Fact]
public async Task BundleVerify_WithValidRekorCheckpoint_ValidatesInclusion()
{
var (bundleDir, checkpointPath) = CreateBundleWithProof(mismatchCheckpointRoot: false);
var verboseOption = new Option<bool>("--verbose");
var services = new ServiceCollection().BuildServiceProvider();
var verifyCommand = BundleVerifyCommand.BuildVerifyBundleEnhancedCommand(services, verboseOption, CancellationToken.None);
var root = new RootCommand { verifyCommand };
var result = await InvokeAsync(
root,
$"verify --bundle \"{bundleDir}\" --rekor-checkpoint \"{checkpointPath}\" --output json --offline");
result.ExitCode.Should().Be(0);
using var json = JsonDocument.Parse(result.StdOut);
var inclusionCheck = FindCheck(json.RootElement, "rekor:inclusion");
inclusionCheck.GetProperty("passed").GetBoolean().Should().BeTrue();
inclusionCheck.GetProperty("message").GetString().Should().Contain("Inclusion verified");
}
[Fact]
public async Task BundleVerify_WithMismatchedCheckpointRoot_FailsDeterministically()
{
var (bundleDir, checkpointPath) = CreateBundleWithProof(mismatchCheckpointRoot: true);
var verboseOption = new Option<bool>("--verbose");
var services = new ServiceCollection().BuildServiceProvider();
var verifyCommand = BundleVerifyCommand.BuildVerifyBundleEnhancedCommand(services, verboseOption, CancellationToken.None);
var root = new RootCommand { verifyCommand };
var result = await InvokeAsync(
root,
$"verify --bundle \"{bundleDir}\" --rekor-checkpoint \"{checkpointPath}\" --output json --offline");
result.ExitCode.Should().Be(1);
using var json = JsonDocument.Parse(result.StdOut);
var inclusionCheck = FindCheck(json.RootElement, "rekor:inclusion");
inclusionCheck.GetProperty("passed").GetBoolean().Should().BeFalse();
inclusionCheck.GetProperty("message").GetString().Should().Be("proof-root-hash-mismatch-with-checkpoint");
}
[Fact]
public async Task BundleVerify_WithMissingCheckpointPath_FailsDeterministically()
{
var (bundleDir, _) = CreateBundleWithProof(mismatchCheckpointRoot: false);
var missingCheckpointPath = Path.Combine(_tempRoot, $"missing-checkpoint-{Guid.NewGuid():N}.json");
var verboseOption = new Option<bool>("--verbose");
var services = new ServiceCollection().BuildServiceProvider();
var verifyCommand = BundleVerifyCommand.BuildVerifyBundleEnhancedCommand(services, verboseOption, CancellationToken.None);
var root = new RootCommand { verifyCommand };
var result = await InvokeAsync(
root,
$"verify --bundle \"{bundleDir}\" --rekor-checkpoint \"{missingCheckpointPath}\" --output json --offline");
result.ExitCode.Should().Be(1);
using var json = JsonDocument.Parse(result.StdOut);
var inclusionCheck = FindCheck(json.RootElement, "rekor:inclusion");
inclusionCheck.GetProperty("passed").GetBoolean().Should().BeFalse();
inclusionCheck.GetProperty("message").GetString().Should().StartWith("checkpoint-not-found:");
}
private (string ArchivePath, string TrustRootPath) CreateSignedSbomArchive(bool tamperSignature)
{
var archivePath = Path.Combine(_tempRoot, $"sbom-{Guid.NewGuid():N}.tar.gz");
var trustRootPath = Path.Combine(_tempRoot, $"trust-{Guid.NewGuid():N}.pem");
var sbomJson = JsonSerializer.Serialize(new
{
spdxVersion = "SPDX-2.3",
SPDXID = "SPDXRef-DOCUMENT",
name = "test-sbom"
});
using var rsa = RSA.Create(2048);
var publicPem = rsa.ExportSubjectPublicKeyInfoPem();
File.WriteAllText(trustRootPath, publicPem);
var payloadBytes = Encoding.UTF8.GetBytes(sbomJson);
var payloadBase64 = Convert.ToBase64String(payloadBytes);
var pae = BuildDssePae("application/vnd.in-toto+json", payloadBytes);
var signature = rsa.SignData(pae, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
if (tamperSignature)
{
signature[0] ^= 0xFF;
}
var dsseJson = JsonSerializer.Serialize(new
{
payloadType = "application/vnd.in-toto+json",
payload = payloadBase64,
signatures = new[]
{
new { keyid = "test-key", sig = Convert.ToBase64String(signature) }
}
});
var files = new Dictionary<string, string>
{
["sbom.spdx.json"] = sbomJson,
["sbom.dsse.json"] = dsseJson
};
files["manifest.json"] = JsonSerializer.Serialize(new
{
schemaVersion = "1.0.0",
files = files.Select(entry => new
{
path = entry.Key,
sha256 = ComputeSha256Hex(entry.Value)
}).ToArray()
});
using var fileStream = File.Create(archivePath);
using var gzipStream = new GZipStream(fileStream, CompressionLevel.SmallestSize);
using var tarWriter = new TarWriter(gzipStream, TarEntryFormat.Ustar);
foreach (var (name, content) in files.OrderBy(kv => kv.Key, StringComparer.Ordinal))
{
var entry = new UstarTarEntry(TarEntryType.RegularFile, name)
{
DataStream = new MemoryStream(Encoding.UTF8.GetBytes(content), writable: false),
ModificationTime = new DateTimeOffset(2026, 2, 26, 0, 0, 0, TimeSpan.Zero),
Mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead
};
tarWriter.WriteEntry(entry);
}
return (archivePath, trustRootPath);
}
private (string BundleDir, string CheckpointPath) CreateBundleWithProof(bool mismatchCheckpointRoot)
{
var bundleDir = Path.Combine(_tempRoot, $"bundle-{Guid.NewGuid():N}");
Directory.CreateDirectory(bundleDir);
var artifactPath = Path.Combine(bundleDir, "attestations", "sample.txt");
Directory.CreateDirectory(Path.GetDirectoryName(artifactPath)!);
File.WriteAllText(artifactPath, "sample-artifact");
var artifactDigest = $"sha256:{ComputeSha256Hex("sample-artifact")}";
var leafHash = SHA256.HashData(Encoding.UTF8.GetBytes("leaf-hash"));
var siblingHash = SHA256.HashData(Encoding.UTF8.GetBytes("sibling-hash"));
var rootHash = MerkleProofVerifier.HashInterior(leafHash, siblingHash);
var checkpointRootHash = mismatchCheckpointRoot
? SHA256.HashData(Encoding.UTF8.GetBytes("different-root"))
: rootHash;
var proofJson = JsonSerializer.Serialize(new
{
logIndex = 0,
treeSize = 2,
leafHash = Convert.ToHexString(leafHash).ToLowerInvariant(),
hashes = new[] { Convert.ToHexString(siblingHash).ToLowerInvariant() },
rootHash = Convert.ToHexString(rootHash).ToLowerInvariant()
});
File.WriteAllText(Path.Combine(bundleDir, "rekor.proof.json"), proofJson);
var manifestJson = JsonSerializer.Serialize(new
{
schemaVersion = "2.0",
bundle = new
{
image = "registry.example.com/test:1.0",
digest = "sha256:testdigest",
artifacts = new[]
{
new
{
path = "attestations/sample.txt",
digest = artifactDigest,
mediaType = "text/plain"
}
}
},
verify = new
{
expectations = new { payloadTypes = Array.Empty<string>() }
}
});
File.WriteAllText(Path.Combine(bundleDir, "manifest.json"), manifestJson);
var checkpointPath = Path.Combine(_tempRoot, $"checkpoint-{Guid.NewGuid():N}.json");
var checkpointJson = JsonSerializer.Serialize(new
{
treeSize = 2,
rootHash = Convert.ToHexString(checkpointRootHash).ToLowerInvariant()
});
File.WriteAllText(checkpointPath, checkpointJson);
return (bundleDir, checkpointPath);
}
private static async Task<CommandInvocationResult> InvokeAsync(RootCommand root, string args)
{
var stdout = new StringWriter();
var stderr = new StringWriter();
var originalOut = Console.Out;
var originalErr = Console.Error;
var originalExitCode = Environment.ExitCode;
Environment.ExitCode = 0;
try
{
Console.SetOut(stdout);
Console.SetError(stderr);
var parseResult = root.Parse(args);
var returnCode = await parseResult.InvokeAsync().ConfigureAwait(false);
var exitCode = returnCode != 0 ? returnCode : Environment.ExitCode;
return new CommandInvocationResult(stdout.ToString(), stderr.ToString(), exitCode);
}
finally
{
Console.SetOut(originalOut);
Console.SetError(originalErr);
Environment.ExitCode = originalExitCode;
}
}
private static byte[] BuildDssePae(string payloadType, byte[] payload)
{
var header = Encoding.UTF8.GetBytes("DSSEv1");
var payloadTypeBytes = Encoding.UTF8.GetBytes(payloadType);
var payloadTypeLengthBytes = Encoding.UTF8.GetBytes(payloadTypeBytes.Length.ToString());
var payloadLengthBytes = Encoding.UTF8.GetBytes(payload.Length.ToString());
var space = new[] { (byte)' ' };
var output = new byte[
header.Length + space.Length + payloadTypeLengthBytes.Length + space.Length +
payloadTypeBytes.Length + space.Length + payloadLengthBytes.Length + space.Length +
payload.Length];
var offset = 0;
Buffer.BlockCopy(header, 0, output, offset, header.Length); offset += header.Length;
Buffer.BlockCopy(space, 0, output, offset, space.Length); offset += space.Length;
Buffer.BlockCopy(payloadTypeLengthBytes, 0, output, offset, payloadTypeLengthBytes.Length); offset += payloadTypeLengthBytes.Length;
Buffer.BlockCopy(space, 0, output, offset, space.Length); offset += space.Length;
Buffer.BlockCopy(payloadTypeBytes, 0, output, offset, payloadTypeBytes.Length); offset += payloadTypeBytes.Length;
Buffer.BlockCopy(space, 0, output, offset, space.Length); offset += space.Length;
Buffer.BlockCopy(payloadLengthBytes, 0, output, offset, payloadLengthBytes.Length); offset += payloadLengthBytes.Length;
Buffer.BlockCopy(space, 0, output, offset, space.Length); offset += space.Length;
Buffer.BlockCopy(payload, 0, output, offset, payload.Length);
return output;
}
private static string ComputeSha256Hex(string content)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static JsonElement FindCheck(JsonElement root, string checkName)
{
var checks = root.GetProperty("checks");
foreach (var check in checks.EnumerateArray())
{
if (check.TryGetProperty("name", out var name) &&
string.Equals(name.GetString(), checkName, StringComparison.Ordinal))
{
return check;
}
}
throw new Xunit.Sdk.XunitException($"Check '{checkName}' not found in output.");
}
private sealed record CommandInvocationResult(string StdOut, string StdErr, int ExitCode);
}

View File

@@ -44,4 +44,6 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| SPRINT_20260224_004-LOC-306 | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: added dedicated `/settings/language` UX wiring that reuses Platform persisted language preference API for authenticated users. |
| SPRINT_20260224_004-LOC-307 | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: added Ukrainian locale support (`uk-UA`) across Platform translation assets and preference normalization aliases (`uk-UA`/`uk_UA`/`uk`/`ua`). |
| SPRINT_20260224_004-LOC-308 | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: platform locale catalog endpoint (`GET /api/v1/platform/localization/locales`) is now consumed by both UI and CLI locale-selection paths. |
| SPRINT_20260226_230-LOC-001 | DONE | Sprint `docs-archived/implplan/2026-03-03-completed-sprints/SPRINT_20260226_230_Platform_locale_label_translation_corrections.md`: completed non-English translation correction across Platform/Web/shared localization bundles (`bg-BG`, `de-DE`, `es-ES`, `fr-FR`, `ru-RU`, `uk-UA`, `zh-CN`, `zh-TW`), including cleanup of placeholder/transliteration/malformed values (`Ezik`, leaked token markers, mojibake) and a context-quality pass for backend German resource bundles (`graph`, `policy`, `scanner`, `advisoryai`). |
| PLATFORM-223-001 | DONE | Sprint `docs-archived/implplan/2026-03-03-completed-sprints/SPRINT_20260226_223_Platform_score_explain_contract_and_replay_alignment.md`: shipped deterministic score explain/replay contract completion (`unknowns`, `proof_ref`, deterministic replay envelope parsing/verification differences) and updated score API/module docs with contract notes. |

View File

@@ -0,0 +1,297 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Platform.WebService.Contracts;
using StellaOps.Platform.WebService.Services;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Platform.WebService.Tests;
public sealed class ScoreExplainEndpointContractTests : IClassFixture<PlatformWebApplicationFactory>
{
private readonly PlatformWebApplicationFactory _factory;
public ScoreExplainEndpointContractTests(PlatformWebApplicationFactory factory)
{
_factory = factory;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Explain_WhenDigestExists_ReturnsDeterministicContract()
{
using var deterministicFactory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IScoreEvaluationService>();
services.AddSingleton<IScoreEvaluationService, StaticScoreEvaluationService>();
});
});
using var client = CreateClient(deterministicFactory, "tenant-score-explain-valid");
const string digest = "sha256:abc123";
var explainResponseA = await client.GetAsync(
"/api/v1/score/explain/SHA256:ABC123",
TestContext.Current.CancellationToken);
explainResponseA.EnsureSuccessStatusCode();
using var explainA = JsonDocument.Parse(
await explainResponseA.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
var itemA = GetAnyProperty(explainA.RootElement, "item", "Item");
Assert.Equal("score.explain.v1", GetAnyProperty(itemA, "contract_version", "contractVersion", "ContractVersion").GetString());
Assert.Equal(digest, GetAnyProperty(itemA, "digest", "Digest").GetString());
Assert.Equal(digest.ToLowerInvariant(), GetAnyProperty(itemA, "deterministic_input_hash", "deterministicInputHash", "DeterministicInputHash").GetString());
Assert.True(GetAnyProperty(itemA, "factors", "Factors").GetArrayLength() > 0);
Assert.True(GetAnyProperty(itemA, "sources", "Sources").GetArrayLength() > 0);
var explainResponseB = await client.GetAsync(
$"/api/v1/score/explain/{Uri.EscapeDataString(digest)}",
TestContext.Current.CancellationToken);
explainResponseB.EnsureSuccessStatusCode();
using var explainB = JsonDocument.Parse(
await explainResponseB.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
var itemB = GetAnyProperty(explainB.RootElement, "item", "Item");
var itemAJson = JsonSerializer.Serialize(itemA);
var itemBJson = JsonSerializer.Serialize(itemB);
Assert.Equal(itemAJson, itemBJson);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Explain_WhenDigestOmitsAlgorithm_NormalizesToSha256Deterministically()
{
using var deterministicFactory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IScoreEvaluationService>();
services.AddSingleton<IScoreEvaluationService, StaticScoreEvaluationService>();
});
});
using var client = CreateClient(deterministicFactory, "tenant-score-explain-normalized");
var response = await client.GetAsync(
"/api/v1/score/explain/ABC123",
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
var item = GetAnyProperty(payload.RootElement, "item", "Item");
Assert.Equal("sha256:abc123", GetAnyProperty(item, "digest", "Digest").GetString());
Assert.Equal("sha256:abc123", GetAnyProperty(item, "deterministic_input_hash", "deterministicInputHash", "DeterministicInputHash").GetString());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Explain_WhenDigestMissing_ReturnsDeterministicNotFound()
{
using var client = CreateClient(_factory, "tenant-score-explain-not-found");
var response = await client.GetAsync(
"/api/v1/score/explain/sha256:does-not-exist",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
var error = await response.Content.ReadFromJsonAsync<ScoreExplainErrorResponse>(
cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(error);
Assert.Equal("not_found", error!.Code);
Assert.Equal("sha256:does-not-exist", error.Digest);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Explain_WhenDigestInvalid_ReturnsDeterministicInvalidInput()
{
using var client = CreateClient(_factory, "tenant-score-explain-invalid");
var response = await client.GetAsync(
"/api/v1/score/explain/%20",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var error = await response.Content.ReadFromJsonAsync<ScoreExplainErrorResponse>(
cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(error);
Assert.Equal("invalid_input", error!.Code);
Assert.Equal(" ", error.Digest);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Explain_WhenDigestHasMissingHashSegment_ReturnsDeterministicInvalidInput()
{
using var client = CreateClient(_factory, "tenant-score-explain-invalid-segment");
var response = await client.GetAsync(
$"/api/v1/score/explain/{Uri.EscapeDataString("sha256:")}",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var error = await response.Content.ReadFromJsonAsync<ScoreExplainErrorResponse>(
cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(error);
Assert.Equal("invalid_input", error!.Code);
Assert.Equal("sha256:", error.Digest);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Explain_WhenBackendUnavailable_ReturnsDeterministicBackendUnavailable()
{
using var backendUnavailableFactory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IScoreEvaluationService>();
services.AddSingleton<IScoreEvaluationService, ThrowingScoreEvaluationService>();
});
});
using var client = CreateClient(backendUnavailableFactory, "tenant-score-explain-backend");
var response = await client.GetAsync(
"/api/v1/score/explain/sha256:abc123",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
var error = await response.Content.ReadFromJsonAsync<ScoreExplainErrorResponse>(
cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(error);
Assert.Equal("backend_unavailable", error!.Code);
Assert.Equal("sha256:abc123", error.Digest);
}
private static HttpClient CreateClient(WebApplicationFactory<StellaOps.Platform.WebService.Options.PlatformServiceOptions> factory, string tenantId)
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "score-test-actor");
return client;
}
private sealed class ThrowingScoreEvaluationService : IScoreEvaluationService
{
public Task<PlatformCacheResult<ScoreEvaluateResponse>> EvaluateAsync(PlatformRequestContext context, ScoreEvaluateRequest request, CancellationToken ct = default) =>
throw new NotSupportedException();
public Task<PlatformCacheResult<ScoreEvaluateResponse?>> GetByIdAsync(PlatformRequestContext context, string scoreId, CancellationToken ct = default) =>
throw new NotSupportedException();
public Task<PlatformCacheResult<ScoreExplainResponse?>> GetExplanationAsync(PlatformRequestContext context, string digest, CancellationToken ct = default) =>
throw new InvalidOperationException("backend unavailable");
public Task<PlatformCacheResult<IReadOnlyList<WeightManifestSummary>>> ListWeightManifestsAsync(PlatformRequestContext context, CancellationToken ct = default) =>
throw new NotSupportedException();
public Task<PlatformCacheResult<WeightManifestDetail?>> GetWeightManifestAsync(PlatformRequestContext context, string version, CancellationToken ct = default) =>
throw new NotSupportedException();
public Task<PlatformCacheResult<WeightManifestDetail?>> GetEffectiveWeightManifestAsync(PlatformRequestContext context, DateTimeOffset asOf, CancellationToken ct = default) =>
throw new NotSupportedException();
public Task<PlatformCacheResult<IReadOnlyList<ScoreHistoryRecord>>> GetHistoryAsync(PlatformRequestContext context, string cveId, string? purl, int limit, CancellationToken ct = default) =>
throw new NotSupportedException();
public Task<PlatformCacheResult<ScoreReplayResponse?>> GetReplayAsync(PlatformRequestContext context, string scoreId, CancellationToken ct = default) =>
throw new NotSupportedException();
public Task<PlatformCacheResult<ScoreVerifyResponse>> VerifyReplayAsync(PlatformRequestContext context, ScoreVerifyRequest request, CancellationToken ct = default) =>
throw new NotSupportedException();
}
private sealed class StaticScoreEvaluationService : IScoreEvaluationService
{
private static readonly ScoreExplainResponse Response = new()
{
ContractVersion = "score.explain.v1",
Digest = "sha256:abc123",
ScoreId = "score_abc123",
FinalScore = 62,
Bucket = "Investigate",
ComputedAt = DateTimeOffset.Parse("2026-02-26T12:00:00Z"),
DeterministicInputHash = "sha256:abc123",
ReplayLink = "/api/v1/score/score_abc123/replay",
Factors =
[
new ScoreExplainFactor
{
Name = "reachability",
Weight = 0.25,
Value = 1.0,
Contribution = 0.25
}
],
Sources =
[
new ScoreExplainSource
{
SourceType = "score_history",
SourceRef = "score-history:score_abc123",
SourceDigest = "sha256:abc123"
}
]
};
public Task<PlatformCacheResult<ScoreEvaluateResponse>> EvaluateAsync(PlatformRequestContext context, ScoreEvaluateRequest request, CancellationToken ct = default) =>
throw new NotSupportedException();
public Task<PlatformCacheResult<ScoreEvaluateResponse?>> GetByIdAsync(PlatformRequestContext context, string scoreId, CancellationToken ct = default) =>
throw new NotSupportedException();
public Task<PlatformCacheResult<ScoreExplainResponse?>> GetExplanationAsync(PlatformRequestContext context, string digest, CancellationToken ct = default)
{
var normalized = digest.ToLowerInvariant();
var value = string.Equals(normalized, "sha256:abc123", StringComparison.Ordinal)
? Response
: null;
return Task.FromResult(new PlatformCacheResult<ScoreExplainResponse?>(
value,
DateTimeOffset.Parse("2026-02-26T12:00:00Z"),
Cached: true,
CacheTtlSeconds: 300));
}
public Task<PlatformCacheResult<IReadOnlyList<WeightManifestSummary>>> ListWeightManifestsAsync(PlatformRequestContext context, CancellationToken ct = default) =>
throw new NotSupportedException();
public Task<PlatformCacheResult<WeightManifestDetail?>> GetWeightManifestAsync(PlatformRequestContext context, string version, CancellationToken ct = default) =>
throw new NotSupportedException();
public Task<PlatformCacheResult<WeightManifestDetail?>> GetEffectiveWeightManifestAsync(PlatformRequestContext context, DateTimeOffset asOf, CancellationToken ct = default) =>
throw new NotSupportedException();
public Task<PlatformCacheResult<IReadOnlyList<ScoreHistoryRecord>>> GetHistoryAsync(PlatformRequestContext context, string cveId, string? purl, int limit, CancellationToken ct = default) =>
throw new NotSupportedException();
public Task<PlatformCacheResult<ScoreReplayResponse?>> GetReplayAsync(PlatformRequestContext context, string scoreId, CancellationToken ct = default) =>
throw new NotSupportedException();
public Task<PlatformCacheResult<ScoreVerifyResponse>> VerifyReplayAsync(PlatformRequestContext context, ScoreVerifyRequest request, CancellationToken ct = default) =>
throw new NotSupportedException();
}
private static JsonElement GetAnyProperty(JsonElement element, params string[] names)
{
foreach (var name in names)
{
if (element.TryGetProperty(name, out var value))
{
return value;
}
}
throw new KeyNotFoundException($"None of the expected properties [{string.Join(", ", names)}] were found.");
}
}

View File

@@ -0,0 +1,220 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Scanner.Storage.Oci;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Options;
using StellaOps.Scanner.WebService.Services;
using StellaOps.TestKit;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using Xunit;
namespace StellaOps.Scanner.WebService.Tests;
public sealed class OciAttestationPublisherTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task PublishAsync_WhenDisabled_ReturnsSkippedDeterministically()
{
var handler = new OciRegistryHandler();
using var httpClient = new HttpClient(handler);
var pusher = new OciArtifactPusher(
httpClient,
CryptoHashFactory.CreateDefault(),
new OciRegistryOptions
{
DefaultRegistry = "registry.example"
},
NullLogger<OciArtifactPusher>.Instance);
var options = Microsoft.Extensions.Options.Options.Create(new ScannerWebServiceOptions
{
AttestationAttachment = new ScannerWebServiceOptions.AttestationAttachmentOptions
{
AutoAttach = false
}
});
var publisher = new OciAttestationPublisher(
options,
pusher,
NullLogger<OciAttestationPublisher>.Instance);
var report = new ReportDocumentDto
{
ReportId = "report-disabled",
ImageDigest = "registry.example/stellaops/demo@sha256:subjectdigest",
GeneratedAt = DateTimeOffset.Parse("2026-02-26T00:00:00Z"),
Verdict = "allow"
};
var result = await publisher.PublishAsync(report, envelope: null, tenant: "tenant-a", CancellationToken.None);
Assert.True(result.Success);
Assert.Equal(0, result.AttachmentCount);
Assert.Empty(result.Digests);
Assert.Equal("Attestation attachment disabled", result.Error);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task PublishAsync_WhenImageDigestMissing_ReturnsFailedDeterministically()
{
var handler = new OciRegistryHandler();
using var httpClient = new HttpClient(handler);
var pusher = new OciArtifactPusher(
httpClient,
CryptoHashFactory.CreateDefault(),
new OciRegistryOptions
{
DefaultRegistry = "registry.example"
},
NullLogger<OciArtifactPusher>.Instance);
var options = Microsoft.Extensions.Options.Options.Create(new ScannerWebServiceOptions
{
AttestationAttachment = new ScannerWebServiceOptions.AttestationAttachmentOptions
{
AutoAttach = true
}
});
var publisher = new OciAttestationPublisher(
options,
pusher,
NullLogger<OciAttestationPublisher>.Instance);
var report = new ReportDocumentDto
{
ReportId = "report-missing-image",
ImageDigest = "",
GeneratedAt = DateTimeOffset.Parse("2026-02-26T00:00:00Z"),
Verdict = "blocked"
};
var result = await publisher.PublishAsync(report, envelope: null, tenant: "tenant-a", CancellationToken.None);
Assert.False(result.Success);
Assert.Equal(0, result.AttachmentCount);
Assert.Empty(result.Digests);
Assert.Equal("Missing image digest", result.Error);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task PublishAsync_WhenEnabled_AttachesAttestationAndReturnsRealDigest()
{
var handler = new OciRegistryHandler();
using var httpClient = new HttpClient(handler);
var pusher = new OciArtifactPusher(
httpClient,
CryptoHashFactory.CreateDefault(),
new OciRegistryOptions
{
DefaultRegistry = "registry.example"
},
NullLogger<OciArtifactPusher>.Instance);
var options = Microsoft.Extensions.Options.Options.Create(new ScannerWebServiceOptions
{
AttestationAttachment = new ScannerWebServiceOptions.AttestationAttachmentOptions
{
AutoAttach = true,
ReplaceExisting = false,
PredicateTypes = new List<string>
{
"stellaops.io/predicates/scan-result@v1"
}
}
});
var publisher = new OciAttestationPublisher(
options,
pusher,
NullLogger<OciAttestationPublisher>.Instance);
var report = new ReportDocumentDto
{
ReportId = "report-1",
ImageDigest = "registry.example/stellaops/demo@sha256:subjectdigest",
GeneratedAt = DateTimeOffset.Parse("2026-02-26T00:00:00Z"),
Verdict = "blocked",
Policy = new ReportPolicyDto
{
Digest = "sha256:policy"
},
Summary = new ReportSummaryDto
{
Total = 1,
Blocked = 1
}
};
var envelope = new DsseEnvelopeDto
{
PayloadType = "application/vnd.stellaops.report+json",
Payload = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
Signatures =
[
new DsseSignatureDto
{
KeyId = "key-1",
Sig = "signature-value"
}
]
};
var result = await publisher.PublishAsync(report, envelope, "tenant-a", CancellationToken.None);
Assert.True(result.Success);
Assert.Equal(1, result.AttachmentCount);
Assert.Single(result.Digests);
Assert.StartsWith("sha256:", result.Digests[0], StringComparison.Ordinal);
}
private sealed class OciRegistryHandler : HttpMessageHandler
{
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
if (request.Method == HttpMethod.Head && path.Contains("/manifests/", StringComparison.Ordinal))
{
return new HttpResponseMessage(HttpStatusCode.NotFound);
}
if (request.Method == HttpMethod.Head && path.Contains("/blobs/", StringComparison.Ordinal))
{
return new HttpResponseMessage(HttpStatusCode.NotFound);
}
if (request.Method == HttpMethod.Post && path.EndsWith("/blobs/uploads/", StringComparison.Ordinal))
{
var response = new HttpResponseMessage(HttpStatusCode.Accepted);
response.Headers.Location = new Uri("/v2/stellaops/demo/blobs/uploads/upload-id", UriKind.Relative);
return response;
}
if (request.Method == HttpMethod.Put && path.Contains("/blobs/uploads/", StringComparison.Ordinal))
{
return new HttpResponseMessage(HttpStatusCode.Created);
}
if (request.Method == HttpMethod.Put && path.Contains("/manifests/", StringComparison.Ordinal))
{
var response = new HttpResponseMessage(HttpStatusCode.Created);
var manifestBytes = request.Content is null
? Array.Empty<byte>()
: await request.Content.ReadAsByteArrayAsync(cancellationToken);
var digest = $"sha256:{Convert.ToHexString(SHA256.HashData(manifestBytes)).ToLowerInvariant()}";
response.Headers.TryAddWithoutValidation("Docker-Content-Digest", digest);
return response;
}
return new HttpResponseMessage(HttpStatusCode.OK);
}
}
}

View File

@@ -0,0 +1,174 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Scanner.Explainability.Assumptions;
using StellaOps.Scanner.Reachability.Stack;
using StellaOps.Scanner.WebService.Endpoints;
using StellaOps.TestKit;
using System.Net;
using System.Text.Json;
using Xunit;
namespace StellaOps.Scanner.WebService.Tests;
public sealed class ReachabilityStackEndpointsTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetStack_WhenRepositoryNotConfigured_ReturnsNotImplemented()
{
await using var factory = ScannerApplicationFactory.CreateLightweight();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/reachability/finding-123/stack");
Assert.Equal(HttpStatusCode.NotImplemented, response.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetStack_WhenRepositoryConfigured_ReturnsPersistedStack()
{
var repository = new InMemoryReachabilityStackRepository();
await repository.StoreAsync(CreateSampleStack("finding-abc"), CancellationToken.None);
await using var factory = ScannerApplicationFactory.CreateLightweight().WithOverrides(
configureServices: services =>
{
services.RemoveAll<IReachabilityStackRepository>();
services.AddSingleton<IReachabilityStackRepository>(repository);
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/reachability/finding-abc/stack");
var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("finding-abc", payload.GetProperty("findingId").GetString());
Assert.Equal("Exploitable", payload.GetProperty("verdict").GetString());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetStack_WhenRepositoryConfiguredButFindingMissing_ReturnsNotFound()
{
var repository = new InMemoryReachabilityStackRepository();
await using var factory = ScannerApplicationFactory.CreateLightweight().WithOverrides(
configureServices: services =>
{
services.RemoveAll<IReachabilityStackRepository>();
services.AddSingleton<IReachabilityStackRepository>(repository);
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/reachability/finding-missing/stack");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetLayer_WhenRepositoryConfigured_ReturnsLayerPayload()
{
var repository = new InMemoryReachabilityStackRepository();
await repository.StoreAsync(CreateSampleStack("finding-layer"), CancellationToken.None);
await using var factory = ScannerApplicationFactory.CreateLightweight().WithOverrides(
configureServices: services =>
{
services.RemoveAll<IReachabilityStackRepository>();
services.AddSingleton<IReachabilityStackRepository>(repository);
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/reachability/finding-layer/stack/layer/2");
var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.True(payload.GetProperty("isResolved").GetBoolean());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetLayer_WhenLayerNumberInvalid_ReturnsBadRequest()
{
var repository = new InMemoryReachabilityStackRepository();
await repository.StoreAsync(CreateSampleStack("finding-invalid-layer"), CancellationToken.None);
await using var factory = ScannerApplicationFactory.CreateLightweight().WithOverrides(
configureServices: services =>
{
services.RemoveAll<IReachabilityStackRepository>();
services.AddSingleton<IReachabilityStackRepository>(repository);
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/reachability/finding-invalid-layer/stack/layer/4");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
private static ReachabilityStack CreateSampleStack(string findingId)
{
return new ReachabilityStack
{
Id = $"stack-{findingId}",
FindingId = findingId,
Symbol = new VulnerableSymbol(
Name: "vulnerable_func",
Library: "libdemo.so",
Version: "1.0.0",
VulnerabilityId: "CVE-2026-0001",
Type: SymbolType.Function),
StaticCallGraph = new ReachabilityLayer1
{
IsReachable = true,
Confidence = ConfidenceLevel.High,
AnalysisMethod = "unit-test"
},
BinaryResolution = new ReachabilityLayer2
{
IsResolved = true,
Confidence = ConfidenceLevel.High,
Reason = "resolved",
Resolution = new SymbolResolution(
SymbolName: "vulnerable_func",
ResolvedLibrary: "libdemo.so",
ResolvedVersion: "1.0.0",
SymbolVersion: null,
Method: ResolutionMethod.DirectLink)
},
RuntimeGating = new ReachabilityLayer3
{
IsGated = false,
Outcome = GatingOutcome.NotGated,
Confidence = ConfidenceLevel.High
},
Verdict = ReachabilityVerdict.Exploitable,
AnalyzedAt = DateTimeOffset.Parse("2026-02-26T00:00:00Z"),
Explanation = "deterministic-test"
};
}
private sealed class InMemoryReachabilityStackRepository : IReachabilityStackRepository
{
private readonly Dictionary<string, ReachabilityStack> _items = new(StringComparer.Ordinal);
public Task<ReachabilityStack?> TryGetByFindingIdAsync(string findingId, CancellationToken ct)
{
_items.TryGetValue(findingId, out var stack);
return Task.FromResult(stack);
}
public Task StoreAsync(ReachabilityStack stack, CancellationToken ct)
{
_items[stack.FindingId] = stack;
return Task.CompletedTask;
}
}
}

View File

@@ -0,0 +1,216 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Replay.Core;
using StellaOps.Scanner.Cache;
using StellaOps.Scanner.Cache.Abstractions;
using StellaOps.Scanner.Cache.FileCas;
using StellaOps.Scanner.Core;
using StellaOps.Scanner.ProofSpine;
using StellaOps.Scanner.Reachability.Slices;
using StellaOps.Scanner.WebService.Services;
using StellaOps.TestKit;
using System.Collections.Immutable;
using System.Text;
using System.Text.Json;
using Xunit;
namespace StellaOps.Scanner.WebService.Tests;
public sealed class SliceQueryServiceRetrievalTests : IDisposable
{
private readonly string _tempRoot;
private readonly IFileContentAddressableStore _cas;
private readonly SliceQueryService _service;
public SliceQueryServiceRetrievalTests()
{
_tempRoot = Path.Combine(Path.GetTempPath(), $"stella-slice-query-{Guid.NewGuid():N}");
var scannerCacheOptions = Microsoft.Extensions.Options.Options.Create(new ScannerCacheOptions
{
RootPath = _tempRoot,
FileTtl = TimeSpan.FromDays(1),
MaxBytes = 1024 * 1024 * 10
});
_cas = new FileContentAddressableStore(
scannerCacheOptions,
NullLogger<FileContentAddressableStore>.Instance,
TimeProvider.System);
var cryptoHash = CryptoHashFactory.CreateDefault();
var sliceHasher = new SliceHasher(cryptoHash);
var sliceSigner = new SliceDsseSigner(
new TestDsseSigningService(),
new TestCryptoProfile(),
sliceHasher,
TimeProvider.System);
var casStorage = new SliceCasStorage(sliceHasher, sliceSigner, cryptoHash);
_service = new SliceQueryService(
cache: new SliceCache(Microsoft.Extensions.Options.Options.Create(new SliceCacheOptions())),
extractor: new SliceExtractor(new VerdictComputer()),
casStorage: casStorage,
diffComputer: new StellaOps.Scanner.Reachability.Slices.Replay.SliceDiffComputer(),
hasher: sliceHasher,
cas: _cas,
scannerCacheOptions: scannerCacheOptions,
scanRepo: new NullScanMetadataRepository(),
timeProvider: TimeProvider.System,
options: Microsoft.Extensions.Options.Options.Create(new SliceQueryServiceOptions()),
logger: NullLogger<SliceQueryService>.Instance);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetSliceAsync_WhenSliceExistsInCas_ReturnsSlice()
{
const string digestHex = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
var bytes = JsonSerializer.SerializeToUtf8Bytes(CreateSlice("scan-a"));
await _cas.PutAsync(new FileCasPutRequest(digestHex, new MemoryStream(bytes)));
var result = await _service.GetSliceAsync($"sha256:{digestHex}");
Assert.NotNull(result);
Assert.Equal("scan-a", result!.Manifest.ScanId);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetSliceAsync_WhenSliceMissingInCas_ReturnsNull()
{
var result = await _service.GetSliceAsync("sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
Assert.Null(result);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetSliceAsync_WhenSlicePayloadCorrupt_ThrowsDeterministicError()
{
const string digestHex = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
await _cas.PutAsync(new FileCasPutRequest(digestHex, new MemoryStream(Encoding.UTF8.GetBytes("not-json"))));
var action = async () => await _service.GetSliceAsync($"sha256:{digestHex}");
var ex = await Assert.ThrowsAsync<InvalidOperationException>(action);
Assert.Contains("corrupt", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetSliceDsseAsync_WhenEnvelopeExists_ReturnsEnvelope()
{
const string digestHex = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd";
var envelope = new DsseEnvelope(
PayloadType: "application/vnd.stellaops.slice+json",
Payload: Convert.ToBase64String(Encoding.UTF8.GetBytes("{}")),
Signatures: [new DsseSignature("k1", "s1")]);
var envelopeBytes = JsonSerializer.SerializeToUtf8Bytes(envelope);
await _cas.PutAsync(new FileCasPutRequest($"{digestHex}.dsse", new MemoryStream(envelopeBytes)));
var result = await _service.GetSliceDsseAsync($"sha256:{digestHex}");
Assert.NotNull(result);
var typed = Assert.IsType<DsseEnvelope>(result);
Assert.Equal("application/vnd.stellaops.slice+json", typed.PayloadType);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetSliceDsseAsync_WhenEnvelopeCorrupt_ThrowsDeterministicError()
{
const string digestHex = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
await _cas.PutAsync(new FileCasPutRequest($"{digestHex}.dsse", new MemoryStream(Encoding.UTF8.GetBytes("{broken-json"))));
var action = async () => await _service.GetSliceDsseAsync($"sha256:{digestHex}");
var ex = await Assert.ThrowsAsync<InvalidOperationException>(action);
Assert.Contains("corrupt", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetSliceDsseAsync_WhenEnvelopeMissing_ReturnsNull()
{
var result = await _service.GetSliceDsseAsync("sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff");
Assert.Null(result);
}
public void Dispose()
{
try
{
Directory.Delete(_tempRoot, recursive: true);
}
catch (IOException)
{
}
catch (UnauthorizedAccessException)
{
}
}
private static ReachabilitySlice CreateSlice(string scanId)
{
return new ReachabilitySlice
{
Inputs = new SliceInputs
{
GraphDigest = "sha256:graph"
},
Query = new SliceQuery
{
CveId = "CVE-2026-0001",
TargetSymbols = ImmutableArray.Create("target")
},
Subgraph = new SliceSubgraph
{
Nodes = ImmutableArray<SliceNode>.Empty,
Edges = ImmutableArray<SliceEdge>.Empty
},
Verdict = new SliceVerdict
{
Status = SliceVerdictStatus.Unknown,
Confidence = 0.4
},
Manifest = ScanManifest.CreateBuilder(scanId, "sha256:artifact")
.WithScannerVersion("1.0.0")
.WithWorkerVersion("1.0.0")
.WithConcelierSnapshot("sha256:concelier")
.WithExcititorSnapshot("sha256:excititor")
.WithLatticePolicyHash("sha256:policy")
.Build()
};
}
private sealed class TestCryptoProfile : ICryptoProfile
{
public string KeyId => "test-key";
public string Algorithm => "hs256";
}
private sealed class TestDsseSigningService : IDsseSigningService
{
public Task<DsseEnvelope> SignAsync(object payload, string payloadType, ICryptoProfile cryptoProfile, CancellationToken cancellationToken = default)
{
var payloadBytes = CanonicalJson.SerializeToUtf8Bytes(payload);
var envelope = new DsseEnvelope(
PayloadType: payloadType,
Payload: Convert.ToBase64String(payloadBytes),
Signatures: [new DsseSignature(cryptoProfile.KeyId, "sig")]);
return Task.FromResult(envelope);
}
public Task<DsseVerificationOutcome> VerifyAsync(DsseEnvelope envelope, CancellationToken cancellationToken = default)
=> Task.FromResult(new DsseVerificationOutcome(true, true, null));
}
private sealed class NullScanMetadataRepository : IScanMetadataRepository
{
public Task<ScanMetadata?> GetScanMetadataAsync(string scanId, CancellationToken cancellationToken = default)
=> Task.FromResult<ScanMetadata?>(null);
}
}