feat: add security sink detection patterns for JavaScript/TypeScript

- Introduced `sink-detect.js` with various security sink detection patterns categorized by type (e.g., command injection, SQL injection, file operations).
- Implemented functions to build a lookup map for fast sink detection and to match sink calls against known patterns.
- Added `package-lock.json` for dependency management.
This commit is contained in:
StellaOps Bot
2025-12-22 23:21:21 +02:00
parent 3ba7157b00
commit 5146204f1b
529 changed files with 73579 additions and 5985 deletions

View File

@@ -9,8 +9,8 @@ using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugin.Ldap.Tests.Fakes;
using StellaOps.Authority.Plugin.Ldap.Tests.TestHelpers;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.Sessions;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Auth.Abstractions;
using Xunit;

View File

@@ -10,9 +10,9 @@ using StellaOps.Authority.Plugin.Ldap.Monitoring;
using StellaOps.Authority.Plugin.Ldap.Tests.TestHelpers;
using StellaOps.Authority.Plugin.Ldap.Tests.Fakes;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.Sessions;
using Xunit;
namespace StellaOps.Authority.Plugin.Ldap.Tests.Credentials;

View File

@@ -1,6 +1,6 @@
using System.Collections.Concurrent;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.Sessions;
using StellaOps.Authority.Storage.InMemory.Stores;
namespace StellaOps.Authority.Plugin.Ldap.Tests.TestHelpers;

View File

@@ -9,7 +9,7 @@ using StellaOps.Authority.InMemoryDriver;
using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugin.Ldap.Security;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Auth.Abstractions;

View File

@@ -11,7 +11,7 @@ using StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugin.Ldap.Monitoring;
using StellaOps.Authority.Plugin.Ldap.Security;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Cryptography.Audit;

View File

@@ -6,7 +6,7 @@ using System.Threading.Tasks;
using StellaOps.Authority.InMemoryDriver;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard.Storage;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.InMemory.Stores;
using Xunit;

View File

@@ -13,7 +13,7 @@ using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard;
using StellaOps.Authority.Plugin.Standard.Bootstrap;
using StellaOps.Authority.Plugin.Standard.Storage;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Cryptography.Audit;

View File

@@ -1,7 +1,7 @@
using System.Collections.Generic;
using System.Linq;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.InMemory.Stores;
namespace StellaOps.Authority.Plugin.Standard.Storage;

View File

@@ -1,7 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Authority.InMemoryDriver;
using StellaOps.Authority.Storage.InMemory.Initialization;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.Sessions;
using StellaOps.Authority.Storage.InMemory.Stores;
namespace StellaOps.Authority.Storage.Extensions;

View File

@@ -1,5 +1,5 @@
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.Sessions;
namespace StellaOps.Authority.Storage.InMemory.Stores;

View File

@@ -1,7 +1,7 @@
using System.Collections.Concurrent;
using System.Threading;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.Sessions;
namespace StellaOps.Authority.Storage.InMemory.Stores;

View File

@@ -9,8 +9,8 @@ using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Auth.Abstractions;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.Sessions;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Authority.Tests.Infrastructure;
using StellaOps.Configuration;

View File

@@ -13,8 +13,8 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Auth.Abstractions;
using StellaOps.Authority.Airgap;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.Sessions;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Authority.Tests.Infrastructure;
using Xunit;

View File

@@ -1,10 +1,10 @@
using System.Linq;
using Microsoft.Extensions.Logging;
using StellaOps.Authority.Audit;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Cryptography.Audit;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.Sessions;
namespace StellaOps.Authority.Tests.Audit;

View File

@@ -6,9 +6,9 @@ using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Authority.Bootstrap;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.Sessions;
using StellaOps.Cryptography.Audit;
using Xunit;

View File

@@ -17,9 +17,9 @@ using StellaOps.Auth.Abstractions;
using Microsoft.AspNetCore.Routing;
using StellaOps.Configuration;
using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.Sessions;
using StellaOps.Authority.Tests.Infrastructure;
using StellaOps.Cryptography.Audit;
using Xunit;

View File

@@ -11,7 +11,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Authority.Storage.InMemory.Extensions;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.Sessions;
using StellaOps.Authority.Storage.Postgres;
namespace StellaOps.Authority.Tests.Infrastructure;

View File

@@ -1,6 +1,6 @@
using System.Collections.Concurrent;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.Sessions;
using StellaOps.Authority.Storage.InMemory.Stores;
namespace StellaOps.Authority.Tests.Infrastructure;

View File

@@ -30,8 +30,8 @@ using StellaOps.Authority.Airgap;
using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.OpenIddict.Handlers;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.Sessions;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Authority.RateLimiting;
using StellaOps.Cryptography.Audit;

View File

@@ -23,9 +23,9 @@ using StellaOps.Authority.OpenIddict.Handlers;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.RateLimiting;
using StellaOps.Authority.Airgap;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.Sessions;
using StellaOps.Cryptography.Audit;
using StellaOps.Configuration;
using StellaOps.Auth.Abstractions;

View File

@@ -5,8 +5,8 @@ using Microsoft.Extensions.Time.Testing;
using OpenIddict.Abstractions;
using OpenIddict.Server;
using StellaOps.Authority.OpenIddict.Handlers;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.Sessions;
using StellaOps.Authority.Storage.InMemory.Stores;
using Xunit;

View File

@@ -1,7 +1,7 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.InMemory.Stores;
namespace StellaOps.Authority.Airgap;

View File

@@ -5,7 +5,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Cryptography.Audit;

View File

@@ -4,7 +4,7 @@ using System.Globalization;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Cryptography.Audit;
namespace StellaOps.Authority.Bootstrap;

View File

@@ -10,7 +10,7 @@ using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Authority.Console;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.InMemory.Stores;
namespace StellaOps.Authority.Observability;

View File

@@ -17,8 +17,8 @@ using StellaOps.Auth.Abstractions;
using StellaOps.Authority.Airgap;
using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.Sessions;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Authority.RateLimiting;
using StellaOps.Authority.Security;

View File

@@ -19,7 +19,7 @@ using StellaOps.Authority.OpenIddict;
using StellaOps.Auth.Abstractions;
using StellaOps.Authority.RateLimiting;
using StellaOps.Authority.Security;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Cryptography.Audit;

View File

@@ -15,7 +15,7 @@ using StellaOps.Authority.Airgap;
using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.RateLimiting;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Cryptography.Audit;

View File

@@ -11,7 +11,7 @@ using OpenIddict.Server;
using StellaOps.Auth.Abstractions;
using StellaOps.Authority.Airgap;
using StellaOps.Authority.Security;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.InMemory.Stores;
namespace StellaOps.Authority.OpenIddict.Handlers;

View File

@@ -6,7 +6,7 @@ using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using OpenIddict.Abstractions;
using OpenIddict.Server;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.Sessions;
using StellaOps.Authority.Storage.InMemory.Stores;
namespace StellaOps.Authority.OpenIddict.Handlers;

View File

@@ -11,8 +11,8 @@ using Microsoft.Extensions.Logging;
using OpenIddict.Abstractions;
using OpenIddict.Extensions;
using OpenIddict.Server;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.Sessions;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Auth.Abstractions;

View File

@@ -15,8 +15,8 @@ using StellaOps.Auth.Abstractions;
using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.RateLimiting;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.Sessions;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Cryptography.Audit;
using StellaOps.Authority.Security;

View File

@@ -32,9 +32,9 @@ using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugins;
using StellaOps.Authority.Bootstrap;
using StellaOps.Authority.Console;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.Sessions;
using StellaOps.Authority.Storage.Postgres;
using StellaOps.Authority.Storage.PostgresAdapters;
using StellaOps.Authority.RateLimiting;
@@ -54,7 +54,7 @@ using System.Text;
using StellaOps.Authority.Signing;
using StellaOps.Cryptography;
using StellaOps.Cryptography.Kms;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Security;
using StellaOps.Authority.OpenApi;
using StellaOps.Auth.Abstractions;

View File

@@ -10,7 +10,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Configuration;

View File

@@ -1,5 +1,5 @@
using System;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.Documents;
namespace StellaOps.Authority.Security;

View File

@@ -9,7 +9,7 @@ using System.Formats.Asn1;
using System.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Configuration;
using Microsoft.IdentityModel.Tokens;

View File

@@ -1,7 +1,7 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.Documents;
namespace StellaOps.Authority.Security;

View File

@@ -1,5 +1,5 @@
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.Sessions;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Authority.Storage.Postgres.Repositories;

View File

@@ -1,5 +1,5 @@
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.Sessions;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Authority.Storage.Postgres.Repositories;

View File

@@ -1,5 +1,5 @@
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.Sessions;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Authority.Storage.Postgres.Repositories;

View File

@@ -1,6 +1,6 @@
using System.Globalization;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.Sessions;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Authority.Storage.Postgres.Repositories;

View File

@@ -1,5 +1,5 @@
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.Sessions;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Authority.Storage.Postgres.Repositories;

View File

@@ -1,5 +1,5 @@
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.Sessions;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Authority.Storage.Postgres.Repositories;

View File

@@ -1,5 +1,5 @@
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.Sessions;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Authority.Storage.Postgres.Repositories;

View File

@@ -1,7 +1,7 @@
using System.Collections.Concurrent;
using System.Text.Json;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.Documents;
using StellaOps.Authority.Storage.Sessions;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Authority.Storage.Postgres.Repositories;

View File

@@ -0,0 +1,14 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Collections.Immutable" Version="9.0.3" />
<PackageReference Include="System.Text.Json" Version="9.0.4" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,81 @@
namespace StellaOps.Authority.Core.Verdicts;
/// <summary>
/// Interface for signing and verifying verdict manifests using DSSE.
/// </summary>
public interface IVerdictManifestSigner
{
/// <summary>
/// Sign a verdict manifest.
/// </summary>
/// <param name="manifest">The manifest to sign.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Signed manifest with signature data populated.</returns>
Task<VerdictManifest> SignAsync(VerdictManifest manifest, CancellationToken ct = default);
/// <summary>
/// Verify the signature on a verdict manifest.
/// </summary>
/// <param name="manifest">The manifest to verify.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Verification result.</returns>
Task<SignatureVerificationResult> VerifyAsync(VerdictManifest manifest, CancellationToken ct = default);
}
/// <summary>
/// Result of signature verification.
/// </summary>
public sealed record SignatureVerificationResult
{
/// <summary>True if signature is valid.</summary>
public required bool Valid { get; init; }
/// <summary>Key ID that signed the manifest.</summary>
public string? SigningKeyId { get; init; }
/// <summary>Signature algorithm used.</summary>
public string? Algorithm { get; init; }
/// <summary>Timestamp when signature was created.</summary>
public DateTimeOffset? SignedAt { get; init; }
/// <summary>Error message if verification failed.</summary>
public string? Error { get; init; }
/// <summary>Rekor transparency log verification status.</summary>
public RekorVerificationStatus? RekorStatus { get; init; }
}
/// <summary>
/// Rekor transparency log verification status.
/// </summary>
public sealed record RekorVerificationStatus
{
/// <summary>True if log entry was verified.</summary>
public required bool Verified { get; init; }
/// <summary>Log index in Rekor.</summary>
public long? LogIndex { get; init; }
/// <summary>Integrated time from Rekor.</summary>
public DateTimeOffset? IntegratedTime { get; init; }
/// <summary>Log ID.</summary>
public string? LogId { get; init; }
}
/// <summary>
/// Null implementation for environments where signing is disabled.
/// </summary>
public sealed class NullVerdictManifestSigner : IVerdictManifestSigner
{
public Task<VerdictManifest> SignAsync(VerdictManifest manifest, CancellationToken ct = default)
=> Task.FromResult(manifest);
public Task<SignatureVerificationResult> VerifyAsync(VerdictManifest manifest, CancellationToken ct = default)
=> Task.FromResult(new SignatureVerificationResult
{
Valid = true,
Error = "Signing disabled",
});
}

View File

@@ -0,0 +1,102 @@
using System.Collections.Immutable;
namespace StellaOps.Authority.Core.Verdicts;
/// <summary>
/// Repository interface for verdict manifest persistence.
/// </summary>
public interface IVerdictManifestStore
{
/// <summary>
/// Store a verdict manifest.
/// </summary>
/// <param name="manifest">The manifest to store.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The stored manifest.</returns>
Task<VerdictManifest> StoreAsync(VerdictManifest manifest, CancellationToken ct = default);
/// <summary>
/// Retrieve a manifest by its ID.
/// </summary>
/// <param name="tenant">Tenant identifier.</param>
/// <param name="manifestId">Manifest identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The manifest or null if not found.</returns>
Task<VerdictManifest?> GetByIdAsync(string tenant, string manifestId, CancellationToken ct = default);
/// <summary>
/// Retrieve the latest manifest for a specific asset and vulnerability.
/// </summary>
/// <param name="tenant">Tenant identifier.</param>
/// <param name="assetDigest">Asset digest.</param>
/// <param name="vulnerabilityId">Vulnerability identifier.</param>
/// <param name="policyHash">Optional policy hash filter.</param>
/// <param name="latticeVersion">Optional lattice version filter.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The latest matching manifest or null.</returns>
Task<VerdictManifest?> GetByScopeAsync(
string tenant,
string assetDigest,
string vulnerabilityId,
string? policyHash = null,
string? latticeVersion = null,
CancellationToken ct = default);
/// <summary>
/// List manifests by policy hash and lattice version.
/// </summary>
/// <param name="tenant">Tenant identifier.</param>
/// <param name="policyHash">Policy hash.</param>
/// <param name="latticeVersion">Lattice version.</param>
/// <param name="limit">Maximum results to return.</param>
/// <param name="pageToken">Continuation token for pagination.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of matching manifests.</returns>
Task<VerdictManifestPage> ListByPolicyAsync(
string tenant,
string policyHash,
string latticeVersion,
int limit = 100,
string? pageToken = null,
CancellationToken ct = default);
/// <summary>
/// List manifests for a specific asset.
/// </summary>
/// <param name="tenant">Tenant identifier.</param>
/// <param name="assetDigest">Asset digest.</param>
/// <param name="limit">Maximum results to return.</param>
/// <param name="pageToken">Continuation token for pagination.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of matching manifests.</returns>
Task<VerdictManifestPage> ListByAssetAsync(
string tenant,
string assetDigest,
int limit = 100,
string? pageToken = null,
CancellationToken ct = default);
/// <summary>
/// Delete a manifest by ID.
/// </summary>
/// <param name="tenant">Tenant identifier.</param>
/// <param name="manifestId">Manifest identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if deleted, false if not found.</returns>
Task<bool> DeleteAsync(string tenant, string manifestId, CancellationToken ct = default);
}
/// <summary>
/// Paginated result for manifest list queries.
/// </summary>
public sealed record VerdictManifestPage
{
/// <summary>Manifests in this page.</summary>
public required ImmutableArray<VerdictManifest> Manifests { get; init; }
/// <summary>Token for retrieving the next page, or null if no more pages.</summary>
public string? NextPageToken { get; init; }
/// <summary>Total count if available.</summary>
public int? TotalCount { get; init; }
}

View File

@@ -0,0 +1,155 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
namespace StellaOps.Authority.Core.Verdicts;
/// <summary>
/// In-memory implementation of verdict manifest store for testing and development.
/// </summary>
public sealed class InMemoryVerdictManifestStore : IVerdictManifestStore
{
private readonly ConcurrentDictionary<(string Tenant, string ManifestId), VerdictManifest> _manifests = new();
public Task<VerdictManifest> StoreAsync(VerdictManifest manifest, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(manifest);
var key = (manifest.Tenant, manifest.ManifestId);
_manifests[key] = manifest;
return Task.FromResult(manifest);
}
public Task<VerdictManifest?> GetByIdAsync(string tenant, string manifestId, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(manifestId);
var key = (tenant, manifestId);
return Task.FromResult(_manifests.TryGetValue(key, out var manifest) ? manifest : null);
}
public Task<VerdictManifest?> GetByScopeAsync(
string tenant,
string assetDigest,
string vulnerabilityId,
string? policyHash = null,
string? latticeVersion = null,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(assetDigest);
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId);
var query = _manifests.Values
.Where(m => m.Tenant == tenant
&& m.AssetDigest == assetDigest
&& m.VulnerabilityId.Equals(vulnerabilityId, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(policyHash))
{
query = query.Where(m => m.PolicyHash == policyHash);
}
if (!string.IsNullOrWhiteSpace(latticeVersion))
{
query = query.Where(m => m.LatticeVersion == latticeVersion);
}
var latest = query
.OrderByDescending(m => m.EvaluatedAt)
.FirstOrDefault();
return Task.FromResult(latest);
}
public Task<VerdictManifestPage> ListByPolicyAsync(
string tenant,
string policyHash,
string latticeVersion,
int limit = 100,
string? pageToken = null,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(policyHash);
ArgumentException.ThrowIfNullOrWhiteSpace(latticeVersion);
var offset = 0;
if (!string.IsNullOrWhiteSpace(pageToken) && int.TryParse(pageToken, out var parsed))
{
offset = parsed;
}
var query = _manifests.Values
.Where(m => m.Tenant == tenant
&& m.PolicyHash == policyHash
&& m.LatticeVersion == latticeVersion)
.OrderByDescending(m => m.EvaluatedAt)
.ThenBy(m => m.ManifestId, StringComparer.Ordinal)
.Skip(offset)
.Take(limit + 1)
.ToList();
var hasMore = query.Count > limit;
var manifests = query.Take(limit).ToImmutableArray();
return Task.FromResult(new VerdictManifestPage
{
Manifests = manifests,
NextPageToken = hasMore ? (offset + limit).ToString() : null,
});
}
public Task<VerdictManifestPage> ListByAssetAsync(
string tenant,
string assetDigest,
int limit = 100,
string? pageToken = null,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(assetDigest);
var offset = 0;
if (!string.IsNullOrWhiteSpace(pageToken) && int.TryParse(pageToken, out var parsed))
{
offset = parsed;
}
var query = _manifests.Values
.Where(m => m.Tenant == tenant && m.AssetDigest == assetDigest)
.OrderByDescending(m => m.EvaluatedAt)
.ThenBy(m => m.ManifestId, StringComparer.Ordinal)
.Skip(offset)
.Take(limit + 1)
.ToList();
var hasMore = query.Count > limit;
var manifests = query.Take(limit).ToImmutableArray();
return Task.FromResult(new VerdictManifestPage
{
Manifests = manifests,
NextPageToken = hasMore ? (offset + limit).ToString() : null,
});
}
public Task<bool> DeleteAsync(string tenant, string manifestId, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(manifestId);
var key = (tenant, manifestId);
return Task.FromResult(_manifests.TryRemove(key, out _));
}
/// <summary>
/// Clear all stored manifests (for testing).
/// </summary>
public void Clear() => _manifests.Clear();
/// <summary>
/// Get count of stored manifests (for testing).
/// </summary>
public int Count => _manifests.Count;
}

View File

@@ -0,0 +1,199 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Authority.Core.Verdicts;
/// <summary>
/// VEX verdict status enumeration per OpenVEX specification.
/// </summary>
public enum VexStatus
{
[JsonPropertyName("affected")]
Affected,
[JsonPropertyName("not_affected")]
NotAffected,
[JsonPropertyName("fixed")]
Fixed,
[JsonPropertyName("under_investigation")]
UnderInvestigation,
}
/// <summary>
/// Captures all inputs and outputs of a VEX verdict for deterministic replay.
/// </summary>
public sealed record VerdictManifest
{
/// <summary>Unique identifier for this manifest.</summary>
public required string ManifestId { get; init; }
/// <summary>Tenant that owns this verdict.</summary>
public required string Tenant { get; init; }
/// <summary>SHA256 digest of the asset being evaluated.</summary>
public required string AssetDigest { get; init; }
/// <summary>CVE or vulnerability identifier.</summary>
public required string VulnerabilityId { get; init; }
/// <summary>All inputs pinned for replay.</summary>
public required VerdictInputs Inputs { get; init; }
/// <summary>The computed verdict result.</summary>
public required VerdictResult Result { get; init; }
/// <summary>SHA256 hash of the policy document used.</summary>
public required string PolicyHash { get; init; }
/// <summary>Version of the trust lattice configuration.</summary>
public required string LatticeVersion { get; init; }
/// <summary>UTC timestamp when evaluation occurred.</summary>
public required DateTimeOffset EvaluatedAt { get; init; }
/// <summary>SHA256 digest of the canonical manifest payload.</summary>
public required string ManifestDigest { get; init; }
/// <summary>Optional DSSE signature bytes (base64 encoded).</summary>
public string? SignatureBase64 { get; init; }
/// <summary>Optional Rekor transparency log ID.</summary>
public string? RekorLogId { get; init; }
}
/// <summary>
/// All inputs required to replay a verdict deterministically.
/// </summary>
public sealed record VerdictInputs
{
/// <summary>SBOM digests used in evaluation.</summary>
public required ImmutableArray<string> SbomDigests { get; init; }
/// <summary>Vulnerability feed snapshot identifiers.</summary>
public required ImmutableArray<string> VulnFeedSnapshotIds { get; init; }
/// <summary>VEX document digests considered.</summary>
public required ImmutableArray<string> VexDocumentDigests { get; init; }
/// <summary>Reachability graph IDs if reachability analysis was used.</summary>
public required ImmutableArray<string> ReachabilityGraphIds { get; init; }
/// <summary>Clock cutoff for deterministic time-based evaluation.</summary>
public required DateTimeOffset ClockCutoff { get; init; }
}
/// <summary>
/// The computed verdict result with confidence and explanations.
/// </summary>
public sealed record VerdictResult
{
/// <summary>Final VEX status determination.</summary>
public required VexStatus Status { get; init; }
/// <summary>Confidence score [0, 1].</summary>
public required double Confidence { get; init; }
/// <summary>Detailed explanations from contributing VEX sources.</summary>
public required ImmutableArray<VerdictExplanation> Explanations { get; init; }
/// <summary>References to supporting evidence.</summary>
public required ImmutableArray<string> EvidenceRefs { get; init; }
/// <summary>True if conflicting claims were detected.</summary>
public bool HasConflicts { get; init; }
/// <summary>True if reachability proof was required and present.</summary>
public bool RequiresReplayProof { get; init; }
}
/// <summary>
/// Explanation of how a single VEX source contributed to the verdict.
/// </summary>
public sealed record VerdictExplanation
{
/// <summary>Identifier of the VEX source.</summary>
public required string SourceId { get; init; }
/// <summary>Human-readable reason for this contribution.</summary>
public required string Reason { get; init; }
/// <summary>Provenance score component [0, 1].</summary>
public required double ProvenanceScore { get; init; }
/// <summary>Coverage score component [0, 1].</summary>
public required double CoverageScore { get; init; }
/// <summary>Replayability score component [0, 1].</summary>
public required double ReplayabilityScore { get; init; }
/// <summary>Claim strength multiplier.</summary>
public required double StrengthMultiplier { get; init; }
/// <summary>Freshness decay multiplier.</summary>
public required double FreshnessMultiplier { get; init; }
/// <summary>Final computed claim score.</summary>
public required double ClaimScore { get; init; }
/// <summary>VEX status this source asserted.</summary>
public required VexStatus AssertedStatus { get; init; }
/// <summary>True if this source's claim was accepted as the winner.</summary>
public bool Accepted { get; init; }
}
/// <summary>
/// Serialization helper for canonical JSON output.
/// </summary>
public static class VerdictManifestSerializer
{
private static readonly JsonSerializerOptions s_options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) },
};
/// <summary>
/// Serialize manifest to canonical JSON (sorted keys, no indentation).
/// </summary>
public static string Serialize(VerdictManifest manifest)
{
ArgumentNullException.ThrowIfNull(manifest);
return JsonSerializer.Serialize(manifest, s_options);
}
/// <summary>
/// Deserialize from JSON.
/// </summary>
public static VerdictManifest? Deserialize(string json)
{
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
return JsonSerializer.Deserialize<VerdictManifest>(json, s_options);
}
/// <summary>
/// Compute SHA256 digest of the canonical JSON representation.
/// </summary>
public static string ComputeDigest(VerdictManifest manifest)
{
ArgumentNullException.ThrowIfNull(manifest);
// Create a copy without the digest field for hashing
var forHashing = manifest with { ManifestDigest = string.Empty, SignatureBase64 = null, RekorLogId = null };
var json = Serialize(forHashing);
var bytes = Encoding.UTF8.GetBytes(json);
var hash = SHA256.HashData(bytes);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}

View File

@@ -0,0 +1,219 @@
using System.Collections.Immutable;
namespace StellaOps.Authority.Core.Verdicts;
/// <summary>
/// Fluent builder for constructing VerdictManifest instances with deterministic ordering.
/// </summary>
public sealed class VerdictManifestBuilder
{
private string? _tenant;
private string? _assetDigest;
private string? _vulnerabilityId;
private VerdictInputs? _inputs;
private VerdictResult? _result;
private string? _policyHash;
private string? _latticeVersion;
private DateTimeOffset _evaluatedAt = DateTimeOffset.UtcNow;
private readonly Func<string> _idGenerator;
public VerdictManifestBuilder()
: this(() => Guid.NewGuid().ToString("n"))
{
}
public VerdictManifestBuilder(Func<string> idGenerator)
{
_idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
}
public VerdictManifestBuilder WithTenant(string tenant)
{
if (string.IsNullOrWhiteSpace(tenant))
{
throw new ArgumentException("Tenant must be provided.", nameof(tenant));
}
_tenant = tenant.Trim();
return this;
}
public VerdictManifestBuilder WithAsset(string assetDigest, string vulnerabilityId)
{
if (string.IsNullOrWhiteSpace(assetDigest))
{
throw new ArgumentException("Asset digest must be provided.", nameof(assetDigest));
}
if (string.IsNullOrWhiteSpace(vulnerabilityId))
{
throw new ArgumentException("Vulnerability ID must be provided.", nameof(vulnerabilityId));
}
_assetDigest = assetDigest.Trim();
_vulnerabilityId = vulnerabilityId.Trim().ToUpperInvariant();
return this;
}
public VerdictManifestBuilder WithInputs(VerdictInputs inputs)
{
_inputs = inputs ?? throw new ArgumentNullException(nameof(inputs));
return this;
}
public VerdictManifestBuilder WithInputs(
IEnumerable<string> sbomDigests,
IEnumerable<string> vulnFeedSnapshotIds,
IEnumerable<string> vexDocumentDigests,
IEnumerable<string>? reachabilityGraphIds = null,
DateTimeOffset? clockCutoff = null)
{
_inputs = new VerdictInputs
{
SbomDigests = SortedImmutable(sbomDigests),
VulnFeedSnapshotIds = SortedImmutable(vulnFeedSnapshotIds),
VexDocumentDigests = SortedImmutable(vexDocumentDigests),
ReachabilityGraphIds = SortedImmutable(reachabilityGraphIds ?? Enumerable.Empty<string>()),
ClockCutoff = clockCutoff ?? DateTimeOffset.UtcNow,
};
return this;
}
public VerdictManifestBuilder WithResult(VerdictResult result)
{
_result = result ?? throw new ArgumentNullException(nameof(result));
return this;
}
public VerdictManifestBuilder WithResult(
VexStatus status,
double confidence,
IEnumerable<VerdictExplanation> explanations,
IEnumerable<string>? evidenceRefs = null,
bool hasConflicts = false,
bool requiresReplayProof = false)
{
if (confidence < 0 || confidence > 1)
{
throw new ArgumentOutOfRangeException(nameof(confidence), "Confidence must be between 0 and 1.");
}
// Sort explanations deterministically by source ID
var sortedExplanations = explanations
.OrderByDescending(e => e.ClaimScore)
.ThenByDescending(e => e.ProvenanceScore)
.ThenBy(e => e.SourceId, StringComparer.Ordinal)
.ToImmutableArray();
_result = new VerdictResult
{
Status = status,
Confidence = confidence,
Explanations = sortedExplanations,
EvidenceRefs = SortedImmutable(evidenceRefs ?? Enumerable.Empty<string>()),
HasConflicts = hasConflicts,
RequiresReplayProof = requiresReplayProof,
};
return this;
}
public VerdictManifestBuilder WithPolicy(string policyHash, string latticeVersion)
{
if (string.IsNullOrWhiteSpace(policyHash))
{
throw new ArgumentException("Policy hash must be provided.", nameof(policyHash));
}
if (string.IsNullOrWhiteSpace(latticeVersion))
{
throw new ArgumentException("Lattice version must be provided.", nameof(latticeVersion));
}
_policyHash = policyHash.Trim();
_latticeVersion = latticeVersion.Trim();
return this;
}
public VerdictManifestBuilder WithClock(DateTimeOffset evaluatedAt)
{
_evaluatedAt = evaluatedAt.ToUniversalTime();
return this;
}
public VerdictManifest Build()
{
Validate();
var manifestId = _idGenerator();
var manifest = new VerdictManifest
{
ManifestId = manifestId,
Tenant = _tenant!,
AssetDigest = _assetDigest!,
VulnerabilityId = _vulnerabilityId!,
Inputs = _inputs!,
Result = _result!,
PolicyHash = _policyHash!,
LatticeVersion = _latticeVersion!,
EvaluatedAt = _evaluatedAt,
ManifestDigest = string.Empty, // Will be computed
};
// Compute digest over the complete manifest
var digest = VerdictManifestSerializer.ComputeDigest(manifest);
return manifest with { ManifestDigest = digest };
}
private void Validate()
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(_tenant))
{
errors.Add("Tenant is required.");
}
if (string.IsNullOrWhiteSpace(_assetDigest))
{
errors.Add("Asset digest is required.");
}
if (string.IsNullOrWhiteSpace(_vulnerabilityId))
{
errors.Add("Vulnerability ID is required.");
}
if (_inputs is null)
{
errors.Add("Inputs are required.");
}
if (_result is null)
{
errors.Add("Result is required.");
}
if (string.IsNullOrWhiteSpace(_policyHash))
{
errors.Add("Policy hash is required.");
}
if (string.IsNullOrWhiteSpace(_latticeVersion))
{
errors.Add("Lattice version is required.");
}
if (errors.Count > 0)
{
throw new InvalidOperationException($"VerdictManifest validation failed: {string.Join("; ", errors)}");
}
}
private static ImmutableArray<string> SortedImmutable(IEnumerable<string> items)
=> items
.Where(s => !string.IsNullOrWhiteSpace(s))
.Select(s => s.Trim())
.OrderBy(s => s, StringComparer.Ordinal)
.Distinct(StringComparer.Ordinal)
.ToImmutableArray();
}

View File

@@ -0,0 +1,240 @@
using System.Collections.Immutable;
namespace StellaOps.Authority.Core.Verdicts;
/// <summary>
/// Result of replay verification.
/// </summary>
public sealed record ReplayVerificationResult
{
/// <summary>True if replay produced identical results.</summary>
public required bool Success { get; init; }
/// <summary>The original manifest being verified.</summary>
public required VerdictManifest OriginalManifest { get; init; }
/// <summary>The manifest produced by replay (if successful).</summary>
public VerdictManifest? ReplayedManifest { get; init; }
/// <summary>List of differences between original and replayed manifests.</summary>
public ImmutableArray<string>? Differences { get; init; }
/// <summary>True if signature verification passed.</summary>
public bool SignatureValid { get; init; }
/// <summary>Error message if replay failed.</summary>
public string? Error { get; init; }
/// <summary>Duration of the replay operation.</summary>
public TimeSpan? ReplayDuration { get; init; }
}
/// <summary>
/// Interface for replaying verdicts to verify determinism.
/// </summary>
public interface IVerdictReplayVerifier
{
/// <summary>
/// Verify that a verdict can be replayed to produce identical results.
/// </summary>
/// <param name="manifestId">Manifest ID to verify.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Verification result with differences if any.</returns>
Task<ReplayVerificationResult> VerifyAsync(string manifestId, CancellationToken ct = default);
/// <summary>
/// Verify that a verdict can be replayed to produce identical results.
/// </summary>
/// <param name="manifest">Manifest to verify.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Verification result with differences if any.</returns>
Task<ReplayVerificationResult> VerifyAsync(VerdictManifest manifest, CancellationToken ct = default);
}
/// <summary>
/// Provides verdict evaluation capability for replay verification.
/// </summary>
public interface IVerdictEvaluator
{
/// <summary>
/// Evaluate a verdict using the specified inputs and policy context.
/// </summary>
/// <param name="tenant">Tenant identifier.</param>
/// <param name="assetDigest">Asset being evaluated.</param>
/// <param name="vulnerabilityId">Vulnerability being evaluated.</param>
/// <param name="inputs">Pinned inputs for evaluation.</param>
/// <param name="policyHash">Policy hash to use.</param>
/// <param name="latticeVersion">Lattice version to use.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Verdict result.</returns>
Task<VerdictResult> EvaluateAsync(
string tenant,
string assetDigest,
string vulnerabilityId,
VerdictInputs inputs,
string policyHash,
string latticeVersion,
CancellationToken ct = default);
}
/// <summary>
/// Default implementation of verdict replay verifier.
/// </summary>
public sealed class VerdictReplayVerifier : IVerdictReplayVerifier
{
private readonly IVerdictManifestStore _store;
private readonly IVerdictManifestSigner _signer;
private readonly IVerdictEvaluator _evaluator;
public VerdictReplayVerifier(
IVerdictManifestStore store,
IVerdictManifestSigner signer,
IVerdictEvaluator evaluator)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_signer = signer ?? throw new ArgumentNullException(nameof(signer));
_evaluator = evaluator ?? throw new ArgumentNullException(nameof(evaluator));
}
public async Task<ReplayVerificationResult> VerifyAsync(string manifestId, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(manifestId);
// We need to find the manifest - this requires a search across tenants
// In practice, the caller should provide the tenant or the manifest directly
return new ReplayVerificationResult
{
Success = false,
OriginalManifest = null!,
Error = "Use VerifyAsync(VerdictManifest) overload with the full manifest.",
};
}
public async Task<ReplayVerificationResult> VerifyAsync(VerdictManifest manifest, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(manifest);
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
try
{
// Verify signature first if present
var signatureValid = true;
if (!string.IsNullOrWhiteSpace(manifest.SignatureBase64))
{
var sigResult = await _signer.VerifyAsync(manifest, ct).ConfigureAwait(false);
signatureValid = sigResult.Valid;
if (!signatureValid)
{
return new ReplayVerificationResult
{
Success = false,
OriginalManifest = manifest,
SignatureValid = false,
Error = $"Signature verification failed: {sigResult.Error}",
ReplayDuration = stopwatch.Elapsed,
};
}
}
// Re-evaluate using pinned inputs
var replayedResult = await _evaluator.EvaluateAsync(
manifest.Tenant,
manifest.AssetDigest,
manifest.VulnerabilityId,
manifest.Inputs,
manifest.PolicyHash,
manifest.LatticeVersion,
ct).ConfigureAwait(false);
// Build replayed manifest
var replayedManifest = new VerdictManifestBuilder(() => manifest.ManifestId)
.WithTenant(manifest.Tenant)
.WithAsset(manifest.AssetDigest, manifest.VulnerabilityId)
.WithInputs(manifest.Inputs)
.WithResult(replayedResult)
.WithPolicy(manifest.PolicyHash, manifest.LatticeVersion)
.WithClock(manifest.Inputs.ClockCutoff)
.Build();
// Compare results
var differences = CompareManifests(manifest, replayedManifest);
var success = differences.Length == 0;
stopwatch.Stop();
return new ReplayVerificationResult
{
Success = success,
OriginalManifest = manifest,
ReplayedManifest = replayedManifest,
Differences = differences,
SignatureValid = signatureValid,
Error = success ? null : "Replay produced different results",
ReplayDuration = stopwatch.Elapsed,
};
}
catch (Exception ex)
{
stopwatch.Stop();
return new ReplayVerificationResult
{
Success = false,
OriginalManifest = manifest,
Error = $"Replay failed: {ex.Message}",
ReplayDuration = stopwatch.Elapsed,
};
}
}
private static ImmutableArray<string> CompareManifests(VerdictManifest original, VerdictManifest replayed)
{
var diffs = new List<string>();
if (original.Result.Status != replayed.Result.Status)
{
diffs.Add($"Status: {original.Result.Status} vs {replayed.Result.Status}");
}
if (Math.Abs(original.Result.Confidence - replayed.Result.Confidence) > 0.0001)
{
diffs.Add($"Confidence: {original.Result.Confidence:F4} vs {replayed.Result.Confidence:F4}");
}
if (original.Result.HasConflicts != replayed.Result.HasConflicts)
{
diffs.Add($"HasConflicts: {original.Result.HasConflicts} vs {replayed.Result.HasConflicts}");
}
if (original.Result.Explanations.Length != replayed.Result.Explanations.Length)
{
diffs.Add($"Explanations count: {original.Result.Explanations.Length} vs {replayed.Result.Explanations.Length}");
}
else
{
for (var i = 0; i < original.Result.Explanations.Length; i++)
{
var origExp = original.Result.Explanations[i];
var repExp = replayed.Result.Explanations[i];
if (origExp.SourceId != repExp.SourceId)
{
diffs.Add($"Explanation[{i}].SourceId: {origExp.SourceId} vs {repExp.SourceId}");
}
if (Math.Abs(origExp.ClaimScore - repExp.ClaimScore) > 0.0001)
{
diffs.Add($"Explanation[{i}].ClaimScore: {origExp.ClaimScore:F4} vs {repExp.ClaimScore:F4}");
}
}
}
// Compare manifest digest (computed from result)
if (original.ManifestDigest != replayed.ManifestDigest)
{
diffs.Add($"ManifestDigest: {original.ManifestDigest} vs {replayed.ManifestDigest}");
}
return diffs.ToImmutableArray();
}
}

View File

@@ -0,0 +1,84 @@
-- Verdict Manifest Schema for VEX Trust Lattice
-- Sprint: 7100.0001.0002
-- Create schema if not exists
CREATE SCHEMA IF NOT EXISTS authority;
-- Verdict manifests table
CREATE TABLE IF NOT EXISTS authority.verdict_manifests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
manifest_id TEXT NOT NULL,
tenant TEXT NOT NULL,
-- Scope
asset_digest TEXT NOT NULL,
vulnerability_id TEXT NOT NULL,
-- Inputs (JSONB for flexibility and schema evolution)
inputs_json JSONB NOT NULL,
-- Result
status TEXT NOT NULL CHECK (status IN ('affected', 'not_affected', 'fixed', 'under_investigation')),
confidence DOUBLE PRECISION NOT NULL CHECK (confidence >= 0 AND confidence <= 1),
result_json JSONB NOT NULL,
-- Policy context
policy_hash TEXT NOT NULL,
lattice_version TEXT NOT NULL,
-- Metadata
evaluated_at TIMESTAMPTZ NOT NULL,
manifest_digest TEXT NOT NULL,
-- Signature
signature_base64 TEXT,
rekor_log_id TEXT,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Uniqueness constraints
CONSTRAINT uq_verdict_manifest_id UNIQUE (tenant, manifest_id)
);
-- Primary lookup: asset + CVE
CREATE INDEX IF NOT EXISTS idx_verdict_asset_vuln
ON authority.verdict_manifests(tenant, asset_digest, vulnerability_id);
-- Replay queries: same policy + lattice
CREATE INDEX IF NOT EXISTS idx_verdict_policy
ON authority.verdict_manifests(tenant, policy_hash, lattice_version);
-- Time-based queries (BRIN for append-mostly workload)
CREATE INDEX IF NOT EXISTS idx_verdict_time
ON authority.verdict_manifests USING BRIN (evaluated_at);
-- Composite for deterministic replay lookup
CREATE UNIQUE INDEX IF NOT EXISTS idx_verdict_replay
ON authority.verdict_manifests(
tenant, asset_digest, vulnerability_id, policy_hash, lattice_version
);
-- Index for digest lookups (verification)
CREATE INDEX IF NOT EXISTS idx_verdict_digest
ON authority.verdict_manifests(manifest_digest);
-- Row-level security
ALTER TABLE authority.verdict_manifests ENABLE ROW LEVEL SECURITY;
-- RLS policy for tenant isolation
CREATE POLICY verdict_tenant_isolation ON authority.verdict_manifests
USING (tenant = current_setting('app.current_tenant', true))
WITH CHECK (tenant = current_setting('app.current_tenant', true));
-- Grant permissions
GRANT SELECT, INSERT, UPDATE, DELETE ON authority.verdict_manifests TO stellaops_app;
GRANT USAGE ON SCHEMA authority TO stellaops_app;
COMMENT ON TABLE authority.verdict_manifests IS 'VEX verdict manifests for deterministic replay verification';
COMMENT ON COLUMN authority.verdict_manifests.manifest_id IS 'Unique manifest identifier';
COMMENT ON COLUMN authority.verdict_manifests.inputs_json IS 'JSONB containing VerdictInputs (SBOM digests, VEX docs, etc.)';
COMMENT ON COLUMN authority.verdict_manifests.result_json IS 'JSONB containing VerdictResult with explanations';
COMMENT ON COLUMN authority.verdict_manifests.policy_hash IS 'SHA256 hash of the policy document used';
COMMENT ON COLUMN authority.verdict_manifests.lattice_version IS 'Version of trust lattice configuration';
COMMENT ON COLUMN authority.verdict_manifests.manifest_digest IS 'SHA256 digest of canonical manifest for integrity';

View File

@@ -16,6 +16,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Core\StellaOps.Authority.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,335 @@
using System.Collections.Immutable;
using System.Text.Json;
using Npgsql;
using StellaOps.Authority.Core.Verdicts;
namespace StellaOps.Authority.Storage.Postgres;
/// <summary>
/// PostgreSQL implementation of verdict manifest store.
/// </summary>
public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
{
private readonly NpgsqlDataSource _dataSource;
private static readonly JsonSerializerOptions s_jsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = false,
};
public PostgresVerdictManifestStore(NpgsqlDataSource dataSource)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
}
public async Task<VerdictManifest> StoreAsync(VerdictManifest manifest, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(manifest);
const string sql = """
INSERT INTO authority.verdict_manifests (
manifest_id, tenant, asset_digest, vulnerability_id,
inputs_json, status, confidence, result_json,
policy_hash, lattice_version, evaluated_at, manifest_digest,
signature_base64, rekor_log_id
) VALUES (
@manifestId, @tenant, @assetDigest, @vulnerabilityId,
@inputsJson::jsonb, @status, @confidence, @resultJson::jsonb,
@policyHash, @latticeVersion, @evaluatedAt, @manifestDigest,
@signatureBase64, @rekorLogId
)
ON CONFLICT (tenant, asset_digest, vulnerability_id, policy_hash, lattice_version)
DO UPDATE SET
manifest_id = EXCLUDED.manifest_id,
inputs_json = EXCLUDED.inputs_json,
status = EXCLUDED.status,
confidence = EXCLUDED.confidence,
result_json = EXCLUDED.result_json,
evaluated_at = EXCLUDED.evaluated_at,
manifest_digest = EXCLUDED.manifest_digest,
signature_base64 = EXCLUDED.signature_base64,
rekor_log_id = EXCLUDED.rekor_log_id
""";
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("manifestId", manifest.ManifestId);
cmd.Parameters.AddWithValue("tenant", manifest.Tenant);
cmd.Parameters.AddWithValue("assetDigest", manifest.AssetDigest);
cmd.Parameters.AddWithValue("vulnerabilityId", manifest.VulnerabilityId);
cmd.Parameters.AddWithValue("inputsJson", JsonSerializer.Serialize(manifest.Inputs, s_jsonOptions));
cmd.Parameters.AddWithValue("status", StatusToString(manifest.Result.Status));
cmd.Parameters.AddWithValue("confidence", manifest.Result.Confidence);
cmd.Parameters.AddWithValue("resultJson", JsonSerializer.Serialize(manifest.Result, s_jsonOptions));
cmd.Parameters.AddWithValue("policyHash", manifest.PolicyHash);
cmd.Parameters.AddWithValue("latticeVersion", manifest.LatticeVersion);
cmd.Parameters.AddWithValue("evaluatedAt", manifest.EvaluatedAt);
cmd.Parameters.AddWithValue("manifestDigest", manifest.ManifestDigest);
cmd.Parameters.AddWithValue("signatureBase64", (object?)manifest.SignatureBase64 ?? DBNull.Value);
cmd.Parameters.AddWithValue("rekorLogId", (object?)manifest.RekorLogId ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
return manifest;
}
public async Task<VerdictManifest?> GetByIdAsync(string tenant, string manifestId, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(manifestId);
const string sql = """
SELECT manifest_id, tenant, asset_digest, vulnerability_id,
inputs_json, status, confidence, result_json,
policy_hash, lattice_version, evaluated_at, manifest_digest,
signature_base64, rekor_log_id
FROM authority.verdict_manifests
WHERE tenant = @tenant AND manifest_id = @manifestId
""";
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("tenant", tenant);
cmd.Parameters.AddWithValue("manifestId", manifestId);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
if (await reader.ReadAsync(ct).ConfigureAwait(false))
{
return MapFromReader(reader);
}
return null;
}
public async Task<VerdictManifest?> GetByScopeAsync(
string tenant,
string assetDigest,
string vulnerabilityId,
string? policyHash = null,
string? latticeVersion = null,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(assetDigest);
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId);
var sql = """
SELECT manifest_id, tenant, asset_digest, vulnerability_id,
inputs_json, status, confidence, result_json,
policy_hash, lattice_version, evaluated_at, manifest_digest,
signature_base64, rekor_log_id
FROM authority.verdict_manifests
WHERE tenant = @tenant
AND asset_digest = @assetDigest
AND vulnerability_id = @vulnerabilityId
""";
if (!string.IsNullOrWhiteSpace(policyHash))
{
sql += " AND policy_hash = @policyHash";
}
if (!string.IsNullOrWhiteSpace(latticeVersion))
{
sql += " AND lattice_version = @latticeVersion";
}
sql += " ORDER BY evaluated_at DESC LIMIT 1";
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("tenant", tenant);
cmd.Parameters.AddWithValue("assetDigest", assetDigest);
cmd.Parameters.AddWithValue("vulnerabilityId", vulnerabilityId);
if (!string.IsNullOrWhiteSpace(policyHash))
{
cmd.Parameters.AddWithValue("policyHash", policyHash);
}
if (!string.IsNullOrWhiteSpace(latticeVersion))
{
cmd.Parameters.AddWithValue("latticeVersion", latticeVersion);
}
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
if (await reader.ReadAsync(ct).ConfigureAwait(false))
{
return MapFromReader(reader);
}
return null;
}
public async Task<VerdictManifestPage> ListByPolicyAsync(
string tenant,
string policyHash,
string latticeVersion,
int limit = 100,
string? pageToken = null,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(policyHash);
ArgumentException.ThrowIfNullOrWhiteSpace(latticeVersion);
var offset = ParsePageToken(pageToken);
limit = Math.Clamp(limit, 1, 1000);
const string sql = """
SELECT manifest_id, tenant, asset_digest, vulnerability_id,
inputs_json, status, confidence, result_json,
policy_hash, lattice_version, evaluated_at, manifest_digest,
signature_base64, rekor_log_id
FROM authority.verdict_manifests
WHERE tenant = @tenant
AND policy_hash = @policyHash
AND lattice_version = @latticeVersion
ORDER BY evaluated_at DESC, manifest_id
LIMIT @limit OFFSET @offset
""";
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("tenant", tenant);
cmd.Parameters.AddWithValue("policyHash", policyHash);
cmd.Parameters.AddWithValue("latticeVersion", latticeVersion);
cmd.Parameters.AddWithValue("limit", limit + 1);
cmd.Parameters.AddWithValue("offset", offset);
var manifests = new List<VerdictManifest>();
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
manifests.Add(MapFromReader(reader));
}
var hasMore = manifests.Count > limit;
if (hasMore)
{
manifests.RemoveAt(manifests.Count - 1);
}
return new VerdictManifestPage
{
Manifests = manifests.ToImmutableArray(),
NextPageToken = hasMore ? (offset + limit).ToString() : null,
};
}
public async Task<VerdictManifestPage> ListByAssetAsync(
string tenant,
string assetDigest,
int limit = 100,
string? pageToken = null,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(assetDigest);
var offset = ParsePageToken(pageToken);
limit = Math.Clamp(limit, 1, 1000);
const string sql = """
SELECT manifest_id, tenant, asset_digest, vulnerability_id,
inputs_json, status, confidence, result_json,
policy_hash, lattice_version, evaluated_at, manifest_digest,
signature_base64, rekor_log_id
FROM authority.verdict_manifests
WHERE tenant = @tenant AND asset_digest = @assetDigest
ORDER BY evaluated_at DESC, manifest_id
LIMIT @limit OFFSET @offset
""";
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("tenant", tenant);
cmd.Parameters.AddWithValue("assetDigest", assetDigest);
cmd.Parameters.AddWithValue("limit", limit + 1);
cmd.Parameters.AddWithValue("offset", offset);
var manifests = new List<VerdictManifest>();
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
manifests.Add(MapFromReader(reader));
}
var hasMore = manifests.Count > limit;
if (hasMore)
{
manifests.RemoveAt(manifests.Count - 1);
}
return new VerdictManifestPage
{
Manifests = manifests.ToImmutableArray(),
NextPageToken = hasMore ? (offset + limit).ToString() : null,
};
}
public async Task<bool> DeleteAsync(string tenant, string manifestId, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(manifestId);
const string sql = """
DELETE FROM authority.verdict_manifests
WHERE tenant = @tenant AND manifest_id = @manifestId
""";
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("tenant", tenant);
cmd.Parameters.AddWithValue("manifestId", manifestId);
var rows = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
return rows > 0;
}
private static VerdictManifest MapFromReader(NpgsqlDataReader reader)
{
var inputsJson = reader.GetString(4);
var resultJson = reader.GetString(7);
var inputs = JsonSerializer.Deserialize<VerdictInputs>(inputsJson, s_jsonOptions)
?? throw new InvalidOperationException("Failed to deserialize inputs");
var result = JsonSerializer.Deserialize<VerdictResult>(resultJson, s_jsonOptions)
?? throw new InvalidOperationException("Failed to deserialize result");
return new VerdictManifest
{
ManifestId = reader.GetString(0),
Tenant = reader.GetString(1),
AssetDigest = reader.GetString(2),
VulnerabilityId = reader.GetString(3),
Inputs = inputs,
Result = result,
PolicyHash = reader.GetString(8),
LatticeVersion = reader.GetString(9),
EvaluatedAt = reader.GetDateTime(10),
ManifestDigest = reader.GetString(11),
SignatureBase64 = reader.IsDBNull(12) ? null : reader.GetString(12),
RekorLogId = reader.IsDBNull(13) ? null : reader.GetString(13),
};
}
private static string StatusToString(VexStatus status) => status switch
{
VexStatus.Affected => "affected",
VexStatus.NotAffected => "not_affected",
VexStatus.Fixed => "fixed",
VexStatus.UnderInvestigation => "under_investigation",
_ => "affected",
};
private static int ParsePageToken(string? pageToken)
{
if (string.IsNullOrWhiteSpace(pageToken))
{
return 0;
}
return int.TryParse(pageToken, out var offset) ? Math.Max(0, offset) : 0;
}
}

View File

@@ -0,0 +1,25 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="8.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Authority.Core/StellaOps.Authority.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,155 @@
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Authority.Core.Verdicts;
using Xunit;
namespace StellaOps.Authority.Core.Tests.Verdicts;
public sealed class InMemoryVerdictManifestStoreTests
{
private readonly InMemoryVerdictManifestStore _store = new();
[Fact]
public async Task StoreAndRetrieve_ByManifestId()
{
var manifest = CreateManifest("manifest-1", "tenant-1");
await _store.StoreAsync(manifest);
var retrieved = await _store.GetByIdAsync("tenant-1", "manifest-1");
retrieved.Should().NotBeNull();
retrieved!.ManifestId.Should().Be("manifest-1");
retrieved.Tenant.Should().Be("tenant-1");
}
[Fact]
public async Task GetByScope_ReturnsLatest()
{
var older = CreateManifest("m1", "t", evaluatedAt: DateTimeOffset.Parse("2025-01-01T00:00:00Z"));
var newer = CreateManifest("m2", "t", evaluatedAt: DateTimeOffset.Parse("2025-01-02T00:00:00Z"));
await _store.StoreAsync(older);
await _store.StoreAsync(newer);
var result = await _store.GetByScopeAsync("t", "sha256:asset", "CVE-2024-1234");
result.Should().NotBeNull();
result!.ManifestId.Should().Be("m2");
}
[Fact]
public async Task GetByScope_FiltersOnPolicyAndLattice()
{
var m1 = CreateManifest("m1", "t", policyHash: "p1", latticeVersion: "v1");
var m2 = CreateManifest("m2", "t", policyHash: "p2", latticeVersion: "v1");
await _store.StoreAsync(m1);
await _store.StoreAsync(m2);
var result = await _store.GetByScopeAsync("t", "sha256:asset", "CVE-2024-1234", policyHash: "p1");
result.Should().NotBeNull();
result!.ManifestId.Should().Be("m1");
}
[Fact]
public async Task ListByPolicy_Paginates()
{
for (var i = 0; i < 5; i++)
{
var manifest = CreateManifest($"m{i}", "t", policyHash: "p1", latticeVersion: "v1",
evaluatedAt: DateTimeOffset.UtcNow.AddMinutes(-i));
await _store.StoreAsync(manifest);
}
var page1 = await _store.ListByPolicyAsync("t", "p1", "v1", limit: 2);
page1.Manifests.Should().HaveCount(2);
page1.NextPageToken.Should().NotBeNull();
var page2 = await _store.ListByPolicyAsync("t", "p1", "v1", limit: 2, pageToken: page1.NextPageToken);
page2.Manifests.Should().HaveCount(2);
page2.NextPageToken.Should().NotBeNull();
var page3 = await _store.ListByPolicyAsync("t", "p1", "v1", limit: 2, pageToken: page2.NextPageToken);
page3.Manifests.Should().HaveCount(1);
page3.NextPageToken.Should().BeNull();
}
[Fact]
public async Task Delete_RemovesManifest()
{
var manifest = CreateManifest("m1", "t");
await _store.StoreAsync(manifest);
var deleted = await _store.DeleteAsync("t", "m1");
deleted.Should().BeTrue();
var retrieved = await _store.GetByIdAsync("t", "m1");
retrieved.Should().BeNull();
}
[Fact]
public async Task Delete_ReturnsFalseWhenNotFound()
{
var deleted = await _store.DeleteAsync("t", "nonexistent");
deleted.Should().BeFalse();
}
[Fact]
public async Task TenantIsolation_Works()
{
var m1 = CreateManifest("shared-id", "tenant-a");
var m2 = CreateManifest("shared-id", "tenant-b");
await _store.StoreAsync(m1);
await _store.StoreAsync(m2);
var fromA = await _store.GetByIdAsync("tenant-a", "shared-id");
var fromB = await _store.GetByIdAsync("tenant-b", "shared-id");
fromA.Should().NotBeNull();
fromB.Should().NotBeNull();
fromA!.Tenant.Should().Be("tenant-a");
fromB!.Tenant.Should().Be("tenant-b");
_store.Count.Should().Be(2);
}
private static VerdictManifest CreateManifest(
string manifestId,
string tenant,
string assetDigest = "sha256:asset",
string vulnerabilityId = "CVE-2024-1234",
string policyHash = "sha256:policy",
string latticeVersion = "1.0.0",
DateTimeOffset? evaluatedAt = null)
{
return new VerdictManifest
{
ManifestId = manifestId,
Tenant = tenant,
AssetDigest = assetDigest,
VulnerabilityId = vulnerabilityId,
Inputs = new VerdictInputs
{
SbomDigests = ImmutableArray.Create("sha256:sbom"),
VulnFeedSnapshotIds = ImmutableArray.Create("feed-1"),
VexDocumentDigests = ImmutableArray.Create("sha256:vex"),
ReachabilityGraphIds = ImmutableArray<string>.Empty,
ClockCutoff = DateTimeOffset.UtcNow,
},
Result = new VerdictResult
{
Status = VexStatus.NotAffected,
Confidence = 0.85,
Explanations = ImmutableArray<VerdictExplanation>.Empty,
EvidenceRefs = ImmutableArray<string>.Empty,
},
PolicyHash = policyHash,
LatticeVersion = latticeVersion,
EvaluatedAt = evaluatedAt ?? DateTimeOffset.UtcNow,
ManifestDigest = $"sha256:{manifestId}",
};
}
}

View File

@@ -0,0 +1,165 @@
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Authority.Core.Verdicts;
using Xunit;
namespace StellaOps.Authority.Core.Tests.Verdicts;
public sealed class VerdictManifestBuilderTests
{
[Fact]
public void Build_CreatesValidManifest()
{
var builder = new VerdictManifestBuilder(() => "test-manifest-id")
.WithTenant("tenant-1")
.WithAsset("sha256:abc123", "CVE-2024-1234")
.WithInputs(
sbomDigests: new[] { "sha256:sbom1" },
vulnFeedSnapshotIds: new[] { "feed-snapshot-1" },
vexDocumentDigests: new[] { "sha256:vex1" },
clockCutoff: DateTimeOffset.Parse("2025-01-01T00:00:00Z"))
.WithResult(
status: VexStatus.NotAffected,
confidence: 0.85,
explanations: new[]
{
new VerdictExplanation
{
SourceId = "vendor-a",
Reason = "Official vendor VEX",
ProvenanceScore = 0.9,
CoverageScore = 0.8,
ReplayabilityScore = 0.7,
StrengthMultiplier = 1.0,
FreshnessMultiplier = 0.95,
ClaimScore = 0.85,
AssertedStatus = VexStatus.NotAffected,
Accepted = true,
},
})
.WithPolicy("sha256:policy123", "1.0.0")
.WithClock(DateTimeOffset.Parse("2025-01-01T12:00:00Z"));
var manifest = builder.Build();
manifest.ManifestId.Should().Be("test-manifest-id");
manifest.Tenant.Should().Be("tenant-1");
manifest.AssetDigest.Should().Be("sha256:abc123");
manifest.VulnerabilityId.Should().Be("CVE-2024-1234");
manifest.Result.Status.Should().Be(VexStatus.NotAffected);
manifest.Result.Confidence.Should().Be(0.85);
manifest.ManifestDigest.Should().StartWith("sha256:");
}
[Fact]
public void Build_IsDeterministic()
{
var clock = DateTimeOffset.Parse("2025-01-01T12:00:00Z");
var inputClock = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
VerdictManifest BuildManifest(int seed)
{
return new VerdictManifestBuilder(() => "fixed-id")
.WithTenant("tenant")
.WithAsset("sha256:asset", "CVE-2024-0001")
.WithInputs(
sbomDigests: new[] { "sha256:sbom" },
vulnFeedSnapshotIds: new[] { "feed-1" },
vexDocumentDigests: new[] { "sha256:vex" },
clockCutoff: inputClock)
.WithResult(
status: VexStatus.Fixed,
confidence: 0.9,
explanations: new[]
{
new VerdictExplanation
{
SourceId = "source",
Reason = "Fixed",
ProvenanceScore = 0.9,
CoverageScore = 0.9,
ReplayabilityScore = 0.9,
StrengthMultiplier = 1.0,
FreshnessMultiplier = 1.0,
ClaimScore = 0.9,
AssertedStatus = VexStatus.Fixed,
Accepted = true,
},
})
.WithPolicy("sha256:policy", "1.0")
.WithClock(clock)
.Build();
}
var first = BuildManifest(1);
for (var i = 0; i < 100; i++)
{
var next = BuildManifest(i);
next.ManifestDigest.Should().Be(first.ManifestDigest, "manifests should be deterministic");
}
}
[Fact]
public void Build_SortsInputsDeterministically()
{
var clock = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
var manifestA = new VerdictManifestBuilder(() => "id")
.WithTenant("t")
.WithAsset("sha256:a", "CVE-1")
.WithInputs(
sbomDigests: new[] { "c", "a", "b" },
vulnFeedSnapshotIds: new[] { "z", "y" },
vexDocumentDigests: new[] { "3", "1", "2" },
clockCutoff: clock)
.WithResult(VexStatus.Affected, 0.5, Enumerable.Empty<VerdictExplanation>())
.WithPolicy("p", "v")
.WithClock(clock)
.Build();
var manifestB = new VerdictManifestBuilder(() => "id")
.WithTenant("t")
.WithAsset("sha256:a", "CVE-1")
.WithInputs(
sbomDigests: new[] { "b", "c", "a" },
vulnFeedSnapshotIds: new[] { "y", "z" },
vexDocumentDigests: new[] { "2", "3", "1" },
clockCutoff: clock)
.WithResult(VexStatus.Affected, 0.5, Enumerable.Empty<VerdictExplanation>())
.WithPolicy("p", "v")
.WithClock(clock)
.Build();
manifestA.ManifestDigest.Should().Be(manifestB.ManifestDigest);
manifestA.Inputs.SbomDigests.Should().Equal("a", "b", "c");
}
[Fact]
public void Build_ThrowsOnMissingRequiredFields()
{
var builder = new VerdictManifestBuilder();
var act = () => builder.Build();
act.Should().Throw<InvalidOperationException>()
.WithMessage("*validation failed*");
}
[Fact]
public void Build_NormalizesVulnerabilityIdToUpperCase()
{
var manifest = new VerdictManifestBuilder(() => "id")
.WithTenant("t")
.WithAsset("sha256:a", "cve-2024-1234")
.WithInputs(
sbomDigests: new[] { "sha256:s" },
vulnFeedSnapshotIds: new[] { "f" },
vexDocumentDigests: new[] { "v" },
clockCutoff: DateTimeOffset.UtcNow)
.WithResult(VexStatus.Affected, 0.5, Enumerable.Empty<VerdictExplanation>())
.WithPolicy("p", "v")
.Build();
manifest.VulnerabilityId.Should().Be("CVE-2024-1234");
}
}

View File

@@ -0,0 +1,122 @@
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Authority.Core.Verdicts;
using Xunit;
namespace StellaOps.Authority.Core.Tests.Verdicts;
public sealed class VerdictManifestSerializerTests
{
[Fact]
public void Serialize_ProducesValidJson()
{
var manifest = CreateTestManifest();
var json = VerdictManifestSerializer.Serialize(manifest);
json.Should().Contain("\"manifest_id\"");
json.Should().Contain("\"tenant\"");
json.Should().Contain("\"not_affected\"");
json.Should().NotContain("\"ManifestId\""); // Should use snake_case
}
[Fact]
public void SerializeDeserialize_RoundTrips()
{
var manifest = CreateTestManifest();
var json = VerdictManifestSerializer.Serialize(manifest);
var deserialized = VerdictManifestSerializer.Deserialize(json);
deserialized.Should().NotBeNull();
deserialized!.ManifestId.Should().Be(manifest.ManifestId);
deserialized.Result.Status.Should().Be(manifest.Result.Status);
deserialized.Result.Confidence.Should().Be(manifest.Result.Confidence);
}
[Fact]
public void ComputeDigest_IsDeterministic()
{
var manifest = CreateTestManifest();
var digest1 = VerdictManifestSerializer.ComputeDigest(manifest);
var digest2 = VerdictManifestSerializer.ComputeDigest(manifest);
digest1.Should().Be(digest2);
digest1.Should().StartWith("sha256:");
}
[Fact]
public void ComputeDigest_ChangesWithContent()
{
var manifest1 = CreateTestManifest();
var manifest2 = manifest1 with
{
Result = manifest1.Result with { Confidence = 0.5 }
};
var digest1 = VerdictManifestSerializer.ComputeDigest(manifest1);
var digest2 = VerdictManifestSerializer.ComputeDigest(manifest2);
digest1.Should().NotBe(digest2);
}
[Fact]
public void ComputeDigest_IgnoresSignatureFields()
{
var manifest1 = CreateTestManifest();
var manifest2 = manifest1 with
{
SignatureBase64 = "some-signature",
RekorLogId = "some-log-id"
};
var digest1 = VerdictManifestSerializer.ComputeDigest(manifest1);
var digest2 = VerdictManifestSerializer.ComputeDigest(manifest2);
digest1.Should().Be(digest2);
}
private static VerdictManifest CreateTestManifest()
{
return new VerdictManifest
{
ManifestId = "test-id",
Tenant = "test-tenant",
AssetDigest = "sha256:asset123",
VulnerabilityId = "CVE-2024-1234",
Inputs = new VerdictInputs
{
SbomDigests = ImmutableArray.Create("sha256:sbom1"),
VulnFeedSnapshotIds = ImmutableArray.Create("feed-1"),
VexDocumentDigests = ImmutableArray.Create("sha256:vex1"),
ReachabilityGraphIds = ImmutableArray<string>.Empty,
ClockCutoff = DateTimeOffset.Parse("2025-01-01T00:00:00Z"),
},
Result = new VerdictResult
{
Status = VexStatus.NotAffected,
Confidence = 0.85,
Explanations = ImmutableArray.Create(
new VerdictExplanation
{
SourceId = "vendor-a",
Reason = "Official vendor statement",
ProvenanceScore = 0.9,
CoverageScore = 0.8,
ReplayabilityScore = 0.7,
StrengthMultiplier = 1.0,
FreshnessMultiplier = 0.95,
ClaimScore = 0.85,
AssertedStatus = VexStatus.NotAffected,
Accepted = true,
}),
EvidenceRefs = ImmutableArray.Create("evidence-1"),
},
PolicyHash = "sha256:policy123",
LatticeVersion = "1.0.0",
EvaluatedAt = DateTimeOffset.Parse("2025-01-01T12:00:00Z"),
ManifestDigest = "sha256:placeholder",
};
}
}