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
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:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
432
src/Policy/StellaOps.Policy.Engine/Vex/VexDecisionEmitter.cs
Normal file
432
src/Policy/StellaOps.Policy.Engine/Vex/VexDecisionEmitter.cs
Normal 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";
|
||||
}
|
||||
467
src/Policy/StellaOps.Policy.Engine/Vex/VexDecisionModels.cs
Normal file
467
src/Policy/StellaOps.Policy.Engine/Vex/VexDecisionModels.cs
Normal 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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user