chore(sprints): archive 20260226 advisories and expand deterministic tests
This commit is contained in:
@@ -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-----");
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user