up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-14 15:50:38 +02:00
parent f1a39c4ce3
commit 233873f620
249 changed files with 29746 additions and 154 deletions

View File

@@ -1,11 +1,15 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Http;
using StellaOps.Policy.Engine.Caching;
using StellaOps.Policy.Engine.EffectiveDecisionMap;
using StellaOps.Policy.Engine.Events;
using StellaOps.Policy.Engine.ExceptionCache;
using StellaOps.Policy.Engine.Gates;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.ReachabilityFacts;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Vex;
using StellaOps.Policy.Engine.WhatIfSimulation;
using StellaOps.Policy.Engine.Workers;
using StackExchange.Redis;
@@ -115,6 +119,65 @@ public static class PolicyEngineServiceCollectionExtensions
return services;
}
/// <summary>
/// Adds the VEX decision emitter and gate evaluator services.
/// Supports OpenVEX document generation from reachability evidence.
/// </summary>
public static IServiceCollection AddVexDecisionEmitter(this IServiceCollection services)
{
// Gate evaluator for VEX status transitions
services.TryAddSingleton<IPolicyGateEvaluator, PolicyGateEvaluator>();
// VEX decision emitter
services.TryAddSingleton<IVexDecisionEmitter, VexDecisionEmitter>();
return services;
}
/// <summary>
/// Adds the VEX decision emitter with options configuration.
/// </summary>
public static IServiceCollection AddVexDecisionEmitter(
this IServiceCollection services,
Action<VexDecisionEmitterOptions> configure)
{
services.Configure(configure);
return services.AddVexDecisionEmitter();
}
/// <summary>
/// Adds policy gate evaluator with options configuration.
/// </summary>
public static IServiceCollection AddPolicyGates(
this IServiceCollection services,
Action<PolicyGateOptions> configure)
{
services.Configure(configure);
services.TryAddSingleton<IPolicyGateEvaluator, PolicyGateEvaluator>();
return services;
}
/// <summary>
/// Adds the VEX decision signing service for DSSE envelope creation and Rekor submission.
/// Optional dependencies: IVexSignerClient, IVexRekorClient.
/// </summary>
public static IServiceCollection AddVexDecisionSigning(this IServiceCollection services)
{
services.TryAddSingleton<IVexDecisionSigningService, VexDecisionSigningService>();
return services;
}
/// <summary>
/// Adds the VEX decision signing service with options configuration.
/// </summary>
public static IServiceCollection AddVexDecisionSigning(
this IServiceCollection services,
Action<VexSigningOptions> configure)
{
services.Configure(configure);
return services.AddVexDecisionSigning();
}
/// <summary>
/// Adds Redis connection for effective decision map and evaluation cache.
/// </summary>
@@ -128,6 +191,59 @@ public static class PolicyEngineServiceCollectionExtensions
return services;
}
/// <summary>
/// Adds the Signals-backed reachability facts client.
/// </summary>
public static IServiceCollection AddReachabilityFactsSignalsClient(
this IServiceCollection services,
Action<ReachabilityFactsSignalsClientOptions>? configure = null)
{
if (configure is not null)
{
services.Configure(configure);
}
services.AddHttpClient<IReachabilityFactsSignalsClient, ReachabilityFactsSignalsClient>()
.ConfigureHttpClient((sp, client) =>
{
var options = sp.GetService<Microsoft.Extensions.Options.IOptions<ReachabilityFactsSignalsClientOptions>>()?.Value;
if (options?.BaseUri is not null)
{
client.BaseAddress = options.BaseUri;
}
if (options?.Timeout > TimeSpan.Zero)
{
client.Timeout = options.Timeout;
}
});
return services;
}
/// <summary>
/// Adds the Signals-backed reachability facts store.
/// Requires AddReachabilityFactsSignalsClient to be called first.
/// </summary>
public static IServiceCollection AddSignalsBackedReachabilityFactsStore(this IServiceCollection services)
{
services.TryAddSingleton<IReachabilityFactsStore, SignalsBackedReachabilityFactsStore>();
return services;
}
/// <summary>
/// Adds reachability facts integration with Signals service.
/// Combines client and store registration.
/// </summary>
public static IServiceCollection AddReachabilityFactsSignalsIntegration(
this IServiceCollection services,
Action<ReachabilityFactsSignalsClientOptions>? configure = null)
{
services.AddReachabilityFactsSignalsClient(configure);
services.AddSignalsBackedReachabilityFactsStore();
return services;
}
/// <summary>
/// Adds all Policy Engine services with default configuration.
/// </summary>

View File

@@ -222,6 +222,7 @@ builder.Services.AddSingleton<IReachabilityFactsStore, InMemoryReachabilityFacts
builder.Services.AddSingleton<IReachabilityFactsOverlayCache, InMemoryReachabilityFactsOverlayCache>();
builder.Services.AddSingleton<ReachabilityFactsJoiningService>();
builder.Services.AddSingleton<IRuntimeEvaluationExecutor, RuntimeEvaluationExecutor>();
builder.Services.AddVexDecisionEmitter(); // POLICY-VEX-401-006
builder.Services.AddHttpContextAccessor();
builder.Services.AddRouting(options => options.LowercaseUrls = true);

View File

@@ -0,0 +1,234 @@
namespace StellaOps.Policy.Engine.ReachabilityFacts;
/// <summary>
/// HTTP client interface for fetching reachability facts from Signals service.
/// </summary>
public interface IReachabilityFactsSignalsClient
{
/// <summary>
/// Gets a reachability fact by subject key.
/// </summary>
/// <param name="subjectKey">Subject key (scan ID or component key).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The reachability fact document, or null if not found.</returns>
Task<SignalsReachabilityFactResponse?> GetBySubjectAsync(
string subjectKey,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets multiple reachability facts by subject keys.
/// </summary>
/// <param name="subjectKeys">Subject keys to lookup.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Dictionary of subject key to fact.</returns>
Task<IReadOnlyDictionary<string, SignalsReachabilityFactResponse>> GetBatchBySubjectsAsync(
IReadOnlyList<string> subjectKeys,
CancellationToken cancellationToken = default);
/// <summary>
/// Triggers recomputation of reachability for a subject.
/// </summary>
/// <param name="request">Recompute request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if recompute was triggered.</returns>
Task<bool> TriggerRecomputeAsync(
SignalsRecomputeRequest request,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Response from Signals /facts/{subjectKey} endpoint.
/// Maps to ReachabilityFactDocument in Signals module.
/// </summary>
public sealed record SignalsReachabilityFactResponse
{
/// <summary>
/// Document ID.
/// </summary>
public string Id { get; init; } = string.Empty;
/// <summary>
/// Callgraph ID.
/// </summary>
public string CallgraphId { get; init; } = string.Empty;
/// <summary>
/// Subject information.
/// </summary>
public SignalsSubject? Subject { get; init; }
/// <summary>
/// Entry points.
/// </summary>
public List<string>? EntryPoints { get; init; }
/// <summary>
/// Reachability states.
/// </summary>
public List<SignalsReachabilityState>? States { get; init; }
/// <summary>
/// Runtime facts.
/// </summary>
public List<SignalsRuntimeFact>? RuntimeFacts { get; init; }
/// <summary>
/// CAS URI for runtime-facts batch artifact.
/// </summary>
public string? RuntimeFactsBatchUri { get; init; }
/// <summary>
/// BLAKE3 hash of runtime-facts batch.
/// </summary>
public string? RuntimeFactsBatchHash { get; init; }
/// <summary>
/// Additional metadata.
/// </summary>
public Dictionary<string, string?>? Metadata { get; init; }
/// <summary>
/// Context facts for provenance.
/// </summary>
public SignalsContextFacts? ContextFacts { get; init; }
/// <summary>
/// Uncertainty information.
/// </summary>
public SignalsUncertainty? Uncertainty { get; init; }
/// <summary>
/// Edge bundle references.
/// </summary>
public List<SignalsEdgeBundleReference>? EdgeBundles { get; init; }
/// <summary>
/// Whether quarantined edges exist.
/// </summary>
public bool HasQuarantinedEdges { get; init; }
/// <summary>
/// Reachability score.
/// </summary>
public double Score { get; init; }
/// <summary>
/// Risk score.
/// </summary>
public double RiskScore { get; init; }
/// <summary>
/// Count of unknowns.
/// </summary>
public int UnknownsCount { get; init; }
/// <summary>
/// Unknowns pressure.
/// </summary>
public double UnknownsPressure { get; init; }
/// <summary>
/// Computation timestamp.
/// </summary>
public DateTimeOffset ComputedAt { get; init; }
/// <summary>
/// Subject key.
/// </summary>
public string SubjectKey { get; init; } = string.Empty;
}
/// <summary>
/// Subject information from Signals.
/// </summary>
public sealed record SignalsSubject
{
public string? ImageDigest { get; init; }
public string? Component { get; init; }
public string? Version { get; init; }
public string? ScanId { get; init; }
}
/// <summary>
/// Reachability state from Signals.
/// </summary>
public sealed record SignalsReachabilityState
{
public string Target { get; init; } = string.Empty;
public bool Reachable { get; init; }
public double Confidence { get; init; }
public string Bucket { get; init; } = "unknown";
public string? LatticeState { get; init; }
public string? PreviousLatticeState { get; init; }
public double Weight { get; init; }
public double Score { get; init; }
public List<string>? Path { get; init; }
public SignalsEvidence? Evidence { get; init; }
public DateTimeOffset? LatticeTransitionAt { get; init; }
}
/// <summary>
/// Evidence from Signals.
/// </summary>
public sealed record SignalsEvidence
{
public List<string>? RuntimeHits { get; init; }
public List<string>? BlockedEdges { get; init; }
}
/// <summary>
/// Runtime fact from Signals.
/// </summary>
public sealed record SignalsRuntimeFact
{
public string SymbolId { get; init; } = string.Empty;
public string? CodeId { get; init; }
public string? SymbolDigest { get; init; }
public string? Purl { get; init; }
public string? BuildId { get; init; }
public int HitCount { get; init; }
public DateTimeOffset? ObservedAt { get; init; }
}
/// <summary>
/// Context facts from Signals.
/// </summary>
public sealed record SignalsContextFacts;
/// <summary>
/// Uncertainty information from Signals.
/// </summary>
public sealed record SignalsUncertainty
{
public string? AggregateTier { get; init; }
public double? RiskScore { get; init; }
}
/// <summary>
/// Edge bundle reference from Signals.
/// </summary>
public sealed record SignalsEdgeBundleReference
{
public string BundleId { get; init; } = string.Empty;
public string Reason { get; init; } = string.Empty;
public int EdgeCount { get; init; }
public string? CasUri { get; init; }
public string? DsseDigest { get; init; }
public bool HasRevokedEdges { get; init; }
}
/// <summary>
/// Request to trigger reachability recomputation.
/// </summary>
public sealed record SignalsRecomputeRequest
{
/// <summary>
/// Subject key to recompute.
/// </summary>
public required string SubjectKey { get; init; }
/// <summary>
/// Tenant ID.
/// </summary>
public required string TenantId { get; init; }
}

View File

@@ -0,0 +1,227 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Engine.Telemetry;
namespace StellaOps.Policy.Engine.ReachabilityFacts;
/// <summary>
/// HTTP client for fetching reachability facts from Signals service.
/// </summary>
public sealed class ReachabilityFactsSignalsClient : IReachabilityFactsSignalsClient
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
private readonly HttpClient _httpClient;
private readonly ReachabilityFactsSignalsClientOptions _options;
private readonly ILogger<ReachabilityFactsSignalsClient> _logger;
public ReachabilityFactsSignalsClient(
HttpClient httpClient,
IOptions<ReachabilityFactsSignalsClientOptions> options,
ILogger<ReachabilityFactsSignalsClient> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
ArgumentNullException.ThrowIfNull(options);
_options = options.Value;
if (_httpClient.BaseAddress is null && _options.BaseUri is not null)
{
_httpClient.BaseAddress = _options.BaseUri;
}
_httpClient.DefaultRequestHeaders.Accept.Clear();
_httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/json");
}
/// <inheritdoc />
public async Task<SignalsReachabilityFactResponse?> GetBySubjectAsync(
string subjectKey,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(subjectKey);
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity(
"signals_client.get_fact",
ActivityKind.Client);
activity?.SetTag("signals.subject_key", subjectKey);
var path = $"signals/facts/{Uri.EscapeDataString(subjectKey)}";
try
{
var response = await _httpClient.GetAsync(path, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
_logger.LogDebug("Reachability fact not found for subject {SubjectKey}", subjectKey);
return null;
}
response.EnsureSuccessStatusCode();
var fact = await response.Content
.ReadFromJsonAsync<SignalsReachabilityFactResponse>(SerializerOptions, cancellationToken)
.ConfigureAwait(false);
_logger.LogDebug(
"Retrieved reachability fact for subject {SubjectKey}: score={Score}, states={StateCount}",
subjectKey,
fact?.Score,
fact?.States?.Count ?? 0);
return fact;
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get reachability fact for subject {SubjectKey}", subjectKey);
throw;
}
}
/// <inheritdoc />
public async Task<IReadOnlyDictionary<string, SignalsReachabilityFactResponse>> GetBatchBySubjectsAsync(
IReadOnlyList<string> subjectKeys,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(subjectKeys);
if (subjectKeys.Count == 0)
{
return new Dictionary<string, SignalsReachabilityFactResponse>();
}
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity(
"signals_client.get_facts_batch",
ActivityKind.Client);
activity?.SetTag("signals.batch_size", subjectKeys.Count);
var result = new Dictionary<string, SignalsReachabilityFactResponse>(StringComparer.Ordinal);
// Signals doesn't expose a batch endpoint, so we fetch in parallel with concurrency limit
var semaphore = new SemaphoreSlim(_options.MaxConcurrentRequests);
var tasks = subjectKeys.Select(async key =>
{
await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var fact = await GetBySubjectAsync(key, cancellationToken).ConfigureAwait(false);
return (Key: key, Fact: fact);
}
finally
{
semaphore.Release();
}
});
var results = await Task.WhenAll(tasks).ConfigureAwait(false);
foreach (var (key, fact) in results)
{
if (fact is not null)
{
result[key] = fact;
}
}
activity?.SetTag("signals.found_count", result.Count);
_logger.LogDebug(
"Batch retrieved {FoundCount}/{TotalCount} reachability facts",
result.Count,
subjectKeys.Count);
return result;
}
/// <inheritdoc />
public async Task<bool> TriggerRecomputeAsync(
SignalsRecomputeRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity(
"signals_client.trigger_recompute",
ActivityKind.Client);
activity?.SetTag("signals.subject_key", request.SubjectKey);
activity?.SetTag("signals.tenant_id", request.TenantId);
try
{
var response = await _httpClient.PostAsJsonAsync(
"signals/reachability/recompute",
new { subjectKey = request.SubjectKey, tenantId = request.TenantId },
SerializerOptions,
cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
_logger.LogInformation(
"Triggered reachability recompute for subject {SubjectKey}",
request.SubjectKey);
return true;
}
_logger.LogWarning(
"Failed to trigger reachability recompute for subject {SubjectKey}: {StatusCode}",
request.SubjectKey,
response.StatusCode);
return false;
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Error triggering reachability recompute for subject {SubjectKey}",
request.SubjectKey);
return false;
}
}
}
/// <summary>
/// Configuration options for the Signals reachability client.
/// </summary>
public sealed class ReachabilityFactsSignalsClientOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "ReachabilitySignals";
/// <summary>
/// Base URI for the Signals service.
/// </summary>
public Uri? BaseUri { get; set; }
/// <summary>
/// Maximum concurrent requests for batch operations.
/// Default: 10.
/// </summary>
public int MaxConcurrentRequests { get; set; } = 10;
/// <summary>
/// Request timeout.
/// Default: 30 seconds.
/// </summary>
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Retry count for transient failures.
/// Default: 3.
/// </summary>
public int RetryCount { get; set; } = 3;
}

View File

@@ -0,0 +1,377 @@
using Microsoft.Extensions.Logging;
namespace StellaOps.Policy.Engine.ReachabilityFacts;
/// <summary>
/// Implementation of <see cref="IReachabilityFactsStore"/> that delegates to the Signals service.
/// Maps between Signals' ReachabilityFactDocument and Policy's ReachabilityFact.
/// </summary>
public sealed class SignalsBackedReachabilityFactsStore : IReachabilityFactsStore
{
private readonly IReachabilityFactsSignalsClient _signalsClient;
private readonly ILogger<SignalsBackedReachabilityFactsStore> _logger;
private readonly TimeProvider _timeProvider;
public SignalsBackedReachabilityFactsStore(
IReachabilityFactsSignalsClient signalsClient,
ILogger<SignalsBackedReachabilityFactsStore> logger,
TimeProvider? timeProvider = null)
{
_signalsClient = signalsClient ?? throw new ArgumentNullException(nameof(signalsClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public async Task<ReachabilityFact?> GetAsync(
string tenantId,
string componentPurl,
string advisoryId,
CancellationToken cancellationToken = default)
{
// Signals uses subjectKey which is typically a scan ID or component key
// For Policy lookups, we construct a composite key
var subjectKey = BuildSubjectKey(componentPurl, advisoryId);
var response = await _signalsClient.GetBySubjectAsync(subjectKey, cancellationToken)
.ConfigureAwait(false);
if (response is null)
{
_logger.LogDebug(
"No reachability fact found for {TenantId}/{ComponentPurl}/{AdvisoryId}",
tenantId, componentPurl, advisoryId);
return null;
}
return MapToReachabilityFact(tenantId, componentPurl, advisoryId, response);
}
/// <inheritdoc />
public async Task<IReadOnlyDictionary<ReachabilityFactKey, ReachabilityFact>> GetBatchAsync(
IReadOnlyList<ReachabilityFactKey> keys,
CancellationToken cancellationToken = default)
{
if (keys.Count == 0)
{
return new Dictionary<ReachabilityFactKey, ReachabilityFact>();
}
// Build subject keys for batch lookup
var subjectKeyMap = keys.ToDictionary(
k => BuildSubjectKey(k.ComponentPurl, k.AdvisoryId),
k => k,
StringComparer.Ordinal);
var responses = await _signalsClient.GetBatchBySubjectsAsync(
subjectKeyMap.Keys.ToList(),
cancellationToken).ConfigureAwait(false);
var result = new Dictionary<ReachabilityFactKey, ReachabilityFact>();
foreach (var (subjectKey, response) in responses)
{
if (subjectKeyMap.TryGetValue(subjectKey, out var key))
{
var fact = MapToReachabilityFact(key.TenantId, key.ComponentPurl, key.AdvisoryId, response);
result[key] = fact;
}
}
return result;
}
/// <inheritdoc />
public Task<IReadOnlyList<ReachabilityFact>> QueryAsync(
ReachabilityFactsQuery query,
CancellationToken cancellationToken = default)
{
// Signals service doesn't expose a direct query API
// For now, return empty - callers should use batch lookups instead
_logger.LogDebug(
"Query not supported by Signals backend; use batch lookups instead. Tenant={TenantId}",
query.TenantId);
return Task.FromResult<IReadOnlyList<ReachabilityFact>>(Array.Empty<ReachabilityFact>());
}
/// <inheritdoc />
public Task SaveAsync(ReachabilityFact fact, CancellationToken cancellationToken = default)
{
// Read-only store - facts are computed by Signals service
_logger.LogWarning(
"Save not supported by Signals backend. Facts are computed by Signals service.");
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SaveBatchAsync(IReadOnlyList<ReachabilityFact> facts, CancellationToken cancellationToken = default)
{
// Read-only store - facts are computed by Signals service
_logger.LogWarning(
"SaveBatch not supported by Signals backend. Facts are computed by Signals service.");
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteAsync(
string tenantId,
string componentPurl,
string advisoryId,
CancellationToken cancellationToken = default)
{
// Read-only store - facts are managed by Signals service
_logger.LogWarning(
"Delete not supported by Signals backend. Facts are managed by Signals service.");
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<long> CountAsync(string tenantId, CancellationToken cancellationToken = default)
{
// Not available from Signals API
return Task.FromResult(0L);
}
/// <summary>
/// Triggers recomputation of reachability for a subject.
/// </summary>
public Task<bool> TriggerRecomputeAsync(
string tenantId,
string subjectKey,
CancellationToken cancellationToken = default)
{
return _signalsClient.TriggerRecomputeAsync(
new SignalsRecomputeRequest { SubjectKey = subjectKey, TenantId = tenantId },
cancellationToken);
}
private static string BuildSubjectKey(string componentPurl, string advisoryId)
{
// Build a deterministic subject key from component and advisory
// This should match how Signals indexes facts
return $"{componentPurl}|{advisoryId}";
}
private ReachabilityFact MapToReachabilityFact(
string tenantId,
string componentPurl,
string advisoryId,
SignalsReachabilityFactResponse response)
{
// Determine overall state from lattice states
var (state, confidence, hasRuntimeEvidence) = DetermineOverallState(response);
// Determine analysis method
var method = DetermineAnalysisMethod(response);
// Build evidence reference
var evidenceRef = response.RuntimeFactsBatchUri ?? response.CallgraphId;
var evidenceHash = response.RuntimeFactsBatchHash;
// Build metadata
var metadata = BuildMetadata(response);
return new ReachabilityFact
{
Id = response.Id,
TenantId = tenantId,
ComponentPurl = componentPurl,
AdvisoryId = advisoryId,
State = state,
Confidence = (decimal)confidence,
Score = (decimal)response.Score,
HasRuntimeEvidence = hasRuntimeEvidence,
Source = "signals",
Method = method,
EvidenceRef = evidenceRef,
EvidenceHash = evidenceHash,
ComputedAt = response.ComputedAt,
ExpiresAt = null, // Signals doesn't expose expiry; rely on cache TTL
Metadata = metadata,
};
}
private static (ReachabilityState State, double Confidence, bool HasRuntimeEvidence) DetermineOverallState(
SignalsReachabilityFactResponse response)
{
if (response.States is null || response.States.Count == 0)
{
return (ReachabilityState.Unknown, 0, false);
}
// Aggregate states - worst case wins for reachability
var hasReachable = false;
var hasUnreachable = false;
var hasRuntimeEvidence = false;
var maxConfidence = 0.0;
var totalConfidence = 0.0;
foreach (var state in response.States)
{
if (state.Reachable)
{
hasReachable = true;
}
else
{
hasUnreachable = true;
}
if (state.Evidence?.RuntimeHits?.Count > 0)
{
hasRuntimeEvidence = true;
}
maxConfidence = Math.Max(maxConfidence, state.Confidence);
totalConfidence += state.Confidence;
}
// Also check runtime facts
if (response.RuntimeFacts?.Count > 0)
{
hasRuntimeEvidence = true;
}
var avgConfidence = totalConfidence / response.States.Count;
// Determine overall state
ReachabilityState overallState;
if (hasReachable && hasRuntimeEvidence)
{
overallState = ReachabilityState.Reachable; // Confirmed reachable
}
else if (hasReachable)
{
overallState = ReachabilityState.Reachable; // Statically reachable
}
else if (hasUnreachable && avgConfidence >= 0.7)
{
overallState = ReachabilityState.Unreachable;
}
else if (hasUnreachable)
{
overallState = ReachabilityState.UnderInvestigation; // Low confidence
}
else
{
overallState = ReachabilityState.Unknown;
}
return (overallState, avgConfidence, hasRuntimeEvidence);
}
private static AnalysisMethod DetermineAnalysisMethod(SignalsReachabilityFactResponse response)
{
var hasStaticAnalysis = response.States?.Count > 0;
var hasRuntimeAnalysis = response.RuntimeFacts?.Count > 0 ||
response.States?.Any(s => s.Evidence?.RuntimeHits?.Count > 0) == true;
if (hasStaticAnalysis && hasRuntimeAnalysis)
{
return AnalysisMethod.Hybrid;
}
if (hasRuntimeAnalysis)
{
return AnalysisMethod.Dynamic;
}
if (hasStaticAnalysis)
{
return AnalysisMethod.Static;
}
return AnalysisMethod.Manual;
}
private static Dictionary<string, object?>? BuildMetadata(SignalsReachabilityFactResponse response)
{
var metadata = new Dictionary<string, object?>(StringComparer.Ordinal);
if (!string.IsNullOrEmpty(response.CallgraphId))
{
metadata["callgraph_id"] = response.CallgraphId;
}
if (response.Subject is not null)
{
if (!string.IsNullOrEmpty(response.Subject.ScanId))
{
metadata["scan_id"] = response.Subject.ScanId;
}
if (!string.IsNullOrEmpty(response.Subject.ImageDigest))
{
metadata["image_digest"] = response.Subject.ImageDigest;
}
}
if (response.EntryPoints?.Count > 0)
{
metadata["entry_points"] = response.EntryPoints;
}
if (response.Uncertainty is not null)
{
metadata["uncertainty_tier"] = response.Uncertainty.AggregateTier;
metadata["uncertainty_risk_score"] = response.Uncertainty.RiskScore;
}
if (response.EdgeBundles?.Count > 0)
{
metadata["edge_bundle_count"] = response.EdgeBundles.Count;
metadata["has_revoked_edges"] = response.EdgeBundles.Any(b => b.HasRevokedEdges);
}
if (response.HasQuarantinedEdges)
{
metadata["has_quarantined_edges"] = true;
}
metadata["unknowns_count"] = response.UnknownsCount;
metadata["unknowns_pressure"] = response.UnknownsPressure;
metadata["risk_score"] = response.RiskScore;
if (!string.IsNullOrEmpty(response.RuntimeFactsBatchUri))
{
metadata["runtime_facts_cas_uri"] = response.RuntimeFactsBatchUri;
}
// Extract call paths from states for evidence
var callPaths = response.States?
.Where(s => s.Path?.Count > 0)
.Select(s => s.Path!)
.ToList();
if (callPaths?.Count > 0)
{
metadata["call_paths"] = callPaths;
}
// Extract runtime hits from states
var runtimeHits = response.States?
.Where(s => s.Evidence?.RuntimeHits?.Count > 0)
.SelectMany(s => s.Evidence!.RuntimeHits!)
.Distinct()
.ToList();
if (runtimeHits?.Count > 0)
{
metadata["runtime_hits"] = runtimeHits;
}
// Extract lattice states
var latticeStates = response.States?
.Where(s => !string.IsNullOrEmpty(s.LatticeState))
.Select(s => new { s.Target, s.LatticeState, s.Confidence })
.ToList();
if (latticeStates?.Count > 0)
{
metadata["lattice_states"] = latticeStates;
}
return metadata.Count > 0 ? metadata : null;
}
}

View File

@@ -476,6 +476,56 @@ public static class PolicyEngineTelemetry
#endregion
#region VEX Decision Metrics
// Counter: policy_vex_decisions_total{status,lattice_state}
private static readonly Counter<long> VexDecisionsCounter =
Meter.CreateCounter<long>(
"policy_vex_decisions_total",
unit: "decisions",
description: "Total VEX decisions emitted by status and lattice state.");
// Counter: policy_vex_signing_total{success,rekor_submitted}
private static readonly Counter<long> VexSigningCounter =
Meter.CreateCounter<long>(
"policy_vex_signing_total",
unit: "signings",
description: "Total VEX decision signing operations.");
/// <summary>
/// Records a VEX decision emission.
/// </summary>
/// <param name="status">VEX status (not_affected, affected, under_investigation, fixed).</param>
/// <param name="latticeState">Lattice state code (U, SR, SU, RO, RU, CR, CU, X).</param>
public static void RecordVexDecision(string status, string latticeState)
{
var tags = new TagList
{
{ "status", NormalizeTag(status) },
{ "lattice_state", NormalizeTag(latticeState) },
};
VexDecisionsCounter.Add(1, tags);
}
/// <summary>
/// Records a VEX signing operation.
/// </summary>
/// <param name="success">Whether the signing operation succeeded.</param>
/// <param name="rekorSubmitted">Whether the envelope was submitted to Rekor.</param>
public static void RecordVexSigning(bool success, bool rekorSubmitted)
{
var tags = new TagList
{
{ "success", success ? "true" : "false" },
{ "rekor_submitted", rekorSubmitted ? "true" : "false" },
};
VexSigningCounter.Add(1, tags);
}
#endregion
#region Reachability Metrics
// Counter: policy_reachability_applied_total{state}

View File

@@ -0,0 +1,432 @@
using System.Collections.Immutable;
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Engine.Gates;
using StellaOps.Policy.Engine.ReachabilityFacts;
using StellaOps.Policy.Engine.Telemetry;
namespace StellaOps.Policy.Engine.Vex;
/// <summary>
/// Service for emitting OpenVEX decisions based on reachability facts.
/// </summary>
public interface IVexDecisionEmitter
{
/// <summary>
/// Emits VEX decisions for a set of findings.
/// </summary>
Task<VexDecisionEmitResult> EmitAsync(VexDecisionEmitRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// Determines the VEX status for a single finding based on reachability.
/// </summary>
Task<VexStatusDetermination> DetermineStatusAsync(
string tenantId,
string vulnId,
string purl,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of determining VEX status from reachability.
/// </summary>
public sealed record VexStatusDetermination
{
public required string Status { get; init; }
public string? Justification { get; init; }
public string? Bucket { get; init; }
public double Confidence { get; init; }
public string? LatticeState { get; init; }
public ReachabilityFact? Fact { get; init; }
}
/// <summary>
/// Default implementation of <see cref="IVexDecisionEmitter"/>.
/// </summary>
public sealed class VexDecisionEmitter : IVexDecisionEmitter
{
private readonly ReachabilityFactsJoiningService _factsService;
private readonly IPolicyGateEvaluator _gateEvaluator;
private readonly IOptionsMonitor<VexDecisionEmitterOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<VexDecisionEmitter> _logger;
// Status constants
private const string StatusNotAffected = "not_affected";
private const string StatusAffected = "affected";
private const string StatusUnderInvestigation = "under_investigation";
private const string StatusFixed = "fixed";
// Lattice state constants
private const string LatticeUnknown = "U";
private const string LatticeStaticallyReachable = "SR";
private const string LatticeStaticallyUnreachable = "SU";
private const string LatticeRuntimeObserved = "RO";
private const string LatticeRuntimeUnobserved = "RU";
private const string LatticeConfirmedReachable = "CR";
private const string LatticeConfirmedUnreachable = "CU";
private const string LatticeContested = "X";
public VexDecisionEmitter(
ReachabilityFactsJoiningService factsService,
IPolicyGateEvaluator gateEvaluator,
IOptionsMonitor<VexDecisionEmitterOptions> options,
TimeProvider timeProvider,
ILogger<VexDecisionEmitter> logger)
{
_factsService = factsService ?? throw new ArgumentNullException(nameof(factsService));
_gateEvaluator = gateEvaluator ?? throw new ArgumentNullException(nameof(gateEvaluator));
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc/>
public async Task<VexDecisionEmitResult> EmitAsync(VexDecisionEmitRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity(
"vex_decision.emit",
ActivityKind.Internal);
activity?.SetTag("tenant", request.TenantId);
activity?.SetTag("findings_count", request.Findings.Count);
var now = _timeProvider.GetUtcNow();
var options = _options.CurrentValue;
// Fetch reachability facts for all findings
var factRequests = request.Findings
.Select(f => new ReachabilityFactsRequest(f.Purl, f.VulnId))
.ToList();
var factsBatch = await _factsService.GetFactsBatchAsync(request.TenantId, factRequests, cancellationToken)
.ConfigureAwait(false);
// Process each finding
var statements = new List<VexStatement>();
var gateDecisions = new Dictionary<string, PolicyGateDecision>();
var blocked = new List<VexBlockedFinding>();
foreach (var finding in request.Findings)
{
var factKey = new ReachabilityFactKey(request.TenantId, finding.Purl, finding.VulnId);
factsBatch.Found.TryGetValue(factKey, out var fact);
// Determine status from reachability
var (status, justification, latticeState, confidence) = DetermineStatusFromFact(fact, finding);
// If override specified, use it
if (!string.IsNullOrWhiteSpace(finding.OverrideStatus))
{
status = finding.OverrideStatus;
justification = null; // Override may need different justification
}
// Evaluate gates
var gateRequest = new PolicyGateRequest
{
TenantId = request.TenantId,
VulnId = finding.VulnId,
Purl = finding.Purl,
SymbolId = finding.SymbolId,
ScanId = finding.ScanId,
RequestedStatus = status,
Justification = justification,
LatticeState = latticeState,
UncertaintyTier = fact?.Metadata?.TryGetValue("uncertainty_tier", out var tier) == true ? tier?.ToString() : null,
GraphHash = fact?.EvidenceHash,
Confidence = confidence,
HasRuntimeEvidence = fact?.HasRuntimeEvidence ?? false,
PathLength = fact?.Metadata?.TryGetValue("path_length", out var pl) == true && pl is int pathLen ? pathLen : null,
AllowOverride = !string.IsNullOrWhiteSpace(finding.OverrideJustification),
OverrideJustification = finding.OverrideJustification
};
var gateDecision = await _gateEvaluator.EvaluateAsync(gateRequest, cancellationToken).ConfigureAwait(false);
gateDecisions[$"{finding.VulnId}:{finding.Purl}"] = gateDecision;
// Handle blocked findings
if (gateDecision.Decision == PolicyGateDecisionType.Block)
{
blocked.Add(new VexBlockedFinding
{
VulnId = finding.VulnId,
Purl = finding.Purl,
RequestedStatus = status,
BlockedBy = gateDecision.BlockedBy ?? "Unknown",
Reason = gateDecision.BlockReason ?? "Gate evaluation blocked this status",
Suggestion = gateDecision.Suggestion
});
// Fall back to under_investigation for blocked findings
if (options.FallbackToUnderInvestigation)
{
status = StatusUnderInvestigation;
justification = null;
}
else
{
continue; // Skip this finding entirely
}
}
// Build statement
var statement = BuildStatement(finding, status, justification, fact, request.IncludeEvidence, now);
statements.Add(statement);
PolicyEngineTelemetry.RecordVexDecision(status, latticeState ?? LatticeUnknown);
}
// Build document
var documentId = $"urn:uuid:{Guid.NewGuid()}";
var document = new VexDecisionDocument
{
Id = documentId,
Author = request.Author,
Timestamp = now,
Statements = statements.ToImmutableArray()
};
_logger.LogInformation(
"Emitted VEX document {DocumentId} with {StatementCount} statements ({BlockedCount} blocked)",
documentId,
statements.Count,
blocked.Count);
return new VexDecisionEmitResult
{
Document = document,
GateDecisions = gateDecisions,
Blocked = blocked
};
}
/// <inheritdoc/>
public async Task<VexStatusDetermination> DetermineStatusAsync(
string tenantId,
string vulnId,
string purl,
CancellationToken cancellationToken = default)
{
var fact = await _factsService.GetFactAsync(tenantId, purl, vulnId, cancellationToken).ConfigureAwait(false);
var (status, justification, latticeState, confidence) = DetermineStatusFromFact(fact, null);
var bucket = BucketFromLatticeState(latticeState);
return new VexStatusDetermination
{
Status = status,
Justification = justification,
Bucket = bucket,
Confidence = confidence,
LatticeState = latticeState,
Fact = fact
};
}
private (string status, string? justification, string? latticeState, double confidence) DetermineStatusFromFact(
ReachabilityFact? fact,
VexFindingInput? finding)
{
if (fact is null)
{
// No reachability data - default to under_investigation
return (StatusUnderInvestigation, null, LatticeUnknown, 0.0);
}
var latticeState = MapReachabilityStateToLattice(fact.State, fact.HasRuntimeEvidence);
var confidence = (double)fact.Confidence;
return fact.State switch
{
// Confirmed unreachable - not_affected with strong justification
ReachabilityState.Unreachable when fact.HasRuntimeEvidence =>
(StatusNotAffected, VexJustification.VulnerableCodeNotInExecutePath, LatticeConfirmedUnreachable, confidence),
// Static unreachable - not_affected with weaker justification
ReachabilityState.Unreachable =>
(StatusNotAffected, VexJustification.VulnerableCodeNotInExecutePath, LatticeStaticallyUnreachable, confidence),
// Confirmed reachable - affected
ReachabilityState.Reachable when fact.HasRuntimeEvidence =>
(StatusAffected, null, LatticeConfirmedReachable, confidence),
// Static reachable - affected
ReachabilityState.Reachable =>
(StatusAffected, null, LatticeStaticallyReachable, confidence),
// Under investigation
ReachabilityState.UnderInvestigation =>
(StatusUnderInvestigation, null, latticeState, confidence),
// Unknown - default to under_investigation
ReachabilityState.Unknown =>
(StatusUnderInvestigation, null, LatticeUnknown, confidence),
_ => (StatusUnderInvestigation, null, LatticeUnknown, 0.0)
};
}
private static string MapReachabilityStateToLattice(ReachabilityState state, bool hasRuntimeEvidence)
{
return state switch
{
ReachabilityState.Reachable when hasRuntimeEvidence => LatticeConfirmedReachable,
ReachabilityState.Reachable => LatticeStaticallyReachable,
ReachabilityState.Unreachable when hasRuntimeEvidence => LatticeConfirmedUnreachable,
ReachabilityState.Unreachable => LatticeStaticallyUnreachable,
ReachabilityState.UnderInvestigation => LatticeContested,
_ => LatticeUnknown
};
}
private static string BucketFromLatticeState(string? latticeState)
{
return latticeState switch
{
LatticeConfirmedReachable or LatticeRuntimeObserved => "runtime",
LatticeStaticallyReachable => "static",
LatticeConfirmedUnreachable or LatticeRuntimeUnobserved => "runtime_unreachable",
LatticeStaticallyUnreachable => "static_unreachable",
LatticeContested => "contested",
_ => "unknown"
};
}
private VexStatement BuildStatement(
VexFindingInput finding,
string status,
string? justification,
ReachabilityFact? fact,
bool includeEvidence,
DateTimeOffset timestamp)
{
var vulnerability = new VexVulnerability
{
Id = finding.VulnId,
Name = finding.VulnName,
Description = finding.VulnDescription
};
var productBuilder = ImmutableArray.CreateBuilder<VexProduct>();
var product = new VexProduct
{
Id = finding.Purl,
Subcomponents = !string.IsNullOrWhiteSpace(finding.SymbolId)
? ImmutableArray.Create(new VexSubcomponent { Id = finding.SymbolId })
: null
};
productBuilder.Add(product);
VexEvidenceBlock? evidence = null;
if (includeEvidence && fact is not null)
{
var latticeState = MapReachabilityStateToLattice(fact.State, fact.HasRuntimeEvidence);
// Extract evidence details from metadata
ImmutableArray<string>? callPath = null;
ImmutableArray<string>? entryPoints = null;
ImmutableArray<string>? runtimeHits = null;
string? graphCasUri = null;
string? graphDsseDigest = null;
if (fact.Metadata is not null)
{
if (fact.Metadata.TryGetValue("call_path", out var cpObj) && cpObj is IEnumerable<object> cpList)
{
callPath = cpList.Select(x => x?.ToString() ?? string.Empty).ToImmutableArray();
}
if (fact.Metadata.TryGetValue("entry_points", out var epObj) && epObj is IEnumerable<object> epList)
{
entryPoints = epList.Select(x => x?.ToString() ?? string.Empty).ToImmutableArray();
}
if (fact.Metadata.TryGetValue("runtime_hits", out var rhObj) && rhObj is IEnumerable<object> rhList)
{
runtimeHits = rhList.Select(x => x?.ToString() ?? string.Empty).ToImmutableArray();
}
if (fact.Metadata.TryGetValue("graph_cas_uri", out var casUri))
{
graphCasUri = casUri?.ToString();
}
if (fact.Metadata.TryGetValue("graph_dsse_digest", out var dsseDigest))
{
graphDsseDigest = dsseDigest?.ToString();
}
}
evidence = new VexEvidenceBlock
{
LatticeState = latticeState,
UncertaintyTier = fact.Metadata?.TryGetValue("uncertainty_tier", out var tier) == true ? tier?.ToString() : null,
Confidence = (double)fact.Confidence,
RiskScore = fact.Metadata?.TryGetValue("risk_score", out var rs) == true && rs is double riskScore ? riskScore : null,
CallPath = callPath,
EntryPoints = entryPoints,
RuntimeHits = runtimeHits,
GraphHash = fact.EvidenceHash,
GraphCasUri = graphCasUri,
GraphDsseDigest = graphDsseDigest,
Method = fact.Method.ToString().ToLowerInvariant(),
ComputedAt = fact.ComputedAt
};
}
// Build impact/action statements
string? impactStatement = null;
string? actionStatement = null;
if (status == StatusNotAffected && justification == VexJustification.VulnerableCodeNotInExecutePath)
{
impactStatement = "Reachability analysis confirms the vulnerable code path is not executed.";
}
else if (status == StatusAffected)
{
actionStatement = "Vulnerable code path is reachable. Remediation recommended.";
}
return new VexStatement
{
Vulnerability = vulnerability,
Products = productBuilder.ToImmutable(),
Status = status,
Justification = justification,
ImpactStatement = impactStatement,
ActionStatement = actionStatement,
Timestamp = timestamp,
Evidence = evidence
};
}
}
/// <summary>
/// Options for VEX decision emitter.
/// </summary>
public sealed class VexDecisionEmitterOptions
{
/// <summary>
/// Whether to fall back to under_investigation when gates block.
/// </summary>
public bool FallbackToUnderInvestigation { get; set; } = true;
/// <summary>
/// Minimum confidence required for not_affected auto-determination.
/// </summary>
public double MinConfidenceForNotAffected { get; set; } = 0.7;
/// <summary>
/// Whether to require runtime evidence for not_affected.
/// </summary>
public bool RequireRuntimeForNotAffected { get; set; }
/// <summary>
/// Default author for VEX documents.
/// </summary>
public string DefaultAuthor { get; set; } = "stellaops/policy-engine";
}

View File

@@ -0,0 +1,467 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
using StellaOps.Policy.Engine.Gates;
using StellaOps.Policy.Engine.ReachabilityFacts;
namespace StellaOps.Policy.Engine.Vex;
/// <summary>
/// OpenVEX decision document emitted by the policy engine.
/// </summary>
public sealed record VexDecisionDocument
{
/// <summary>
/// Document identifier (GUID).
/// </summary>
[JsonPropertyName("@id")]
public required string Id { get; init; }
/// <summary>
/// OpenVEX context (always "https://openvex.dev/ns/v0.2.0").
/// </summary>
[JsonPropertyName("@context")]
public string Context { get; init; } = "https://openvex.dev/ns/v0.2.0";
/// <summary>
/// Author identifier.
/// </summary>
[JsonPropertyName("author")]
public required string Author { get; init; }
/// <summary>
/// Role of the author.
/// </summary>
[JsonPropertyName("role")]
public string Role { get; init; } = "policy_engine";
/// <summary>
/// Timestamp when the document was created.
/// </summary>
[JsonPropertyName("timestamp")]
public required DateTimeOffset Timestamp { get; init; }
/// <summary>
/// Document version (SemVer).
/// </summary>
[JsonPropertyName("version")]
public int Version { get; init; } = 1;
/// <summary>
/// Tooling identifier.
/// </summary>
[JsonPropertyName("tooling")]
public string Tooling { get; init; } = "stellaops/policy-engine";
/// <summary>
/// VEX statements in this document.
/// </summary>
[JsonPropertyName("statements")]
public required ImmutableArray<VexStatement> Statements { get; init; }
}
/// <summary>
/// A single VEX statement with reachability evidence.
/// </summary>
public sealed record VexStatement
{
/// <summary>
/// Vulnerability identifier (CVE, GHSA, etc.).
/// </summary>
[JsonPropertyName("vulnerability")]
public required VexVulnerability Vulnerability { get; init; }
/// <summary>
/// Products affected by this statement.
/// </summary>
[JsonPropertyName("products")]
public required ImmutableArray<VexProduct> Products { get; init; }
/// <summary>
/// VEX status (not_affected, affected, under_investigation, fixed).
/// </summary>
[JsonPropertyName("status")]
public required string Status { get; init; }
/// <summary>
/// Justification for not_affected status.
/// </summary>
[JsonPropertyName("justification")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Justification { get; init; }
/// <summary>
/// Impact statement for not_affected.
/// </summary>
[JsonPropertyName("impact_statement")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ImpactStatement { get; init; }
/// <summary>
/// Action statement for affected/fixed.
/// </summary>
[JsonPropertyName("action_statement")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ActionStatement { get; init; }
/// <summary>
/// Timestamp of the statement.
/// </summary>
[JsonPropertyName("timestamp")]
public required DateTimeOffset Timestamp { get; init; }
/// <summary>
/// Status notes.
/// </summary>
[JsonPropertyName("status_notes")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? StatusNotes { get; init; }
/// <summary>
/// Reachability evidence block (StellaOps extension).
/// </summary>
[JsonPropertyName("x-stellaops-evidence")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public VexEvidenceBlock? Evidence { get; init; }
}
/// <summary>
/// VEX vulnerability reference.
/// </summary>
public sealed record VexVulnerability
{
/// <summary>
/// Vulnerability identifier (CVE-2021-44228, GHSA-..., etc.).
/// </summary>
[JsonPropertyName("@id")]
public required string Id { get; init; }
/// <summary>
/// Vulnerability name/title.
/// </summary>
[JsonPropertyName("name")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Name { get; init; }
/// <summary>
/// Description of the vulnerability.
/// </summary>
[JsonPropertyName("description")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Description { get; init; }
}
/// <summary>
/// VEX product reference.
/// </summary>
public sealed record VexProduct
{
/// <summary>
/// Product identifier (purl).
/// </summary>
[JsonPropertyName("@id")]
public required string Id { get; init; }
/// <summary>
/// Subcomponents (function-level specificity).
/// </summary>
[JsonPropertyName("subcomponents")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ImmutableArray<VexSubcomponent>? Subcomponents { get; init; }
}
/// <summary>
/// VEX subcomponent for function-level precision.
/// </summary>
public sealed record VexSubcomponent
{
/// <summary>
/// Subcomponent identifier (symbol ID).
/// </summary>
[JsonPropertyName("@id")]
public required string Id { get; init; }
}
/// <summary>
/// StellaOps reachability evidence block (extension).
/// </summary>
public sealed record VexEvidenceBlock
{
/// <summary>
/// v1 lattice state code (U, SR, SU, RO, RU, CR, CU, X).
/// </summary>
[JsonPropertyName("lattice_state")]
public required string LatticeState { get; init; }
/// <summary>
/// Uncertainty tier (T1, T2, T3, T4).
/// </summary>
[JsonPropertyName("uncertainty_tier")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? UncertaintyTier { get; init; }
/// <summary>
/// Confidence score (0.0-1.0).
/// </summary>
[JsonPropertyName("confidence")]
public double Confidence { get; init; }
/// <summary>
/// Risk score incorporating uncertainty.
/// </summary>
[JsonPropertyName("risk_score")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public double? RiskScore { get; init; }
/// <summary>
/// Call path from entry point to target.
/// </summary>
[JsonPropertyName("call_path")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ImmutableArray<string>? CallPath { get; init; }
/// <summary>
/// Entry points considered.
/// </summary>
[JsonPropertyName("entry_points")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ImmutableArray<string>? EntryPoints { get; init; }
/// <summary>
/// Runtime hits (symbols observed at runtime).
/// </summary>
[JsonPropertyName("runtime_hits")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ImmutableArray<string>? RuntimeHits { get; init; }
/// <summary>
/// BLAKE3 hash of the call graph.
/// </summary>
[JsonPropertyName("graph_hash")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? GraphHash { get; init; }
/// <summary>
/// CAS URI for the call graph.
/// </summary>
[JsonPropertyName("graph_cas_uri")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? GraphCasUri { get; init; }
/// <summary>
/// DSSE envelope digest for the graph.
/// </summary>
[JsonPropertyName("graph_dsse_digest")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? GraphDsseDigest { get; init; }
/// <summary>
/// Edge bundles attached to this evidence.
/// </summary>
[JsonPropertyName("edge_bundles")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ImmutableArray<VexEdgeBundleRef>? EdgeBundles { get; init; }
/// <summary>
/// Analysis method (static, dynamic, hybrid).
/// </summary>
[JsonPropertyName("method")]
public string Method { get; init; } = "hybrid";
/// <summary>
/// Timestamp when evidence was computed.
/// </summary>
[JsonPropertyName("computed_at")]
public required DateTimeOffset ComputedAt { get; init; }
}
/// <summary>
/// Reference to an edge bundle with DSSE attestation.
/// </summary>
public sealed record VexEdgeBundleRef
{
/// <summary>
/// Bundle identifier.
/// </summary>
[JsonPropertyName("bundle_id")]
public required string BundleId { get; init; }
/// <summary>
/// Bundle reason (RuntimeHits, InitArray, etc.).
/// </summary>
[JsonPropertyName("reason")]
public required string Reason { get; init; }
/// <summary>
/// CAS URI for the bundle.
/// </summary>
[JsonPropertyName("cas_uri")]
public required string CasUri { get; init; }
/// <summary>
/// DSSE CAS URI (if signed).
/// </summary>
[JsonPropertyName("dsse_cas_uri")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? DsseCasUri { get; init; }
}
/// <summary>
/// Request to emit VEX decisions for a set of findings.
/// </summary>
public sealed record VexDecisionEmitRequest
{
/// <summary>
/// Tenant identifier.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Author identifier for the VEX document.
/// </summary>
public required string Author { get; init; }
/// <summary>
/// Findings to emit decisions for.
/// </summary>
public required IReadOnlyList<VexFindingInput> Findings { get; init; }
/// <summary>
/// Whether to include full evidence blocks.
/// </summary>
public bool IncludeEvidence { get; init; } = true;
/// <summary>
/// Whether to request DSSE signatures.
/// </summary>
public bool RequestDsse { get; init; }
/// <summary>
/// Whether to submit to Rekor transparency log.
/// </summary>
public bool SubmitToRekor { get; init; }
}
/// <summary>
/// Input for a single finding to emit a VEX decision.
/// </summary>
public sealed record VexFindingInput
{
/// <summary>
/// Vulnerability identifier.
/// </summary>
public required string VulnId { get; init; }
/// <summary>
/// Package URL.
/// </summary>
public required string Purl { get; init; }
/// <summary>
/// Target symbol identifier (function-level).
/// </summary>
public string? SymbolId { get; init; }
/// <summary>
/// Scan identifier.
/// </summary>
public string? ScanId { get; init; }
/// <summary>
/// Vulnerability name/title.
/// </summary>
public string? VulnName { get; init; }
/// <summary>
/// Vulnerability description.
/// </summary>
public string? VulnDescription { get; init; }
/// <summary>
/// Override VEX status (if specified, bypasses auto-determination).
/// </summary>
public string? OverrideStatus { get; init; }
/// <summary>
/// Justification for override.
/// </summary>
public string? OverrideJustification { get; init; }
}
/// <summary>
/// Result of emitting VEX decisions.
/// </summary>
public sealed record VexDecisionEmitResult
{
/// <summary>
/// The emitted VEX document.
/// </summary>
public required VexDecisionDocument Document { get; init; }
/// <summary>
/// Gate decisions for each finding.
/// </summary>
public required IReadOnlyDictionary<string, PolicyGateDecision> GateDecisions { get; init; }
/// <summary>
/// Findings that were blocked by gates.
/// </summary>
public required IReadOnlyList<VexBlockedFinding> Blocked { get; init; }
/// <summary>
/// DSSE envelope digest (if signed).
/// </summary>
public string? DsseDigest { get; init; }
/// <summary>
/// Rekor log index (if submitted).
/// </summary>
public long? RekorLogIndex { get; init; }
}
/// <summary>
/// A finding that was blocked by policy gates.
/// </summary>
public sealed record VexBlockedFinding
{
/// <summary>
/// Vulnerability identifier.
/// </summary>
public required string VulnId { get; init; }
/// <summary>
/// Package URL.
/// </summary>
public required string Purl { get; init; }
/// <summary>
/// The status that was requested.
/// </summary>
public required string RequestedStatus { get; init; }
/// <summary>
/// The gate that blocked.
/// </summary>
public required string BlockedBy { get; init; }
/// <summary>
/// Reason for blocking.
/// </summary>
public required string Reason { get; init; }
/// <summary>
/// Suggestion for resolving.
/// </summary>
public string? Suggestion { get; init; }
}
/// <summary>
/// OpenVEX justification values for not_affected status.
/// </summary>
public static class VexJustification
{
public const string ComponentNotPresent = "component_not_present";
public const string VulnerableCodeNotPresent = "vulnerable_code_not_present";
public const string VulnerableCodeNotInExecutePath = "vulnerable_code_not_in_execute_path";
public const string VulnerableCodeCannotBeControlledByAdversary = "vulnerable_code_cannot_be_controlled_by_adversary";
public const string InlineMitigationsAlreadyExist = "inline_mitigations_already_exist";
}

View File

@@ -0,0 +1,696 @@
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Engine.Telemetry;
namespace StellaOps.Policy.Engine.Vex;
/// <summary>
/// Service for signing VEX decision documents with DSSE envelopes and optionally submitting to Rekor.
/// </summary>
public interface IVexDecisionSigningService
{
/// <summary>
/// Signs a VEX decision document, creating a DSSE envelope.
/// </summary>
Task<VexSigningResult> SignAsync(VexSigningRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// Verifies a signed VEX decision envelope.
/// </summary>
Task<VexVerificationResult> VerifyAsync(VexVerificationRequest request, CancellationToken cancellationToken = default);
}
/// <summary>
/// Request to sign a VEX decision document.
/// </summary>
public sealed record VexSigningRequest
{
/// <summary>
/// The VEX decision document to sign.
/// </summary>
public required VexDecisionDocument Document { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Key identifier for signing (null for default/keyless).
/// </summary>
public string? KeyId { get; init; }
/// <summary>
/// Whether to submit to Rekor transparency log.
/// </summary>
public bool SubmitToRekor { get; init; } = true;
/// <summary>
/// Subject URIs for the attestation (e.g., SBOM digest, scan ID).
/// </summary>
public IReadOnlyList<string>? SubjectUris { get; init; }
/// <summary>
/// Evidence artifact digests to reference.
/// </summary>
public IReadOnlyList<VexEvidenceReference>? EvidenceRefs { get; init; }
}
/// <summary>
/// Reference to supporting evidence artifact.
/// </summary>
public sealed record VexEvidenceReference
{
/// <summary>
/// Type of evidence (e.g., "sbom", "callgraph", "scan-report").
/// </summary>
public required string Type { get; init; }
/// <summary>
/// SHA256 digest of the evidence artifact.
/// </summary>
public required string Digest { get; init; }
/// <summary>
/// CAS URI for the artifact.
/// </summary>
public string? CasUri { get; init; }
}
/// <summary>
/// Result of signing a VEX decision.
/// </summary>
public sealed record VexSigningResult
{
/// <summary>
/// Whether signing was successful.
/// </summary>
public bool Success { get; init; }
/// <summary>
/// The DSSE envelope containing the signed VEX decision.
/// </summary>
public VexDsseEnvelope? Envelope { get; init; }
/// <summary>
/// SHA256 digest of the canonical envelope.
/// </summary>
public string? EnvelopeDigest { get; init; }
/// <summary>
/// Rekor transparency log metadata (if submitted).
/// </summary>
public VexRekorMetadata? RekorMetadata { get; init; }
/// <summary>
/// Error message if signing failed.
/// </summary>
public string? Error { get; init; }
}
/// <summary>
/// DSSE envelope for VEX decisions.
/// </summary>
public sealed record VexDsseEnvelope
{
/// <summary>
/// Payload type (always "stella.ops/vexDecision@v1").
/// </summary>
public string PayloadType { get; init; } = VexPredicateTypes.VexDecision;
/// <summary>
/// Base64-encoded payload (canonical JSON of VEX document).
/// </summary>
public required string Payload { get; init; }
/// <summary>
/// Signatures on the envelope.
/// </summary>
public required IReadOnlyList<VexDsseSignature> Signatures { get; init; }
}
/// <summary>
/// Signature in a VEX DSSE envelope.
/// </summary>
public sealed record VexDsseSignature
{
/// <summary>
/// Key identifier used for signing.
/// </summary>
public string? KeyId { get; init; }
/// <summary>
/// Base64-encoded signature.
/// </summary>
public required string Sig { get; init; }
}
/// <summary>
/// Rekor transparency log metadata.
/// </summary>
public sealed record VexRekorMetadata
{
/// <summary>
/// Rekor entry UUID.
/// </summary>
public required string Uuid { get; init; }
/// <summary>
/// Rekor log index.
/// </summary>
public long Index { get; init; }
/// <summary>
/// Rekor log URL.
/// </summary>
public required string LogUrl { get; init; }
/// <summary>
/// Timestamp of entry creation.
/// </summary>
public DateTimeOffset IntegratedAt { get; init; }
/// <summary>
/// Merkle tree root hash at integration time.
/// </summary>
public string? TreeRoot { get; init; }
/// <summary>
/// Inclusion proof (if available).
/// </summary>
public VexRekorInclusionProof? InclusionProof { get; init; }
}
/// <summary>
/// Rekor inclusion proof.
/// </summary>
public sealed record VexRekorInclusionProof
{
/// <summary>
/// Checkpoint text.
/// </summary>
public required string Checkpoint { get; init; }
/// <summary>
/// Hashes in the inclusion proof.
/// </summary>
public required IReadOnlyList<string> Hashes { get; init; }
/// <summary>
/// Leaf index in the tree.
/// </summary>
public long LeafIndex { get; init; }
/// <summary>
/// Tree size at proof time.
/// </summary>
public long TreeSize { get; init; }
}
/// <summary>
/// Request to verify a signed VEX decision.
/// </summary>
public sealed record VexVerificationRequest
{
/// <summary>
/// The DSSE envelope to verify.
/// </summary>
public required VexDsseEnvelope Envelope { get; init; }
/// <summary>
/// Expected Rekor metadata (optional).
/// </summary>
public VexRekorMetadata? ExpectedRekorMetadata { get; init; }
/// <summary>
/// Whether to verify Rekor inclusion.
/// </summary>
public bool VerifyRekorInclusion { get; init; }
}
/// <summary>
/// Result of verifying a signed VEX decision.
/// </summary>
public sealed record VexVerificationResult
{
/// <summary>
/// Whether verification passed.
/// </summary>
public bool Valid { get; init; }
/// <summary>
/// The decoded VEX decision document.
/// </summary>
public VexDecisionDocument? Document { get; init; }
/// <summary>
/// Verification errors (if any).
/// </summary>
public IReadOnlyList<string>? Errors { get; init; }
/// <summary>
/// Verified Rekor metadata.
/// </summary>
public VexRekorMetadata? RekorMetadata { get; init; }
}
/// <summary>
/// VEX predicate type constants.
/// </summary>
public static class VexPredicateTypes
{
/// <summary>
/// Predicate type for VEX decisions: stella.ops/vexDecision@v1.
/// </summary>
public const string VexDecision = "stella.ops/vexDecision@v1";
/// <summary>
/// Predicate type for full VEX documents: stella.ops/vex@v1.
/// </summary>
public const string VexDocument = "stella.ops/vex@v1";
/// <summary>
/// Standard OpenVEX predicate type.
/// </summary>
public const string OpenVex = "https://openvex.dev/ns";
}
/// <summary>
/// Default implementation of <see cref="IVexDecisionSigningService"/>.
/// </summary>
public sealed class VexDecisionSigningService : IVexDecisionSigningService
{
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
private readonly IVexSignerClient? _signerClient;
private readonly IVexRekorClient? _rekorClient;
private readonly IOptionsMonitor<VexSigningOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<VexDecisionSigningService> _logger;
public VexDecisionSigningService(
IVexSignerClient? signerClient,
IVexRekorClient? rekorClient,
IOptionsMonitor<VexSigningOptions> options,
TimeProvider timeProvider,
ILogger<VexDecisionSigningService> logger)
{
_signerClient = signerClient;
_rekorClient = rekorClient;
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc/>
public async Task<VexSigningResult> SignAsync(VexSigningRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity(
"vex_decision.sign",
ActivityKind.Internal);
activity?.SetTag("tenant", request.TenantId);
activity?.SetTag("document_id", request.Document.Id);
try
{
var options = _options.CurrentValue;
// Serialize document to canonical JSON
var documentJson = SerializeCanonical(request.Document);
var payloadBase64 = Convert.ToBase64String(documentJson);
// Sign the payload
VexDsseSignature signature;
if (_signerClient is not null && options.UseSignerService)
{
var signResult = await _signerClient.SignAsync(
new VexSignerRequest
{
PayloadType = VexPredicateTypes.VexDecision,
PayloadBase64 = payloadBase64,
KeyId = request.KeyId,
TenantId = request.TenantId
},
cancellationToken).ConfigureAwait(false);
if (!signResult.Success)
{
return new VexSigningResult
{
Success = false,
Error = signResult.Error ?? "Signer service returned failure"
};
}
signature = new VexDsseSignature
{
KeyId = signResult.KeyId,
Sig = signResult.Signature!
};
}
else
{
// Local signing fallback (for testing/development)
signature = SignLocally(VexPredicateTypes.VexDecision, documentJson, request.KeyId);
}
// Build envelope
var envelope = new VexDsseEnvelope
{
PayloadType = VexPredicateTypes.VexDecision,
Payload = payloadBase64,
Signatures = [signature]
};
// Compute envelope digest
var envelopeJson = SerializeCanonical(envelope);
var envelopeDigest = ComputeSha256(envelopeJson);
// Submit to Rekor if requested
VexRekorMetadata? rekorMetadata = null;
if (request.SubmitToRekor && _rekorClient is not null && options.RekorEnabled)
{
rekorMetadata = await SubmitToRekorAsync(envelope, envelopeDigest, request, cancellationToken)
.ConfigureAwait(false);
}
_logger.LogInformation(
"Signed VEX decision {DocumentId} for tenant {TenantId}. Rekor: {RekorSubmitted}",
request.Document.Id,
request.TenantId,
rekorMetadata is not null);
PolicyEngineTelemetry.RecordVexSigning(success: true, rekorSubmitted: rekorMetadata is not null);
return new VexSigningResult
{
Success = true,
Envelope = envelope,
EnvelopeDigest = $"sha256:{envelopeDigest}",
RekorMetadata = rekorMetadata
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to sign VEX decision {DocumentId}", request.Document.Id);
PolicyEngineTelemetry.RecordVexSigning(success: false, rekorSubmitted: false);
return new VexSigningResult
{
Success = false,
Error = ex.Message
};
}
}
/// <inheritdoc/>
public async Task<VexVerificationResult> VerifyAsync(VexVerificationRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var errors = new List<string>();
try
{
// Decode payload
var payloadBytes = Convert.FromBase64String(request.Envelope.Payload);
var document = JsonSerializer.Deserialize<VexDecisionDocument>(payloadBytes, CanonicalJsonOptions);
if (document is null)
{
errors.Add("Failed to decode VEX document from payload");
return new VexVerificationResult { Valid = false, Errors = errors };
}
// Verify payload type
if (request.Envelope.PayloadType != VexPredicateTypes.VexDecision &&
request.Envelope.PayloadType != VexPredicateTypes.VexDocument &&
request.Envelope.PayloadType != VexPredicateTypes.OpenVex)
{
errors.Add($"Invalid payload type: {request.Envelope.PayloadType}");
}
// Verify signatures
if (request.Envelope.Signatures.Count == 0)
{
errors.Add("Envelope has no signatures");
}
foreach (var sig in request.Envelope.Signatures)
{
if (string.IsNullOrWhiteSpace(sig.Sig))
{
errors.Add("Signature is empty");
continue;
}
// TODO: Verify actual signature if signer client provides public key resolution
// For now, we just verify the signature is well-formed base64
try
{
_ = Convert.FromBase64String(sig.Sig);
}
catch (FormatException)
{
errors.Add($"Invalid base64 signature for keyId: {sig.KeyId ?? "(none)"}");
}
}
// Verify Rekor inclusion if requested
VexRekorMetadata? verifiedRekor = null;
if (request.VerifyRekorInclusion && request.ExpectedRekorMetadata is not null && _rekorClient is not null)
{
var proofResult = await _rekorClient.GetProofAsync(
request.ExpectedRekorMetadata.Uuid,
cancellationToken).ConfigureAwait(false);
if (proofResult is null)
{
errors.Add($"Could not retrieve Rekor proof for UUID: {request.ExpectedRekorMetadata.Uuid}");
}
else
{
verifiedRekor = proofResult;
}
}
return new VexVerificationResult
{
Valid = errors.Count == 0,
Document = document,
Errors = errors.Count > 0 ? errors : null,
RekorMetadata = verifiedRekor ?? request.ExpectedRekorMetadata
};
}
catch (Exception ex)
{
errors.Add($"Verification failed: {ex.Message}");
return new VexVerificationResult { Valid = false, Errors = errors };
}
}
private async Task<VexRekorMetadata?> SubmitToRekorAsync(
VexDsseEnvelope envelope,
string envelopeDigest,
VexSigningRequest request,
CancellationToken cancellationToken)
{
if (_rekorClient is null)
{
return null;
}
try
{
var result = await _rekorClient.SubmitAsync(
new VexRekorSubmitRequest
{
Envelope = envelope,
EnvelopeDigest = envelopeDigest,
ArtifactKind = "vex-decision",
SubjectUris = request.SubjectUris
},
cancellationToken).ConfigureAwait(false);
if (!result.Success)
{
_logger.LogWarning(
"Failed to submit VEX decision {DocumentId} to Rekor: {Error}",
request.Document.Id,
result.Error);
return null;
}
return result.Metadata;
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Error submitting VEX decision {DocumentId} to Rekor",
request.Document.Id);
return null;
}
}
private static VexDsseSignature SignLocally(string payloadType, byte[] payload, string? keyId)
{
// Compute DSSE PAE: "DSSEv1" + len(payloadType) + payloadType + len(payload) + payload
using var ms = new MemoryStream();
using var writer = new BinaryWriter(ms);
var prefix = "DSSEv1 "u8;
writer.Write(prefix);
var typeBytes = System.Text.Encoding.UTF8.GetBytes(payloadType);
writer.Write(typeBytes.Length.ToString());
writer.Write(' ');
writer.Write(typeBytes);
writer.Write(' ');
writer.Write(payload.Length.ToString());
writer.Write(' ');
writer.Write(payload);
var pae = ms.ToArray();
// For local signing, use SHA256 hash as a placeholder signature
// In production, this would use actual key material
using var sha256 = SHA256.Create();
var signatureBytes = sha256.ComputeHash(pae);
return new VexDsseSignature
{
KeyId = keyId ?? "local:sha256",
Sig = Convert.ToBase64String(signatureBytes)
};
}
private static byte[] SerializeCanonical<T>(T value)
{
return JsonSerializer.SerializeToUtf8Bytes(value, CanonicalJsonOptions);
}
private static string ComputeSha256(byte[] data)
{
using var sha256 = SHA256.Create();
var hash = sha256.ComputeHash(data);
return Convert.ToHexStringLower(hash);
}
}
/// <summary>
/// Client interface for VEX signing operations (delegates to Signer service).
/// </summary>
public interface IVexSignerClient
{
/// <summary>
/// Signs a VEX payload.
/// </summary>
Task<VexSignerResult> SignAsync(VexSignerRequest request, CancellationToken cancellationToken = default);
}
/// <summary>
/// Request to sign a VEX payload.
/// </summary>
public sealed record VexSignerRequest
{
public required string PayloadType { get; init; }
public required string PayloadBase64 { get; init; }
public string? KeyId { get; init; }
public required string TenantId { get; init; }
}
/// <summary>
/// Result from VEX signing.
/// </summary>
public sealed record VexSignerResult
{
public bool Success { get; init; }
public string? Signature { get; init; }
public string? KeyId { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// Client interface for Rekor operations.
/// </summary>
public interface IVexRekorClient
{
/// <summary>
/// Submits a VEX envelope to Rekor.
/// </summary>
Task<VexRekorSubmitResult> SubmitAsync(VexRekorSubmitRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a Rekor proof by UUID.
/// </summary>
Task<VexRekorMetadata?> GetProofAsync(string uuid, CancellationToken cancellationToken = default);
}
/// <summary>
/// Request to submit to Rekor.
/// </summary>
public sealed record VexRekorSubmitRequest
{
public required VexDsseEnvelope Envelope { get; init; }
public required string EnvelopeDigest { get; init; }
public string? ArtifactKind { get; init; }
public IReadOnlyList<string>? SubjectUris { get; init; }
}
/// <summary>
/// Result of Rekor submission.
/// </summary>
public sealed record VexRekorSubmitResult
{
public bool Success { get; init; }
public VexRekorMetadata? Metadata { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// Options for VEX signing service.
/// </summary>
public sealed class VexSigningOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "VexSigning";
/// <summary>
/// Whether to use the Signer service (true) or local signing (false).
/// </summary>
public bool UseSignerService { get; set; } = true;
/// <summary>
/// Whether Rekor submission is enabled.
/// </summary>
public bool RekorEnabled { get; set; } = true;
/// <summary>
/// Default key ID for signing (null for keyless).
/// </summary>
public string? DefaultKeyId { get; set; }
/// <summary>
/// Rekor log URL.
/// </summary>
public Uri? RekorUrl { get; set; }
/// <summary>
/// Timeout for Rekor operations.
/// </summary>
public TimeSpan RekorTimeout { get; set; } = TimeSpan.FromSeconds(30);
}

View File

@@ -0,0 +1,339 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using MsOptions = Microsoft.Extensions.Options.Options;
using Moq;
using Moq.Protected;
using StellaOps.Policy.Engine.ReachabilityFacts;
using Xunit;
namespace StellaOps.Policy.Engine.Tests.ReachabilityFacts;
public sealed class ReachabilityFactsSignalsClientTests
{
private readonly Mock<HttpMessageHandler> _mockHandler;
private readonly ReachabilityFactsSignalsClientOptions _options;
private readonly ReachabilityFactsSignalsClient _client;
public ReachabilityFactsSignalsClientTests()
{
_mockHandler = new Mock<HttpMessageHandler>();
_options = new ReachabilityFactsSignalsClientOptions
{
BaseUri = new Uri("https://signals.example.com/"),
MaxConcurrentRequests = 5,
Timeout = TimeSpan.FromSeconds(30)
};
var httpClient = new HttpClient(_mockHandler.Object)
{
BaseAddress = _options.BaseUri
};
_client = new ReachabilityFactsSignalsClient(
httpClient,
MsOptions.Create(_options),
NullLogger<ReachabilityFactsSignalsClient>.Instance);
}
[Fact]
public async Task GetBySubjectAsync_ReturnsNull_WhenNotFound()
{
SetupMockResponse(HttpStatusCode.NotFound);
var result = await _client.GetBySubjectAsync("pkg:maven/foo@1.0|CVE-2025-001");
Assert.Null(result);
}
[Fact]
public async Task GetBySubjectAsync_ReturnsFact_WhenFound()
{
var response = CreateSignalsResponse("fact-1", 0.85);
SetupMockResponse(HttpStatusCode.OK, response);
var result = await _client.GetBySubjectAsync("pkg:maven/foo@1.0|CVE-2025-001");
Assert.NotNull(result);
Assert.Equal("fact-1", result.Id);
Assert.Equal(0.85, result.Score);
}
[Fact]
public async Task GetBySubjectAsync_CallsCorrectEndpoint()
{
var response = CreateSignalsResponse("fact-1", 0.85);
string? capturedUri = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
{
capturedUri = req.RequestUri?.ToString();
})
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = JsonContent.Create(response)
});
await _client.GetBySubjectAsync("pkg:maven/foo@1.0|CVE-2025-001");
Assert.NotNull(capturedUri);
Assert.Contains("signals/facts/", capturedUri);
}
[Fact]
public async Task GetBySubjectAsync_ThrowsOnServerError()
{
SetupMockResponse(HttpStatusCode.InternalServerError);
await Assert.ThrowsAsync<HttpRequestException>(
() => _client.GetBySubjectAsync("pkg:maven/foo@1.0|CVE-2025-001"));
}
[Fact]
public async Task GetBatchBySubjectsAsync_ReturnsEmptyDict_WhenNoKeys()
{
var result = await _client.GetBatchBySubjectsAsync([]);
Assert.Empty(result);
}
[Fact]
public async Task GetBatchBySubjectsAsync_FetchesInParallel()
{
var responses = new Dictionary<string, SignalsReachabilityFactResponse>
{
["pkg:maven/foo@1.0|CVE-2025-001"] = CreateSignalsResponse("fact-1", 0.9),
["pkg:maven/bar@2.0|CVE-2025-002"] = CreateSignalsResponse("fact-2", 0.8)
};
int callCount = 0;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync((HttpRequestMessage req, CancellationToken _) =>
{
Interlocked.Increment(ref callCount);
var path = req.RequestUri?.AbsolutePath ?? "";
// Decode the path to find the key
foreach (var kvp in responses)
{
var encodedKey = Uri.EscapeDataString(kvp.Key);
if (path.Contains(encodedKey))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = JsonContent.Create(kvp.Value)
};
}
}
return new HttpResponseMessage(HttpStatusCode.NotFound);
});
var keys = responses.Keys.ToList();
var result = await _client.GetBatchBySubjectsAsync(keys);
Assert.Equal(2, result.Count);
Assert.Equal(2, callCount);
}
[Fact]
public async Task GetBatchBySubjectsAsync_ReturnsOnlyFound()
{
var response = CreateSignalsResponse("fact-1", 0.9);
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync((HttpRequestMessage req, CancellationToken _) =>
{
var path = req.RequestUri?.AbsolutePath ?? "";
if (path.Contains(Uri.EscapeDataString("pkg:maven/foo@1.0|CVE-2025-001")))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = JsonContent.Create(response)
};
}
return new HttpResponseMessage(HttpStatusCode.NotFound);
});
var keys = new List<string>
{
"pkg:maven/foo@1.0|CVE-2025-001",
"pkg:maven/bar@2.0|CVE-2025-002"
};
var result = await _client.GetBatchBySubjectsAsync(keys);
Assert.Single(result);
Assert.True(result.ContainsKey("pkg:maven/foo@1.0|CVE-2025-001"));
}
[Fact]
public async Task TriggerRecomputeAsync_ReturnsTrue_OnSuccess()
{
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
var request = new SignalsRecomputeRequest
{
SubjectKey = "pkg:maven/foo@1.0|CVE-2025-001",
TenantId = "tenant-1"
};
var result = await _client.TriggerRecomputeAsync(request);
Assert.True(result);
}
[Fact]
public async Task TriggerRecomputeAsync_ReturnsFalse_OnFailure()
{
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.BadRequest));
var request = new SignalsRecomputeRequest
{
SubjectKey = "pkg:maven/foo@1.0|CVE-2025-001",
TenantId = "tenant-1"
};
var result = await _client.TriggerRecomputeAsync(request);
Assert.False(result);
}
[Fact]
public async Task TriggerRecomputeAsync_PostsToCorrectEndpoint()
{
string? capturedUri = null;
string? capturedBody = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>(async (req, _) =>
{
capturedUri = req.RequestUri?.ToString();
if (req.Content is not null)
{
capturedBody = await req.Content.ReadAsStringAsync();
}
})
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
var request = new SignalsRecomputeRequest
{
SubjectKey = "pkg:maven/foo@1.0|CVE-2025-001",
TenantId = "tenant-1"
};
await _client.TriggerRecomputeAsync(request);
Assert.NotNull(capturedUri);
Assert.Contains("signals/reachability/recompute", capturedUri);
Assert.NotNull(capturedBody);
Assert.Contains("subjectKey", capturedBody);
Assert.Contains("tenantId", capturedBody);
}
[Fact]
public async Task TriggerRecomputeAsync_ReturnsFalse_OnException()
{
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Connection failed"));
var request = new SignalsRecomputeRequest
{
SubjectKey = "pkg:maven/foo@1.0|CVE-2025-001",
TenantId = "tenant-1"
};
var result = await _client.TriggerRecomputeAsync(request);
Assert.False(result);
}
// Options Tests
[Fact]
public void Options_HasCorrectDefaults()
{
var options = new ReachabilityFactsSignalsClientOptions();
Assert.Null(options.BaseUri);
Assert.Equal(10, options.MaxConcurrentRequests);
Assert.Equal(TimeSpan.FromSeconds(30), options.Timeout);
Assert.Equal(3, options.RetryCount);
}
[Fact]
public void Options_SectionName_IsCorrect()
{
Assert.Equal("ReachabilitySignals", ReachabilityFactsSignalsClientOptions.SectionName);
}
private void SetupMockResponse(HttpStatusCode statusCode, SignalsReachabilityFactResponse? content = null)
{
var response = new HttpResponseMessage(statusCode);
if (content is not null)
{
response.Content = JsonContent.Create(content);
}
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(response);
}
private static SignalsReachabilityFactResponse CreateSignalsResponse(string id, double score)
{
return new SignalsReachabilityFactResponse
{
Id = id,
CallgraphId = "cg-test",
Score = score,
States = new List<SignalsReachabilityState>
{
new()
{
Target = "test_method",
Reachable = true,
Confidence = 0.9,
Bucket = "reachable"
}
},
ComputedAt = DateTimeOffset.UtcNow
};
}
}

View File

@@ -0,0 +1,369 @@
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Policy.Engine.ReachabilityFacts;
using Xunit;
namespace StellaOps.Policy.Engine.Tests.ReachabilityFacts;
public sealed class SignalsBackedReachabilityFactsStoreTests
{
private readonly Mock<IReachabilityFactsSignalsClient> _mockClient;
private readonly SignalsBackedReachabilityFactsStore _store;
public SignalsBackedReachabilityFactsStoreTests()
{
_mockClient = new Mock<IReachabilityFactsSignalsClient>();
_store = new SignalsBackedReachabilityFactsStore(
_mockClient.Object,
NullLogger<SignalsBackedReachabilityFactsStore>.Instance,
TimeProvider.System);
}
[Fact]
public async Task GetAsync_ReturnsNull_WhenSignalsReturnsNull()
{
_mockClient.Setup(c => c.GetBySubjectAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((SignalsReachabilityFactResponse?)null);
var result = await _store.GetAsync("tenant-1", "pkg:maven/com.example/foo@1.0.0", "CVE-2025-12345");
Assert.Null(result);
}
[Fact]
public async Task GetAsync_MapsSignalsResponse_ToReachabilityFact()
{
var signalsResponse = CreateSignalsResponse(reachable: true, confidence: 0.95);
_mockClient.Setup(c => c.GetBySubjectAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(signalsResponse);
var result = await _store.GetAsync("tenant-1", "pkg:maven/com.example/foo@1.0.0", "CVE-2025-12345");
Assert.NotNull(result);
Assert.Equal("tenant-1", result.TenantId);
Assert.Equal("pkg:maven/com.example/foo@1.0.0", result.ComponentPurl);
Assert.Equal("CVE-2025-12345", result.AdvisoryId);
Assert.Equal(ReachabilityState.Reachable, result.State);
Assert.Equal("signals", result.Source);
}
[Fact]
public async Task GetAsync_BuildsCorrectSubjectKey()
{
var signalsResponse = CreateSignalsResponse(reachable: true, confidence: 0.9);
string? capturedKey = null;
_mockClient.Setup(c => c.GetBySubjectAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback<string, CancellationToken>((key, _) => capturedKey = key)
.ReturnsAsync(signalsResponse);
await _store.GetAsync("tenant-1", "pkg:maven/com.example/foo@1.0.0", "CVE-2025-12345");
Assert.Equal("pkg:maven/com.example/foo@1.0.0|CVE-2025-12345", capturedKey);
}
[Fact]
public async Task GetBatchAsync_ReturnsEmptyDict_WhenNoKeysProvided()
{
var result = await _store.GetBatchAsync([]);
Assert.Empty(result);
_mockClient.Verify(c => c.GetBatchBySubjectsAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task GetBatchAsync_MapsBatchResponse()
{
var keys = new List<ReachabilityFactKey>
{
new("tenant-1", "pkg:maven/foo@1.0", "CVE-2025-001"),
new("tenant-1", "pkg:maven/bar@2.0", "CVE-2025-002")
};
var responses = new Dictionary<string, SignalsReachabilityFactResponse>
{
["pkg:maven/foo@1.0|CVE-2025-001"] = CreateSignalsResponse(reachable: true, confidence: 0.9),
["pkg:maven/bar@2.0|CVE-2025-002"] = CreateSignalsResponse(reachable: false, confidence: 0.8)
};
_mockClient.Setup(c => c.GetBatchBySubjectsAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(responses);
var result = await _store.GetBatchAsync(keys);
Assert.Equal(2, result.Count);
Assert.Contains(keys[0], result.Keys);
Assert.Contains(keys[1], result.Keys);
}
[Fact]
public async Task GetBatchAsync_OnlyReturnsFound()
{
var keys = new List<ReachabilityFactKey>
{
new("tenant-1", "pkg:maven/foo@1.0", "CVE-2025-001"),
new("tenant-1", "pkg:maven/bar@2.0", "CVE-2025-002")
};
// Only return first key
var responses = new Dictionary<string, SignalsReachabilityFactResponse>
{
["pkg:maven/foo@1.0|CVE-2025-001"] = CreateSignalsResponse(reachable: true, confidence: 0.9)
};
_mockClient.Setup(c => c.GetBatchBySubjectsAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(responses);
var result = await _store.GetBatchAsync(keys);
Assert.Single(result);
Assert.Contains(keys[0], result.Keys);
Assert.DoesNotContain(keys[1], result.Keys);
}
// State Determination Tests
[Fact]
public async Task DeterminesState_Reachable_WhenHasReachableStates()
{
var response = CreateSignalsResponse(reachable: true, confidence: 0.9);
_mockClient.Setup(c => c.GetBySubjectAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(response);
var result = await _store.GetAsync("tenant-1", "pkg:maven/foo@1.0", "CVE-2025-001");
Assert.NotNull(result);
Assert.Equal(ReachabilityState.Reachable, result.State);
}
[Fact]
public async Task DeterminesState_Unreachable_WhenHighConfidenceUnreachable()
{
var response = CreateSignalsResponse(reachable: false, confidence: 0.8);
_mockClient.Setup(c => c.GetBySubjectAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(response);
var result = await _store.GetAsync("tenant-1", "pkg:maven/foo@1.0", "CVE-2025-001");
Assert.NotNull(result);
Assert.Equal(ReachabilityState.Unreachable, result.State);
}
[Fact]
public async Task DeterminesState_UnderInvestigation_WhenLowConfidenceUnreachable()
{
var response = CreateSignalsResponse(reachable: false, confidence: 0.5);
_mockClient.Setup(c => c.GetBySubjectAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(response);
var result = await _store.GetAsync("tenant-1", "pkg:maven/foo@1.0", "CVE-2025-001");
Assert.NotNull(result);
Assert.Equal(ReachabilityState.UnderInvestigation, result.State);
}
[Fact]
public async Task DeterminesState_Unknown_WhenNoStates()
{
var response = new SignalsReachabilityFactResponse
{
Id = "fact-1",
CallgraphId = "cg-1",
States = null,
Score = 0,
ComputedAt = DateTimeOffset.UtcNow
};
_mockClient.Setup(c => c.GetBySubjectAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(response);
var result = await _store.GetAsync("tenant-1", "pkg:maven/foo@1.0", "CVE-2025-001");
Assert.NotNull(result);
Assert.Equal(ReachabilityState.Unknown, result.State);
}
// Analysis Method Tests
[Fact]
public async Task DeterminesMethod_Hybrid_WhenBothStaticAndRuntime()
{
var response = CreateSignalsResponse(reachable: true, confidence: 0.9);
response = response with
{
RuntimeFacts = new List<SignalsRuntimeFact>
{
new() { SymbolId = "sym1", HitCount = 5 }
}
};
_mockClient.Setup(c => c.GetBySubjectAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(response);
var result = await _store.GetAsync("tenant-1", "pkg:maven/foo@1.0", "CVE-2025-001");
Assert.NotNull(result);
Assert.Equal(AnalysisMethod.Hybrid, result.Method);
Assert.True(result.HasRuntimeEvidence);
}
[Fact]
public async Task DeterminesMethod_Static_WhenOnlyStates()
{
var response = CreateSignalsResponse(reachable: true, confidence: 0.9);
_mockClient.Setup(c => c.GetBySubjectAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(response);
var result = await _store.GetAsync("tenant-1", "pkg:maven/foo@1.0", "CVE-2025-001");
Assert.NotNull(result);
Assert.Equal(AnalysisMethod.Static, result.Method);
Assert.False(result.HasRuntimeEvidence);
}
// Metadata Extraction Tests
[Fact]
public async Task ExtractsMetadata_FromSignalsResponse()
{
var response = new SignalsReachabilityFactResponse
{
Id = "fact-1",
CallgraphId = "cg-123",
Subject = new SignalsSubject
{
ScanId = "scan-456",
ImageDigest = "sha256:abc"
},
States = new List<SignalsReachabilityState>
{
new()
{
Target = "vulnerable_method",
Reachable = true,
Confidence = 0.9,
Path = new List<string> { "main", "handler", "vulnerable_method" },
LatticeState = "CR"
}
},
EntryPoints = new List<string> { "main" },
Uncertainty = new SignalsUncertainty { AggregateTier = "T3", RiskScore = 0.2 },
UnknownsCount = 5,
UnknownsPressure = 0.1,
RiskScore = 0.3,
Score = 0.85,
ComputedAt = DateTimeOffset.UtcNow
};
_mockClient.Setup(c => c.GetBySubjectAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(response);
var result = await _store.GetAsync("tenant-1", "pkg:maven/foo@1.0", "CVE-2025-001");
Assert.NotNull(result);
Assert.NotNull(result.Metadata);
Assert.Equal("cg-123", result.Metadata["callgraph_id"]);
Assert.Equal("scan-456", result.Metadata["scan_id"]);
Assert.Equal("sha256:abc", result.Metadata["image_digest"]);
Assert.Equal("T3", result.Metadata["uncertainty_tier"]);
Assert.Equal(5, result.Metadata["unknowns_count"]);
}
// Read-only Store Tests
[Fact]
public async Task SaveAsync_DoesNotCallClient()
{
var fact = CreateReachabilityFact();
await _store.SaveAsync(fact);
_mockClient.Verify(c => c.GetBySubjectAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task SaveBatchAsync_DoesNotCallClient()
{
var facts = new List<ReachabilityFact> { CreateReachabilityFact() };
await _store.SaveBatchAsync(facts);
_mockClient.Verify(c => c.GetBySubjectAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task DeleteAsync_DoesNotCallClient()
{
await _store.DeleteAsync("tenant-1", "pkg:maven/foo@1.0", "CVE-2025-001");
_mockClient.Verify(c => c.GetBySubjectAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task CountAsync_ReturnsZero()
{
var count = await _store.CountAsync("tenant-1");
Assert.Equal(0L, count);
}
[Fact]
public async Task QueryAsync_ReturnsEmpty()
{
var query = new ReachabilityFactsQuery { TenantId = "tenant-1" };
var result = await _store.QueryAsync(query);
Assert.Empty(result);
}
// TriggerRecompute Tests
[Fact]
public async Task TriggerRecomputeAsync_DelegatesToClient()
{
_mockClient.Setup(c => c.TriggerRecomputeAsync(It.IsAny<SignalsRecomputeRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var result = await _store.TriggerRecomputeAsync("tenant-1", "pkg:maven/foo@1.0|CVE-2025-001");
Assert.True(result);
_mockClient.Verify(c => c.TriggerRecomputeAsync(
It.Is<SignalsRecomputeRequest>(r => r.SubjectKey == "pkg:maven/foo@1.0|CVE-2025-001" && r.TenantId == "tenant-1"),
It.IsAny<CancellationToken>()), Times.Once);
}
private static SignalsReachabilityFactResponse CreateSignalsResponse(bool reachable, double confidence)
{
return new SignalsReachabilityFactResponse
{
Id = $"fact-{Guid.NewGuid():N}",
CallgraphId = "cg-test",
States = new List<SignalsReachabilityState>
{
new()
{
Target = "vulnerable_method",
Reachable = reachable,
Confidence = confidence,
Bucket = reachable ? "reachable" : "unreachable"
}
},
Score = reachable ? 0.9 : 0.1,
ComputedAt = DateTimeOffset.UtcNow
};
}
private static ReachabilityFact CreateReachabilityFact()
{
return new ReachabilityFact
{
Id = "fact-1",
TenantId = "tenant-1",
ComponentPurl = "pkg:maven/foo@1.0",
AdvisoryId = "CVE-2025-001",
State = ReachabilityState.Reachable,
Confidence = 0.9m,
Source = "test",
Method = AnalysisMethod.Static,
ComputedAt = DateTimeOffset.UtcNow
};
}
}

View File

@@ -24,6 +24,7 @@
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Moq" Version="4.20.70" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,606 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Engine.Gates;
using StellaOps.Policy.Engine.ReachabilityFacts;
using StellaOps.Policy.Engine.Vex;
using Xunit;
namespace StellaOps.Policy.Engine.Tests.Vex;
public class VexDecisionEmitterTests
{
private const string TestTenantId = "test-tenant";
private const string TestVulnId = "CVE-2021-44228";
private const string TestPurl = "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1";
[Fact]
public async Task EmitAsync_WithUnreachableFact_EmitsNotAffected()
{
// Arrange
var fact = CreateFact(ReachabilityState.Unreachable, hasRuntime: true, confidence: 0.95m);
var factsService = CreateMockFactsService(fact);
var gateEvaluator = CreateMockGateEvaluator(PolicyGateDecisionType.Allow);
var emitter = CreateEmitter(factsService, gateEvaluator);
var request = new VexDecisionEmitRequest
{
TenantId = TestTenantId,
Author = "test@example.com",
Findings = new[]
{
new VexFindingInput { VulnId = TestVulnId, Purl = TestPurl }
}
};
// Act
var result = await emitter.EmitAsync(request);
// Assert
Assert.NotNull(result.Document);
Assert.Single(result.Document.Statements);
var statement = result.Document.Statements[0];
Assert.Equal("not_affected", statement.Status);
Assert.Equal(VexJustification.VulnerableCodeNotInExecutePath, statement.Justification);
Assert.Empty(result.Blocked);
}
[Fact]
public async Task EmitAsync_WithReachableFact_EmitsAffected()
{
// Arrange
var fact = CreateFact(ReachabilityState.Reachable, hasRuntime: true, confidence: 0.9m);
var factsService = CreateMockFactsService(fact);
var gateEvaluator = CreateMockGateEvaluator(PolicyGateDecisionType.Allow);
var emitter = CreateEmitter(factsService, gateEvaluator);
var request = new VexDecisionEmitRequest
{
TenantId = TestTenantId,
Author = "test@example.com",
Findings = new[]
{
new VexFindingInput { VulnId = TestVulnId, Purl = TestPurl }
}
};
// Act
var result = await emitter.EmitAsync(request);
// Assert
Assert.NotNull(result.Document);
Assert.Single(result.Document.Statements);
var statement = result.Document.Statements[0];
Assert.Equal("affected", statement.Status);
Assert.Null(statement.Justification);
Assert.Empty(result.Blocked);
}
[Fact]
public async Task EmitAsync_WithUnknownFact_EmitsUnderInvestigation()
{
// Arrange
var fact = CreateFact(ReachabilityState.Unknown, hasRuntime: false, confidence: 0.0m);
var factsService = CreateMockFactsService(fact);
var gateEvaluator = CreateMockGateEvaluator(PolicyGateDecisionType.Allow);
var emitter = CreateEmitter(factsService, gateEvaluator);
var request = new VexDecisionEmitRequest
{
TenantId = TestTenantId,
Author = "test@example.com",
Findings = new[]
{
new VexFindingInput { VulnId = TestVulnId, Purl = TestPurl }
}
};
// Act
var result = await emitter.EmitAsync(request);
// Assert
Assert.NotNull(result.Document);
Assert.Single(result.Document.Statements);
var statement = result.Document.Statements[0];
Assert.Equal("under_investigation", statement.Status);
Assert.Empty(result.Blocked);
}
[Fact]
public async Task EmitAsync_WhenGateBlocks_FallsBackToUnderInvestigation()
{
// Arrange
var fact = CreateFact(ReachabilityState.Unreachable, hasRuntime: false, confidence: 0.5m);
var factsService = CreateMockFactsService(fact);
var gateEvaluator = CreateMockGateEvaluator(PolicyGateDecisionType.Block, blockedBy: "EvidenceCompleteness", reason: "graphHash required");
var emitter = CreateEmitter(factsService, gateEvaluator);
var request = new VexDecisionEmitRequest
{
TenantId = TestTenantId,
Author = "test@example.com",
Findings = new[]
{
new VexFindingInput { VulnId = TestVulnId, Purl = TestPurl }
}
};
// Act
var result = await emitter.EmitAsync(request);
// Assert
Assert.Single(result.Blocked);
Assert.Equal(TestVulnId, result.Blocked[0].VulnId);
Assert.Equal("EvidenceCompleteness", result.Blocked[0].BlockedBy);
// With FallbackToUnderInvestigation=true (default), still emits under_investigation
Assert.Single(result.Document.Statements);
Assert.Equal("under_investigation", result.Document.Statements[0].Status);
}
[Fact]
public async Task EmitAsync_WithOverride_UsesOverrideStatus()
{
// Arrange
var fact = CreateFact(ReachabilityState.Reachable, hasRuntime: true, confidence: 0.9m);
var factsService = CreateMockFactsService(fact);
var gateEvaluator = CreateMockGateEvaluator(PolicyGateDecisionType.Allow);
var emitter = CreateEmitter(factsService, gateEvaluator);
var request = new VexDecisionEmitRequest
{
TenantId = TestTenantId,
Author = "test@example.com",
Findings = new[]
{
new VexFindingInput
{
VulnId = TestVulnId,
Purl = TestPurl,
OverrideStatus = "not_affected",
OverrideJustification = "Manual review confirmed unreachable"
}
}
};
// Act
var result = await emitter.EmitAsync(request);
// Assert
Assert.Single(result.Document.Statements);
Assert.Equal("not_affected", result.Document.Statements[0].Status);
}
[Fact]
public async Task EmitAsync_IncludesEvidenceBlock()
{
// Arrange
var fact = CreateFact(ReachabilityState.Unreachable, hasRuntime: true, confidence: 0.95m);
fact = fact with
{
EvidenceHash = "blake3:abc123",
Metadata = new Dictionary<string, object?>
{
["call_path"] = new List<object> { "main", "svc", "target" },
["entry_points"] = new List<object> { "main" },
["runtime_hits"] = new List<object> { "main", "svc" },
["uncertainty_tier"] = "T3",
["risk_score"] = 0.25
}
};
var factsService = CreateMockFactsService(fact);
var gateEvaluator = CreateMockGateEvaluator(PolicyGateDecisionType.Allow);
var emitter = CreateEmitter(factsService, gateEvaluator);
var request = new VexDecisionEmitRequest
{
TenantId = TestTenantId,
Author = "test@example.com",
IncludeEvidence = true,
Findings = new[]
{
new VexFindingInput { VulnId = TestVulnId, Purl = TestPurl }
}
};
// Act
var result = await emitter.EmitAsync(request);
// Assert
var statement = result.Document.Statements[0];
Assert.NotNull(statement.Evidence);
Assert.Equal("CU", statement.Evidence.LatticeState);
Assert.Equal(0.95, statement.Evidence.Confidence);
Assert.Equal("blake3:abc123", statement.Evidence.GraphHash);
Assert.Equal("T3", statement.Evidence.UncertaintyTier);
Assert.Equal(0.25, statement.Evidence.RiskScore);
Assert.NotNull(statement.Evidence.CallPath);
Assert.Equal(new[] { "main", "svc", "target" }, statement.Evidence.CallPath.Value.ToArray());
}
[Fact]
public async Task EmitAsync_WithMultipleFindings_EmitsMultipleStatements()
{
// Arrange
var facts = new Dictionary<ReachabilityFactKey, ReachabilityFact>
{
[new(TestTenantId, TestPurl, "CVE-2021-44228")] = CreateFact(ReachabilityState.Unreachable, hasRuntime: true, confidence: 0.95m, vulnId: "CVE-2021-44228"),
[new(TestTenantId, "pkg:npm/lodash@4.17.20", "CVE-2021-23337")] = CreateFact(ReachabilityState.Reachable, hasRuntime: false, confidence: 0.8m, vulnId: "CVE-2021-23337", purl: "pkg:npm/lodash@4.17.20")
};
var factsService = CreateMockFactsService(facts);
var gateEvaluator = CreateMockGateEvaluator(PolicyGateDecisionType.Allow);
var emitter = CreateEmitter(factsService, gateEvaluator);
var request = new VexDecisionEmitRequest
{
TenantId = TestTenantId,
Author = "test@example.com",
Findings = new[]
{
new VexFindingInput { VulnId = "CVE-2021-44228", Purl = TestPurl },
new VexFindingInput { VulnId = "CVE-2021-23337", Purl = "pkg:npm/lodash@4.17.20" }
}
};
// Act
var result = await emitter.EmitAsync(request);
// Assert
Assert.Equal(2, result.Document.Statements.Length);
Assert.Contains(result.Document.Statements, s => s.Status == "not_affected");
Assert.Contains(result.Document.Statements, s => s.Status == "affected");
}
[Fact]
public async Task EmitAsync_DocumentHasCorrectMetadata()
{
// Arrange
var factsService = CreateMockFactsService((ReachabilityFact?)null);
var gateEvaluator = CreateMockGateEvaluator(PolicyGateDecisionType.Allow);
var emitter = CreateEmitter(factsService, gateEvaluator);
var request = new VexDecisionEmitRequest
{
TenantId = TestTenantId,
Author = "security-team@company.com",
Findings = new[]
{
new VexFindingInput { VulnId = TestVulnId, Purl = TestPurl }
}
};
// Act
var result = await emitter.EmitAsync(request);
// Assert
Assert.StartsWith("urn:uuid:", result.Document.Id);
Assert.Equal("https://openvex.dev/ns/v0.2.0", result.Document.Context);
Assert.Equal("security-team@company.com", result.Document.Author);
Assert.Equal("policy_engine", result.Document.Role);
Assert.Equal("stellaops/policy-engine", result.Document.Tooling);
Assert.Equal(1, result.Document.Version);
}
[Fact]
public async Task DetermineStatusAsync_ReturnsCorrectBucket()
{
// Arrange
var fact = CreateFact(ReachabilityState.Reachable, hasRuntime: true, confidence: 0.9m);
var factsService = CreateMockFactsService(fact);
var gateEvaluator = CreateMockGateEvaluator(PolicyGateDecisionType.Allow);
var emitter = CreateEmitter(factsService, gateEvaluator);
// Act
var determination = await emitter.DetermineStatusAsync(TestTenantId, TestVulnId, TestPurl);
// Assert
Assert.Equal("affected", determination.Status);
Assert.Equal("runtime", determination.Bucket);
Assert.Equal("CR", determination.LatticeState);
Assert.Equal(0.9, determination.Confidence);
Assert.NotNull(determination.Fact);
}
[Fact]
public async Task EmitAsync_WithSymbolId_IncludesSubcomponent()
{
// Arrange
var factsService = CreateMockFactsService((ReachabilityFact?)null);
var gateEvaluator = CreateMockGateEvaluator(PolicyGateDecisionType.Allow);
var emitter = CreateEmitter(factsService, gateEvaluator);
var request = new VexDecisionEmitRequest
{
TenantId = TestTenantId,
Author = "test@example.com",
Findings = new[]
{
new VexFindingInput
{
VulnId = TestVulnId,
Purl = TestPurl,
SymbolId = "org.apache.logging.log4j.core.lookup.JndiLookup.lookup"
}
}
};
// Act
var result = await emitter.EmitAsync(request);
// Assert
var statement = result.Document.Statements[0];
Assert.NotNull(statement.Products[0].Subcomponents);
var subcomponents = statement.Products[0].Subcomponents!.Value;
Assert.Single(subcomponents);
Assert.Equal("org.apache.logging.log4j.core.lookup.JndiLookup.lookup", subcomponents[0].Id);
}
private static ReachabilityFact CreateFact(
ReachabilityState state,
bool hasRuntime,
decimal confidence,
string? vulnId = null,
string? purl = null)
{
return new ReachabilityFact
{
Id = Guid.NewGuid().ToString("N"),
TenantId = TestTenantId,
ComponentPurl = purl ?? TestPurl,
AdvisoryId = vulnId ?? TestVulnId,
State = state,
Confidence = confidence,
Score = (decimal)(hasRuntime ? 0.9 : 0.5),
HasRuntimeEvidence = hasRuntime,
Source = "stellaops/signals",
Method = hasRuntime ? AnalysisMethod.Hybrid : AnalysisMethod.Static,
ComputedAt = DateTimeOffset.UtcNow,
EvidenceRef = "cas://reachability/graphs/test"
};
}
private static ReachabilityFactsJoiningService CreateMockFactsService(ReachabilityFact? fact)
{
var facts = new Dictionary<ReachabilityFactKey, ReachabilityFact>();
if (fact is not null)
{
facts[new(fact.TenantId, fact.ComponentPurl, fact.AdvisoryId)] = fact;
}
return CreateMockFactsService(facts);
}
private static ReachabilityFactsJoiningService CreateMockFactsService(Dictionary<ReachabilityFactKey, ReachabilityFact> facts)
{
var store = new InMemoryReachabilityFactsStore(facts);
var cache = new InMemoryReachabilityFactsOverlayCache();
return new ReachabilityFactsJoiningService(
store,
cache,
NullLogger<ReachabilityFactsJoiningService>.Instance,
TimeProvider.System);
}
private static IPolicyGateEvaluator CreateMockGateEvaluator(
PolicyGateDecisionType decision,
string? blockedBy = null,
string? reason = null)
{
return new MockPolicyGateEvaluator(decision, blockedBy, reason);
}
private static VexDecisionEmitter CreateEmitter(
ReachabilityFactsJoiningService factsService,
IPolicyGateEvaluator gateEvaluator)
{
var options = new TestOptionsMonitor<VexDecisionEmitterOptions>(new VexDecisionEmitterOptions());
return new VexDecisionEmitter(
factsService,
gateEvaluator,
options,
TimeProvider.System,
NullLogger<VexDecisionEmitter>.Instance);
}
private sealed class TestOptionsMonitor<T> : IOptionsMonitor<T>
{
public TestOptionsMonitor(T currentValue)
{
CurrentValue = currentValue;
}
public T CurrentValue { get; }
public T Get(string? name) => CurrentValue;
public IDisposable? OnChange(Action<T, string?> listener) => null;
}
private sealed class InMemoryReachabilityFactsStore : IReachabilityFactsStore
{
private readonly Dictionary<ReachabilityFactKey, ReachabilityFact> _facts;
public InMemoryReachabilityFactsStore(Dictionary<ReachabilityFactKey, ReachabilityFact> facts)
{
_facts = facts;
}
public Task<ReachabilityFact?> GetAsync(string tenantId, string componentPurl, string advisoryId, CancellationToken cancellationToken = default)
{
var key = new ReachabilityFactKey(tenantId, componentPurl, advisoryId);
_facts.TryGetValue(key, out var fact);
return Task.FromResult(fact);
}
public Task<IReadOnlyDictionary<ReachabilityFactKey, ReachabilityFact>> GetBatchAsync(IReadOnlyList<ReachabilityFactKey> keys, CancellationToken cancellationToken = default)
{
var result = new Dictionary<ReachabilityFactKey, ReachabilityFact>();
foreach (var key in keys)
{
if (_facts.TryGetValue(key, out var fact))
{
result[key] = fact;
}
}
return Task.FromResult<IReadOnlyDictionary<ReachabilityFactKey, ReachabilityFact>>(result);
}
public Task<IReadOnlyList<ReachabilityFact>> QueryAsync(ReachabilityFactsQuery query, CancellationToken cancellationToken = default)
{
var results = _facts.Values
.Where(f => f.TenantId == query.TenantId)
.Where(f => query.ComponentPurls == null || query.ComponentPurls.Contains(f.ComponentPurl))
.Where(f => query.AdvisoryIds == null || query.AdvisoryIds.Contains(f.AdvisoryId))
.Where(f => query.States == null || query.States.Contains(f.State))
.Where(f => !query.MinConfidence.HasValue || f.Confidence >= query.MinConfidence.Value)
.Skip(query.Skip)
.Take(query.Limit)
.ToList();
return Task.FromResult<IReadOnlyList<ReachabilityFact>>(results);
}
public Task SaveAsync(ReachabilityFact fact, CancellationToken cancellationToken = default)
{
var key = new ReachabilityFactKey(fact.TenantId, fact.ComponentPurl, fact.AdvisoryId);
_facts[key] = fact;
return Task.CompletedTask;
}
public Task SaveBatchAsync(IReadOnlyList<ReachabilityFact> facts, CancellationToken cancellationToken = default)
{
foreach (var fact in facts)
{
var key = new ReachabilityFactKey(fact.TenantId, fact.ComponentPurl, fact.AdvisoryId);
_facts[key] = fact;
}
return Task.CompletedTask;
}
public Task DeleteAsync(string tenantId, string componentPurl, string advisoryId, CancellationToken cancellationToken = default)
{
var key = new ReachabilityFactKey(tenantId, componentPurl, advisoryId);
_facts.Remove(key);
return Task.CompletedTask;
}
public Task<long> CountAsync(string tenantId, CancellationToken cancellationToken = default)
{
var count = _facts.Values.Count(f => f.TenantId == tenantId);
return Task.FromResult((long)count);
}
}
private sealed class InMemoryReachabilityFactsOverlayCache : IReachabilityFactsOverlayCache
{
private readonly Dictionary<ReachabilityFactKey, ReachabilityFact> _cache = new();
public Task<(ReachabilityFact? Fact, bool CacheHit)> GetAsync(ReachabilityFactKey key, CancellationToken cancellationToken = default)
{
if (_cache.TryGetValue(key, out var fact))
{
return Task.FromResult<(ReachabilityFact?, bool)>((fact, true));
}
return Task.FromResult<(ReachabilityFact?, bool)>((null, false));
}
public Task<ReachabilityFactsBatch> GetBatchAsync(IReadOnlyList<ReachabilityFactKey> keys, CancellationToken cancellationToken = default)
{
var found = new Dictionary<ReachabilityFactKey, ReachabilityFact>();
var notFound = new List<ReachabilityFactKey>();
foreach (var key in keys)
{
if (_cache.TryGetValue(key, out var fact))
{
found[key] = fact;
}
else
{
notFound.Add(key);
}
}
return Task.FromResult(new ReachabilityFactsBatch
{
Found = found,
NotFound = notFound,
CacheHits = found.Count,
CacheMisses = notFound.Count
});
}
public Task SetAsync(ReachabilityFactKey key, ReachabilityFact fact, CancellationToken cancellationToken = default)
{
_cache[key] = fact;
return Task.CompletedTask;
}
public Task SetBatchAsync(IReadOnlyDictionary<ReachabilityFactKey, ReachabilityFact> facts, CancellationToken cancellationToken = default)
{
foreach (var (key, fact) in facts)
{
_cache[key] = fact;
}
return Task.CompletedTask;
}
public Task InvalidateAsync(ReachabilityFactKey key, CancellationToken cancellationToken = default)
{
_cache.Remove(key);
return Task.CompletedTask;
}
public Task InvalidateTenantAsync(string tenantId, CancellationToken cancellationToken = default)
{
var keysToRemove = _cache.Keys.Where(k => k.TenantId == tenantId).ToList();
foreach (var key in keysToRemove)
{
_cache.Remove(key);
}
return Task.CompletedTask;
}
public ReachabilityFactsCacheStats GetStats()
{
return new ReachabilityFactsCacheStats { ItemCount = _cache.Count };
}
}
private sealed class MockPolicyGateEvaluator : IPolicyGateEvaluator
{
private readonly PolicyGateDecisionType _decision;
private readonly string? _blockedBy;
private readonly string? _reason;
public MockPolicyGateEvaluator(PolicyGateDecisionType decision, string? blockedBy, string? reason)
{
_decision = decision;
_blockedBy = blockedBy;
_reason = reason;
}
public Task<PolicyGateDecision> EvaluateAsync(PolicyGateRequest request, CancellationToken cancellationToken = default)
{
return Task.FromResult(new PolicyGateDecision
{
GateId = $"gate:vex:{request.RequestedStatus}:{DateTimeOffset.UtcNow:O}",
RequestedStatus = request.RequestedStatus,
Subject = new PolicyGateSubject
{
VulnId = request.VulnId,
Purl = request.Purl
},
Evidence = new PolicyGateEvidence
{
LatticeState = request.LatticeState,
Confidence = request.Confidence
},
Gates = ImmutableArray<PolicyGateResult>.Empty,
Decision = _decision,
BlockedBy = _decision == PolicyGateDecisionType.Block ? _blockedBy : null,
BlockReason = _decision == PolicyGateDecisionType.Block ? _reason : null,
DecidedAt = DateTimeOffset.UtcNow
});
}
}
}

View File

@@ -0,0 +1,470 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Policy.Engine.Vex;
using Xunit;
namespace StellaOps.Policy.Engine.Tests.Vex;
public sealed class VexDecisionSigningServiceTests
{
private readonly Mock<IVexSignerClient> _mockSignerClient;
private readonly Mock<IVexRekorClient> _mockRekorClient;
private readonly VexSigningOptions _options;
private readonly VexDecisionSigningService _service;
public VexDecisionSigningServiceTests()
{
_mockSignerClient = new Mock<IVexSignerClient>();
_mockRekorClient = new Mock<IVexRekorClient>();
_options = new VexSigningOptions
{
UseSignerService = true,
RekorEnabled = true
};
var optionsMonitor = new Mock<IOptionsMonitor<VexSigningOptions>>();
optionsMonitor.Setup(o => o.CurrentValue).Returns(_options);
_service = new VexDecisionSigningService(
_mockSignerClient.Object,
_mockRekorClient.Object,
optionsMonitor.Object,
TimeProvider.System,
NullLogger<VexDecisionSigningService>.Instance);
}
[Fact]
public async Task SignAsync_WithSignerService_ReturnsEnvelope()
{
var document = CreateTestDocument();
var request = CreateSigningRequest(document);
_mockSignerClient.Setup(c => c.SignAsync(It.IsAny<VexSignerRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexSignerResult
{
Success = true,
Signature = Convert.ToBase64String(new byte[32]),
KeyId = "test-key"
});
var result = await _service.SignAsync(request);
Assert.True(result.Success);
Assert.NotNull(result.Envelope);
Assert.NotNull(result.EnvelopeDigest);
Assert.StartsWith("sha256:", result.EnvelopeDigest);
}
[Fact]
public async Task SignAsync_WithSignerServiceFailure_ReturnsFailed()
{
var document = CreateTestDocument();
var request = CreateSigningRequest(document);
_mockSignerClient.Setup(c => c.SignAsync(It.IsAny<VexSignerRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexSignerResult
{
Success = false,
Error = "Signing failed"
});
var result = await _service.SignAsync(request);
Assert.False(result.Success);
Assert.Null(result.Envelope);
Assert.Contains("Signing failed", result.Error);
}
[Fact]
public async Task SignAsync_WithLocalSigning_ReturnsEnvelope()
{
var localOptions = new VexSigningOptions
{
UseSignerService = false,
RekorEnabled = false
};
var optionsMonitor = new Mock<IOptionsMonitor<VexSigningOptions>>();
optionsMonitor.Setup(o => o.CurrentValue).Returns(localOptions);
var service = new VexDecisionSigningService(
null,
null,
optionsMonitor.Object,
TimeProvider.System,
NullLogger<VexDecisionSigningService>.Instance);
var document = CreateTestDocument();
var request = CreateSigningRequest(document, submitToRekor: false);
var result = await service.SignAsync(request);
Assert.True(result.Success);
Assert.NotNull(result.Envelope);
Assert.Single(result.Envelope.Signatures);
Assert.Equal("local:sha256", result.Envelope.Signatures[0].KeyId);
}
[Fact]
public async Task SignAsync_WithRekorEnabled_SubmitsToRekor()
{
var document = CreateTestDocument();
var request = CreateSigningRequest(document, submitToRekor: true);
_mockSignerClient.Setup(c => c.SignAsync(It.IsAny<VexSignerRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexSignerResult
{
Success = true,
Signature = Convert.ToBase64String(new byte[32]),
KeyId = "test-key"
});
_mockRekorClient.Setup(c => c.SubmitAsync(It.IsAny<VexRekorSubmitRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexRekorSubmitResult
{
Success = true,
Metadata = new VexRekorMetadata
{
Uuid = "rekor-uuid-123",
Index = 12345,
LogUrl = "https://rekor.sigstore.dev",
IntegratedAt = DateTimeOffset.UtcNow
}
});
var result = await _service.SignAsync(request);
Assert.True(result.Success);
Assert.NotNull(result.RekorMetadata);
Assert.Equal("rekor-uuid-123", result.RekorMetadata.Uuid);
Assert.Equal(12345, result.RekorMetadata.Index);
}
[Fact]
public async Task SignAsync_WithRekorFailure_StillSucceeds()
{
var document = CreateTestDocument();
var request = CreateSigningRequest(document, submitToRekor: true);
_mockSignerClient.Setup(c => c.SignAsync(It.IsAny<VexSignerRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexSignerResult
{
Success = true,
Signature = Convert.ToBase64String(new byte[32]),
KeyId = "test-key"
});
_mockRekorClient.Setup(c => c.SubmitAsync(It.IsAny<VexRekorSubmitRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexRekorSubmitResult
{
Success = false,
Error = "Rekor unavailable"
});
var result = await _service.SignAsync(request);
Assert.True(result.Success);
Assert.NotNull(result.Envelope);
Assert.Null(result.RekorMetadata);
}
[Fact]
public async Task SignAsync_WithRekorDisabled_DoesNotSubmit()
{
var disabledOptions = new VexSigningOptions
{
UseSignerService = true,
RekorEnabled = false
};
var optionsMonitor = new Mock<IOptionsMonitor<VexSigningOptions>>();
optionsMonitor.Setup(o => o.CurrentValue).Returns(disabledOptions);
var service = new VexDecisionSigningService(
_mockSignerClient.Object,
_mockRekorClient.Object,
optionsMonitor.Object,
TimeProvider.System,
NullLogger<VexDecisionSigningService>.Instance);
var document = CreateTestDocument();
var request = CreateSigningRequest(document, submitToRekor: true);
_mockSignerClient.Setup(c => c.SignAsync(It.IsAny<VexSignerRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexSignerResult
{
Success = true,
Signature = Convert.ToBase64String(new byte[32]),
KeyId = "test-key"
});
var result = await service.SignAsync(request);
Assert.True(result.Success);
Assert.Null(result.RekorMetadata);
_mockRekorClient.Verify(c => c.SubmitAsync(It.IsAny<VexRekorSubmitRequest>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task SignAsync_SetsCorrectPayloadType()
{
var document = CreateTestDocument();
var request = CreateSigningRequest(document);
VexSignerRequest? capturedRequest = null;
_mockSignerClient.Setup(c => c.SignAsync(It.IsAny<VexSignerRequest>(), It.IsAny<CancellationToken>()))
.Callback<VexSignerRequest, CancellationToken>((req, _) => capturedRequest = req)
.ReturnsAsync(new VexSignerResult
{
Success = true,
Signature = Convert.ToBase64String(new byte[32]),
KeyId = "test-key"
});
await _service.SignAsync(request);
Assert.NotNull(capturedRequest);
Assert.Equal(VexPredicateTypes.VexDecision, capturedRequest.PayloadType);
}
// Verification Tests
[Fact]
public async Task VerifyAsync_WithValidEnvelope_ReturnsValid()
{
var document = CreateTestDocument();
var payload = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(document);
var envelope = new VexDsseEnvelope
{
PayloadType = VexPredicateTypes.VexDecision,
Payload = Convert.ToBase64String(payload),
Signatures = [new VexDsseSignature { KeyId = "test", Sig = Convert.ToBase64String(new byte[32]) }]
};
var request = new VexVerificationRequest
{
Envelope = envelope,
VerifyRekorInclusion = false
};
var result = await _service.VerifyAsync(request);
Assert.True(result.Valid);
Assert.NotNull(result.Document);
Assert.Equal(document.Id, result.Document.Id);
}
[Fact]
public async Task VerifyAsync_WithInvalidPayloadType_ReturnsInvalid()
{
var document = CreateTestDocument();
var payload = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(document);
var envelope = new VexDsseEnvelope
{
PayloadType = "invalid/type@v1",
Payload = Convert.ToBase64String(payload),
Signatures = [new VexDsseSignature { KeyId = "test", Sig = Convert.ToBase64String(new byte[32]) }]
};
var request = new VexVerificationRequest
{
Envelope = envelope,
VerifyRekorInclusion = false
};
var result = await _service.VerifyAsync(request);
Assert.False(result.Valid);
Assert.NotNull(result.Errors);
Assert.Contains(result.Errors, e => e.Contains("Invalid payload type"));
}
[Fact]
public async Task VerifyAsync_WithNoSignatures_ReturnsInvalid()
{
var document = CreateTestDocument();
var payload = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(document);
var envelope = new VexDsseEnvelope
{
PayloadType = VexPredicateTypes.VexDecision,
Payload = Convert.ToBase64String(payload),
Signatures = []
};
var request = new VexVerificationRequest
{
Envelope = envelope,
VerifyRekorInclusion = false
};
var result = await _service.VerifyAsync(request);
Assert.False(result.Valid);
Assert.NotNull(result.Errors);
Assert.Contains(result.Errors, e => e.Contains("no signatures"));
}
[Fact]
public async Task VerifyAsync_WithInvalidBase64Signature_ReturnsInvalid()
{
var document = CreateTestDocument();
var payload = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(document);
var envelope = new VexDsseEnvelope
{
PayloadType = VexPredicateTypes.VexDecision,
Payload = Convert.ToBase64String(payload),
Signatures = [new VexDsseSignature { KeyId = "test", Sig = "not-valid-base64!!!" }]
};
var request = new VexVerificationRequest
{
Envelope = envelope,
VerifyRekorInclusion = false
};
var result = await _service.VerifyAsync(request);
Assert.False(result.Valid);
Assert.NotNull(result.Errors);
Assert.Contains(result.Errors, e => e.Contains("Invalid base64"));
}
[Fact]
public async Task VerifyAsync_WithRekorVerification_CallsGetProof()
{
var document = CreateTestDocument();
var payload = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(document);
var envelope = new VexDsseEnvelope
{
PayloadType = VexPredicateTypes.VexDecision,
Payload = Convert.ToBase64String(payload),
Signatures = [new VexDsseSignature { KeyId = "test", Sig = Convert.ToBase64String(new byte[32]) }]
};
var rekorMetadata = new VexRekorMetadata
{
Uuid = "rekor-uuid-123",
Index = 12345,
LogUrl = "https://rekor.sigstore.dev",
IntegratedAt = DateTimeOffset.UtcNow
};
_mockRekorClient.Setup(c => c.GetProofAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(rekorMetadata);
var request = new VexVerificationRequest
{
Envelope = envelope,
ExpectedRekorMetadata = rekorMetadata,
VerifyRekorInclusion = true
};
var result = await _service.VerifyAsync(request);
Assert.True(result.Valid);
Assert.NotNull(result.RekorMetadata);
_mockRekorClient.Verify(c => c.GetProofAsync("rekor-uuid-123", It.IsAny<CancellationToken>()), Times.Once);
}
// Options Tests
[Fact]
public void VexSigningOptions_HasCorrectDefaults()
{
var options = new VexSigningOptions();
Assert.True(options.UseSignerService);
Assert.True(options.RekorEnabled);
Assert.Null(options.DefaultKeyId);
Assert.Null(options.RekorUrl);
Assert.Equal(TimeSpan.FromSeconds(30), options.RekorTimeout);
}
[Fact]
public void VexSigningOptions_SectionName_IsCorrect()
{
Assert.Equal("VexSigning", VexSigningOptions.SectionName);
}
// Predicate Types Tests
[Fact]
public void VexPredicateTypes_HasCorrectValues()
{
Assert.Equal("stella.ops/vexDecision@v1", VexPredicateTypes.VexDecision);
Assert.Equal("stella.ops/vex@v1", VexPredicateTypes.VexDocument);
Assert.Equal("https://openvex.dev/ns", VexPredicateTypes.OpenVex);
}
// Evidence Reference Tests
[Fact]
public async Task SignAsync_WithEvidenceRefs_IncludesInRequest()
{
var document = CreateTestDocument();
var evidenceRefs = new List<VexEvidenceReference>
{
new() { Type = "sbom", Digest = "sha256:abc123" },
new() { Type = "callgraph", Digest = "sha256:def456", CasUri = "cas://example/cg/1" }
};
var request = new VexSigningRequest
{
Document = document,
TenantId = "tenant-1",
SubmitToRekor = false,
EvidenceRefs = evidenceRefs
};
var localOptions = new VexSigningOptions { UseSignerService = false, RekorEnabled = false };
var optionsMonitor = new Mock<IOptionsMonitor<VexSigningOptions>>();
optionsMonitor.Setup(o => o.CurrentValue).Returns(localOptions);
var service = new VexDecisionSigningService(
null,
null,
optionsMonitor.Object,
TimeProvider.System,
NullLogger<VexDecisionSigningService>.Instance);
var result = await service.SignAsync(request);
Assert.True(result.Success);
Assert.NotNull(result.Envelope);
}
private static VexDecisionDocument CreateTestDocument()
{
var now = DateTimeOffset.UtcNow;
return new VexDecisionDocument
{
Id = $"https://stellaops.io/vex/{Guid.NewGuid():N}",
Author = "https://stellaops.io/policy-engine",
Timestamp = now,
Statements = ImmutableArray.Create(
new VexStatement
{
Vulnerability = new VexVulnerability { Id = "CVE-2025-12345" },
Status = "not_affected",
Justification = VexJustification.VulnerableCodeNotInExecutePath,
Timestamp = now,
Products = ImmutableArray.Create(
new VexProduct { Id = "pkg:maven/com.example/app@1.0.0" }
)
}
)
};
}
private static VexSigningRequest CreateSigningRequest(VexDecisionDocument document, bool submitToRekor = true)
{
return new VexSigningRequest
{
Document = document,
TenantId = "tenant-1",
SubmitToRekor = submitToRekor
};
}
}