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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System;
|
||||
using StellaOps.Authority.Storage.InMemory.Documents;
|
||||
using StellaOps.Authority.Storage.Documents;
|
||||
|
||||
namespace StellaOps.Authority.Security;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()}";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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}",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user