Implement VEX document verification system with issuer management and signature verification
- Added IIssuerDirectory interface for managing VEX document issuers, including methods for registration, revocation, and trust validation. - Created InMemoryIssuerDirectory class as an in-memory implementation of IIssuerDirectory for testing and single-instance deployments. - Introduced ISignatureVerifier interface for verifying signatures on VEX documents, with support for multiple signature formats. - Developed SignatureVerifier class as the default implementation of ISignatureVerifier, allowing extensibility for different signature formats. - Implemented handlers for DSSE and JWS signature formats, including methods for verification and signature extraction. - Defined various records and enums for issuer and signature metadata, enhancing the structure and clarity of the verification process.
This commit is contained in:
89
src/Policy/StellaOps.Policy.Engine/.editorconfig
Normal file
89
src/Policy/StellaOps.Policy.Engine/.editorconfig
Normal file
@@ -0,0 +1,89 @@
|
||||
# Policy Engine EditorConfig
|
||||
# Enforces determinism, nullability, and async consistency rules
|
||||
# See: docs/modules/policy/design/policy-aoc-linting-rules.md
|
||||
# Applies only to StellaOps.Policy.Engine project
|
||||
|
||||
root = false
|
||||
|
||||
[*.cs]
|
||||
|
||||
# C# 12+ Style Preferences
|
||||
csharp_style_namespace_declarations = file_scoped:error
|
||||
csharp_style_prefer_primary_constructors = true:suggestion
|
||||
csharp_style_prefer_collection_expression = when_types_loosely_match:suggestion
|
||||
|
||||
# Expression-bodied members
|
||||
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
|
||||
csharp_style_expression_bodied_properties = true:suggestion
|
||||
csharp_style_expression_bodied_accessors = true:suggestion
|
||||
|
||||
# Pattern matching preferences
|
||||
csharp_style_prefer_pattern_matching = true:suggestion
|
||||
csharp_style_prefer_switch_expression = true:suggestion
|
||||
|
||||
# Null checking preferences
|
||||
csharp_style_prefer_null_check_over_type_check = true:suggestion
|
||||
csharp_style_throw_expression = true:suggestion
|
||||
csharp_style_conditional_delegate_call = true:suggestion
|
||||
|
||||
# Code block preferences
|
||||
csharp_prefer_braces = when_multiline:suggestion
|
||||
csharp_prefer_simple_using_statement = true:suggestion
|
||||
|
||||
# Using directive preferences
|
||||
csharp_using_directive_placement = outside_namespace:error
|
||||
|
||||
# var preferences
|
||||
csharp_style_var_for_built_in_types = true:suggestion
|
||||
csharp_style_var_when_type_is_apparent = true:suggestion
|
||||
csharp_style_var_elsewhere = true:suggestion
|
||||
|
||||
# Naming conventions
|
||||
dotnet_naming_rule.interface_should_be_begins_with_i.severity = error
|
||||
dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
|
||||
dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
|
||||
|
||||
dotnet_naming_symbols.interface.applicable_kinds = interface
|
||||
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected
|
||||
dotnet_naming_style.begins_with_i.required_prefix = I
|
||||
dotnet_naming_style.begins_with_i.capitalization = pascal_case
|
||||
|
||||
# Private field naming
|
||||
dotnet_naming_rule.private_fields_should_be_camel_case.severity = suggestion
|
||||
dotnet_naming_rule.private_fields_should_be_camel_case.symbols = private_fields
|
||||
dotnet_naming_rule.private_fields_should_be_camel_case.style = camel_case_underscore
|
||||
|
||||
dotnet_naming_symbols.private_fields.applicable_kinds = field
|
||||
dotnet_naming_symbols.private_fields.applicable_accessibilities = private
|
||||
dotnet_naming_style.camel_case_underscore.required_prefix = _
|
||||
dotnet_naming_style.camel_case_underscore.capitalization = camel_case
|
||||
|
||||
# ===== Code Analysis Rules for Policy Engine =====
|
||||
# These rules are specific to the determinism requirements of the Policy Engine
|
||||
# Note: Rules marked as "baseline" have existing violations that need gradual remediation
|
||||
|
||||
# Async rules - important for deterministic evaluation
|
||||
dotnet_diagnostic.CA2012.severity = error # Do not pass async lambdas to void-returning methods
|
||||
dotnet_diagnostic.CA2007.severity = suggestion # ConfigureAwait - suggestion only
|
||||
dotnet_diagnostic.CA1849.severity = suggestion # Call async methods when in async method (baseline: Redis sync calls)
|
||||
|
||||
# Performance rules - baseline violations exist
|
||||
dotnet_diagnostic.CA1829.severity = suggestion # Use Length/Count instead of Count()
|
||||
dotnet_diagnostic.CA1826.severity = suggestion # Use property instead of Linq (baseline: ~10 violations)
|
||||
dotnet_diagnostic.CA1827.severity = suggestion # Do not use Count when Any can be used
|
||||
dotnet_diagnostic.CA1836.severity = suggestion # Prefer IsEmpty over Count
|
||||
|
||||
# Design rules - relaxed for flexibility
|
||||
dotnet_diagnostic.CA1002.severity = suggestion # Generic list in public API
|
||||
dotnet_diagnostic.CA1031.severity = suggestion # Catch general exception
|
||||
dotnet_diagnostic.CA1062.severity = none # Using ThrowIfNull instead
|
||||
|
||||
# Reliability rules
|
||||
dotnet_diagnostic.CA2011.severity = error # Do not assign property within its setter
|
||||
dotnet_diagnostic.CA2013.severity = error # Do not use ReferenceEquals with value types
|
||||
dotnet_diagnostic.CA2016.severity = suggestion # Forward the CancellationToken parameter
|
||||
|
||||
# Security rules - critical, must remain errors
|
||||
dotnet_diagnostic.CA2100.severity = error # Review SQL queries for security vulnerabilities
|
||||
dotnet_diagnostic.CA5350.severity = error # Do not use weak cryptographic algorithms
|
||||
dotnet_diagnostic.CA5351.severity = error # Do not use broken cryptographic algorithms
|
||||
421
src/Policy/StellaOps.Policy.Engine/AirGap/AirGapNotifications.cs
Normal file
421
src/Policy/StellaOps.Policy.Engine/AirGap/AirGapNotifications.cs
Normal file
@@ -0,0 +1,421 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Policy.Engine.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Notification types for air-gap events.
|
||||
/// </summary>
|
||||
public enum AirGapNotificationType
|
||||
{
|
||||
/// <summary>Staleness warning threshold crossed.</summary>
|
||||
StalenessWarning,
|
||||
|
||||
/// <summary>Staleness breach occurred.</summary>
|
||||
StalenessBreach,
|
||||
|
||||
/// <summary>Staleness recovered.</summary>
|
||||
StalenessRecovered,
|
||||
|
||||
/// <summary>Bundle import started.</summary>
|
||||
BundleImportStarted,
|
||||
|
||||
/// <summary>Bundle import completed.</summary>
|
||||
BundleImportCompleted,
|
||||
|
||||
/// <summary>Bundle import failed.</summary>
|
||||
BundleImportFailed,
|
||||
|
||||
/// <summary>Environment sealed.</summary>
|
||||
EnvironmentSealed,
|
||||
|
||||
/// <summary>Environment unsealed.</summary>
|
||||
EnvironmentUnsealed,
|
||||
|
||||
/// <summary>Time anchor missing.</summary>
|
||||
TimeAnchorMissing,
|
||||
|
||||
/// <summary>Policy pack updated.</summary>
|
||||
PolicyPackUpdated
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notification severity levels.
|
||||
/// </summary>
|
||||
public enum NotificationSeverity
|
||||
{
|
||||
Info,
|
||||
Warning,
|
||||
Error,
|
||||
Critical
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a notification to be delivered.
|
||||
/// </summary>
|
||||
public sealed record AirGapNotification(
|
||||
string NotificationId,
|
||||
string TenantId,
|
||||
AirGapNotificationType Type,
|
||||
NotificationSeverity Severity,
|
||||
string Title,
|
||||
string Message,
|
||||
DateTimeOffset OccurredAt,
|
||||
IDictionary<string, object?>? Metadata = null);
|
||||
|
||||
/// <summary>
|
||||
/// Interface for notification delivery channels.
|
||||
/// </summary>
|
||||
public interface IAirGapNotificationChannel
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the name of this notification channel.
|
||||
/// </summary>
|
||||
string ChannelName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Delivers a notification through this channel.
|
||||
/// </summary>
|
||||
Task<bool> DeliverAsync(AirGapNotification notification, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing air-gap notifications.
|
||||
/// </summary>
|
||||
public interface IAirGapNotificationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends a notification through all configured channels.
|
||||
/// </summary>
|
||||
Task SendAsync(AirGapNotification notification, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sends a staleness-related notification.
|
||||
/// </summary>
|
||||
Task NotifyStalenessEventAsync(
|
||||
string tenantId,
|
||||
StalenessEventType eventType,
|
||||
int ageSeconds,
|
||||
int thresholdSeconds,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sends a bundle import notification.
|
||||
/// </summary>
|
||||
Task NotifyBundleImportAsync(
|
||||
string tenantId,
|
||||
string bundleId,
|
||||
bool success,
|
||||
string? error = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sends a sealed-mode state change notification.
|
||||
/// </summary>
|
||||
Task NotifySealedStateChangeAsync(
|
||||
string tenantId,
|
||||
bool isSealed,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of air-gap notification service.
|
||||
/// </summary>
|
||||
internal sealed class AirGapNotificationService : IAirGapNotificationService, IStalenessEventSink
|
||||
{
|
||||
private readonly IEnumerable<IAirGapNotificationChannel> _channels;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<AirGapNotificationService> _logger;
|
||||
|
||||
public AirGapNotificationService(
|
||||
IEnumerable<IAirGapNotificationChannel> channels,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<AirGapNotificationService> logger)
|
||||
{
|
||||
_channels = channels ?? [];
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task SendAsync(AirGapNotification notification, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(notification);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Sending air-gap notification {NotificationId}: {Type} for tenant {TenantId}",
|
||||
notification.NotificationId, notification.Type, notification.TenantId);
|
||||
|
||||
var deliveryTasks = _channels.Select(channel =>
|
||||
DeliverToChannelAsync(channel, notification, cancellationToken));
|
||||
|
||||
await Task.WhenAll(deliveryTasks).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task DeliverToChannelAsync(
|
||||
IAirGapNotificationChannel channel,
|
||||
AirGapNotification notification,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var delivered = await channel.DeliverAsync(notification, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (delivered)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Notification {NotificationId} delivered via {Channel}",
|
||||
notification.NotificationId, channel.ChannelName);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Notification {NotificationId} delivery to {Channel} returned false",
|
||||
notification.NotificationId, channel.ChannelName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Failed to deliver notification {NotificationId} via {Channel}",
|
||||
notification.NotificationId, channel.ChannelName);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task NotifyStalenessEventAsync(
|
||||
string tenantId,
|
||||
StalenessEventType eventType,
|
||||
int ageSeconds,
|
||||
int thresholdSeconds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (notificationType, severity, title, message) = eventType switch
|
||||
{
|
||||
StalenessEventType.Warning => (
|
||||
AirGapNotificationType.StalenessWarning,
|
||||
NotificationSeverity.Warning,
|
||||
"Staleness Warning",
|
||||
$"Time anchor age ({ageSeconds}s) approaching breach threshold ({thresholdSeconds}s)"),
|
||||
|
||||
StalenessEventType.Breach => (
|
||||
AirGapNotificationType.StalenessBreach,
|
||||
NotificationSeverity.Critical,
|
||||
"Staleness Breach",
|
||||
$"Time anchor staleness breached: age {ageSeconds}s exceeds threshold {thresholdSeconds}s"),
|
||||
|
||||
StalenessEventType.Recovered => (
|
||||
AirGapNotificationType.StalenessRecovered,
|
||||
NotificationSeverity.Info,
|
||||
"Staleness Recovered",
|
||||
"Time anchor has been refreshed, staleness recovered"),
|
||||
|
||||
StalenessEventType.AnchorMissing => (
|
||||
AirGapNotificationType.TimeAnchorMissing,
|
||||
NotificationSeverity.Error,
|
||||
"Time Anchor Missing",
|
||||
"Time anchor not configured in sealed mode"),
|
||||
|
||||
_ => (
|
||||
AirGapNotificationType.StalenessWarning,
|
||||
NotificationSeverity.Info,
|
||||
"Staleness Event",
|
||||
$"Staleness event: {eventType}")
|
||||
};
|
||||
|
||||
var notification = new AirGapNotification(
|
||||
NotificationId: GenerateNotificationId(),
|
||||
TenantId: tenantId,
|
||||
Type: notificationType,
|
||||
Severity: severity,
|
||||
Title: title,
|
||||
Message: message,
|
||||
OccurredAt: _timeProvider.GetUtcNow(),
|
||||
Metadata: new Dictionary<string, object?>
|
||||
{
|
||||
["age_seconds"] = ageSeconds,
|
||||
["threshold_seconds"] = thresholdSeconds,
|
||||
["event_type"] = eventType.ToString()
|
||||
});
|
||||
|
||||
await SendAsync(notification, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task NotifyBundleImportAsync(
|
||||
string tenantId,
|
||||
string bundleId,
|
||||
bool success,
|
||||
string? error = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (notificationType, severity, title, message) = success
|
||||
? (
|
||||
AirGapNotificationType.BundleImportCompleted,
|
||||
NotificationSeverity.Info,
|
||||
"Bundle Import Completed",
|
||||
$"Policy pack bundle '{bundleId}' imported successfully")
|
||||
: (
|
||||
AirGapNotificationType.BundleImportFailed,
|
||||
NotificationSeverity.Error,
|
||||
"Bundle Import Failed",
|
||||
$"Policy pack bundle '{bundleId}' import failed: {error ?? "unknown error"}");
|
||||
|
||||
var notification = new AirGapNotification(
|
||||
NotificationId: GenerateNotificationId(),
|
||||
TenantId: tenantId,
|
||||
Type: notificationType,
|
||||
Severity: severity,
|
||||
Title: title,
|
||||
Message: message,
|
||||
OccurredAt: _timeProvider.GetUtcNow(),
|
||||
Metadata: new Dictionary<string, object?>
|
||||
{
|
||||
["bundle_id"] = bundleId,
|
||||
["success"] = success,
|
||||
["error"] = error
|
||||
});
|
||||
|
||||
await SendAsync(notification, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task NotifySealedStateChangeAsync(
|
||||
string tenantId,
|
||||
bool isSealed,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (notificationType, title, message) = isSealed
|
||||
? (
|
||||
AirGapNotificationType.EnvironmentSealed,
|
||||
"Environment Sealed",
|
||||
"Policy engine environment has been sealed for air-gap operation")
|
||||
: (
|
||||
AirGapNotificationType.EnvironmentUnsealed,
|
||||
"Environment Unsealed",
|
||||
"Policy engine environment has been unsealed");
|
||||
|
||||
var notification = new AirGapNotification(
|
||||
NotificationId: GenerateNotificationId(),
|
||||
TenantId: tenantId,
|
||||
Type: notificationType,
|
||||
Severity: NotificationSeverity.Info,
|
||||
Title: title,
|
||||
Message: message,
|
||||
OccurredAt: _timeProvider.GetUtcNow(),
|
||||
Metadata: new Dictionary<string, object?>
|
||||
{
|
||||
["sealed"] = isSealed
|
||||
});
|
||||
|
||||
await SendAsync(notification, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Implement IStalenessEventSink to auto-notify on staleness events
|
||||
public Task OnStalenessEventAsync(StalenessEvent evt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return NotifyStalenessEventAsync(
|
||||
evt.TenantId,
|
||||
evt.Type,
|
||||
evt.AgeSeconds,
|
||||
evt.ThresholdSeconds,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static string GenerateNotificationId()
|
||||
{
|
||||
return $"notify-{Guid.NewGuid():N}"[..24];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logging-based notification channel for observability.
|
||||
/// </summary>
|
||||
internal sealed class LoggingNotificationChannel : IAirGapNotificationChannel
|
||||
{
|
||||
private readonly ILogger<LoggingNotificationChannel> _logger;
|
||||
|
||||
public LoggingNotificationChannel(ILogger<LoggingNotificationChannel> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public string ChannelName => "Logging";
|
||||
|
||||
public Task<bool> DeliverAsync(AirGapNotification notification, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var logLevel = notification.Severity switch
|
||||
{
|
||||
NotificationSeverity.Critical => LogLevel.Critical,
|
||||
NotificationSeverity.Error => LogLevel.Error,
|
||||
NotificationSeverity.Warning => LogLevel.Warning,
|
||||
_ => LogLevel.Information
|
||||
};
|
||||
|
||||
_logger.Log(
|
||||
logLevel,
|
||||
"[{NotificationType}] {Title}: {Message} (tenant={TenantId}, id={NotificationId})",
|
||||
notification.Type,
|
||||
notification.Title,
|
||||
notification.Message,
|
||||
notification.TenantId,
|
||||
notification.NotificationId);
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Webhook-based notification channel for external integrations.
|
||||
/// </summary>
|
||||
internal sealed class WebhookNotificationChannel : IAirGapNotificationChannel
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly string _webhookUrl;
|
||||
private readonly ILogger<WebhookNotificationChannel> _logger;
|
||||
|
||||
public WebhookNotificationChannel(
|
||||
HttpClient httpClient,
|
||||
string webhookUrl,
|
||||
ILogger<WebhookNotificationChannel> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_webhookUrl = webhookUrl ?? throw new ArgumentNullException(nameof(webhookUrl));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public string ChannelName => $"Webhook({_webhookUrl})";
|
||||
|
||||
public async Task<bool> DeliverAsync(AirGapNotification notification, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
notification_id = notification.NotificationId,
|
||||
tenant_id = notification.TenantId,
|
||||
type = notification.Type.ToString(),
|
||||
severity = notification.Severity.ToString(),
|
||||
title = notification.Title,
|
||||
message = notification.Message,
|
||||
occurred_at = notification.OccurredAt.ToString("O"),
|
||||
metadata = notification.Metadata
|
||||
};
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(_webhookUrl, payload, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Webhook delivery returned {StatusCode} for notification {NotificationId}",
|
||||
response.StatusCode, notification.NotificationId);
|
||||
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Webhook delivery failed for notification {NotificationId} to {WebhookUrl}",
|
||||
notification.NotificationId, _webhookUrl);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace StellaOps.Policy.Engine.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing sealed-mode operations for policy packs per CONTRACT-SEALED-MODE-004.
|
||||
/// </summary>
|
||||
public interface ISealedModeService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether the environment is currently sealed.
|
||||
/// </summary>
|
||||
bool IsSealed { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current sealed state for a tenant.
|
||||
/// </summary>
|
||||
Task<PolicyPackSealedState> GetStateAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the sealed status with staleness evaluation.
|
||||
/// </summary>
|
||||
Task<SealedStatusResponse> GetStatusAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Seals the environment for a tenant.
|
||||
/// </summary>
|
||||
Task<SealResponse> SealAsync(string tenantId, SealRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Unseals the environment for a tenant.
|
||||
/// </summary>
|
||||
Task<SealResponse> UnsealAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates staleness for the current time anchor.
|
||||
/// </summary>
|
||||
Task<StalenessEvaluation?> EvaluateStalenessAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Enforces sealed-mode constraints for bundle import operations.
|
||||
/// </summary>
|
||||
Task<SealedModeEnforcementResult> EnforceBundleImportAsync(
|
||||
string tenantId,
|
||||
string bundlePath,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a bundle against trust roots.
|
||||
/// </summary>
|
||||
Task<BundleVerifyResponse> VerifyBundleAsync(
|
||||
BundleVerifyRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.Policy.Engine.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Store for sealed-mode state persistence.
|
||||
/// </summary>
|
||||
public interface ISealedModeStateStore
|
||||
{
|
||||
Task<PolicyPackSealedState?> GetAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
Task SaveAsync(PolicyPackSealedState state, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Policy.Engine.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of sealed-mode state store.
|
||||
/// </summary>
|
||||
internal sealed class InMemorySealedModeStateStore : ISealedModeStateStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, PolicyPackSealedState> _states = new(StringComparer.Ordinal);
|
||||
|
||||
public Task<PolicyPackSealedState?> GetAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_states.TryGetValue(tenantId, out var state);
|
||||
return Task.FromResult(state);
|
||||
}
|
||||
|
||||
public Task SaveAsync(PolicyPackSealedState state, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(state);
|
||||
_states[state.TenantId] = state;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -13,17 +13,20 @@ internal sealed class PolicyPackBundleImportService
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly IPolicyPackBundleStore _store;
|
||||
private readonly ISealedModeService? _sealedModeService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<PolicyPackBundleImportService> _logger;
|
||||
|
||||
public PolicyPackBundleImportService(
|
||||
IPolicyPackBundleStore store,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<PolicyPackBundleImportService> logger)
|
||||
ILogger<PolicyPackBundleImportService> logger,
|
||||
ISealedModeService? sealedModeService = null)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_sealedModeService = sealedModeService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -38,6 +41,20 @@ internal sealed class PolicyPackBundleImportService
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.BundlePath);
|
||||
|
||||
// Enforce sealed-mode constraints
|
||||
if (_sealedModeService is not null)
|
||||
{
|
||||
var enforcement = await _sealedModeService.EnforceBundleImportAsync(
|
||||
tenantId, request.BundlePath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!enforcement.Allowed)
|
||||
{
|
||||
_logger.LogWarning("Bundle import blocked by sealed-mode: {Reason}", enforcement.Reason);
|
||||
throw new InvalidOperationException(
|
||||
$"Bundle import blocked: {enforcement.Reason}. {enforcement.Remediation}");
|
||||
}
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var importId = GenerateImportId();
|
||||
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Policy.RiskProfile.Export;
|
||||
using StellaOps.Policy.RiskProfile.Hashing;
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
|
||||
namespace StellaOps.Policy.Engine.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Air-gap export/import for risk profiles per CONTRACT-MIRROR-BUNDLE-003.
|
||||
/// </summary>
|
||||
public sealed class RiskProfileAirGapExportService
|
||||
{
|
||||
private const string FormatVersion = "1.0";
|
||||
private const string DomainId = "risk-profiles";
|
||||
private const string PredicateType = "https://stella.ops/attestation/risk-profile/v1";
|
||||
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ISealedModeService? _sealedModeService;
|
||||
private readonly RiskProfileHasher _hasher;
|
||||
private readonly ILogger<RiskProfileAirGapExportService> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public RiskProfileAirGapExportService(
|
||||
ICryptoHash cryptoHash,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<RiskProfileAirGapExportService> logger,
|
||||
ISealedModeService? sealedModeService = null)
|
||||
{
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_sealedModeService = sealedModeService;
|
||||
_hasher = new RiskProfileHasher(cryptoHash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an air-gap compatible bundle from risk profiles.
|
||||
/// </summary>
|
||||
public async Task<RiskProfileAirGapBundle> ExportAsync(
|
||||
IReadOnlyList<RiskProfileModel> profiles,
|
||||
AirGapExportRequest request,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profiles);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var bundleId = GenerateBundleId(now);
|
||||
|
||||
_logger.LogInformation("Creating air-gap bundle {BundleId} with {Count} profiles",
|
||||
bundleId, profiles.Count);
|
||||
|
||||
// Create exports for each profile
|
||||
var exports = new List<RiskProfileAirGapExport>();
|
||||
foreach (var profile in profiles)
|
||||
{
|
||||
var contentHash = _hasher.ComputeContentHash(profile);
|
||||
var profileJson = JsonSerializer.Serialize(profile, JsonOptions);
|
||||
var artifactDigest = ComputeArtifactDigest(profileJson);
|
||||
|
||||
var export = new RiskProfileAirGapExport(
|
||||
Key: $"profile-{profile.Id}-{profile.Version}",
|
||||
Format: "json",
|
||||
ExportId: Guid.NewGuid().ToString("N")[..16],
|
||||
ProfileId: profile.Id,
|
||||
ProfileVersion: profile.Version,
|
||||
CreatedAt: now.ToString("O"),
|
||||
ArtifactSizeBytes: Encoding.UTF8.GetByteCount(profileJson),
|
||||
ArtifactDigest: artifactDigest,
|
||||
ContentHash: contentHash,
|
||||
ProfileDigest: ComputeProfileDigest(profile),
|
||||
Attestation: request.SignBundle ? CreateAttestation(now) : null);
|
||||
|
||||
exports.Add(export);
|
||||
}
|
||||
|
||||
// Compute bundle-level Merkle root
|
||||
var merkleRoot = ComputeMerkleRoot(exports);
|
||||
|
||||
// Create signature if requested
|
||||
BundleSignature? signature = null;
|
||||
if (request.SignBundle)
|
||||
{
|
||||
signature = await CreateSignatureAsync(
|
||||
exports, merkleRoot, request.KeyId, now, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return new RiskProfileAirGapBundle(
|
||||
SchemaVersion: 1,
|
||||
GeneratedAt: now.ToString("O"),
|
||||
TargetRepository: request.TargetRepository,
|
||||
DomainId: DomainId,
|
||||
DisplayName: request.DisplayName ?? "Risk Profiles Export",
|
||||
TenantId: tenantId,
|
||||
Exports: exports.AsReadOnly(),
|
||||
MerkleRoot: merkleRoot,
|
||||
Signature: signature,
|
||||
Profiles: profiles);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports profiles from an air-gap bundle with sealed-mode enforcement.
|
||||
/// </summary>
|
||||
public async Task<RiskProfileAirGapImportResult> ImportAsync(
|
||||
RiskProfileAirGapBundle bundle,
|
||||
AirGapImportRequest request,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var details = new List<RiskProfileAirGapImportDetail>();
|
||||
var errors = new List<string>();
|
||||
|
||||
// Enforce sealed-mode constraints
|
||||
if (_sealedModeService is not null && request.EnforceSealedMode)
|
||||
{
|
||||
// Pass bundle domain ID as path identifier for sealed-mode enforcement
|
||||
var enforcement = await _sealedModeService.EnforceBundleImportAsync(
|
||||
tenantId, $"risk-profile-bundle:{bundle.DomainId}", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!enforcement.Allowed)
|
||||
{
|
||||
_logger.LogWarning("Air-gap profile import blocked by sealed-mode: {Reason}",
|
||||
enforcement.Reason);
|
||||
|
||||
return new RiskProfileAirGapImportResult(
|
||||
BundleId: bundle.GeneratedAt,
|
||||
Success: false,
|
||||
TotalCount: bundle.Exports.Count,
|
||||
ImportedCount: 0,
|
||||
SkippedCount: 0,
|
||||
ErrorCount: bundle.Exports.Count,
|
||||
Details: details.AsReadOnly(),
|
||||
Errors: new[] { $"Sealed-mode blocked: {enforcement.Reason}. {enforcement.Remediation}" },
|
||||
SignatureVerified: false,
|
||||
MerkleVerified: false);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify signature if present and requested
|
||||
bool? signatureVerified = null;
|
||||
if (request.VerifySignature && bundle.Signature is not null)
|
||||
{
|
||||
signatureVerified = VerifySignature(bundle);
|
||||
if (!signatureVerified.Value)
|
||||
{
|
||||
errors.Add("Bundle signature verification failed");
|
||||
|
||||
if (request.RejectOnSignatureFailure)
|
||||
{
|
||||
return new RiskProfileAirGapImportResult(
|
||||
BundleId: bundle.GeneratedAt,
|
||||
Success: false,
|
||||
TotalCount: bundle.Exports.Count,
|
||||
ImportedCount: 0,
|
||||
SkippedCount: 0,
|
||||
ErrorCount: bundle.Exports.Count,
|
||||
Details: details.AsReadOnly(),
|
||||
Errors: errors.AsReadOnly(),
|
||||
SignatureVerified: false,
|
||||
MerkleVerified: null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify Merkle root
|
||||
bool? merkleVerified = null;
|
||||
if (request.VerifyMerkle && !string.IsNullOrEmpty(bundle.MerkleRoot))
|
||||
{
|
||||
var computedMerkle = ComputeMerkleRoot(bundle.Exports.ToList());
|
||||
merkleVerified = string.Equals(computedMerkle, bundle.MerkleRoot, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!merkleVerified.Value)
|
||||
{
|
||||
errors.Add("Merkle root verification failed - bundle may have been tampered with");
|
||||
|
||||
if (request.RejectOnMerkleFailure)
|
||||
{
|
||||
return new RiskProfileAirGapImportResult(
|
||||
BundleId: bundle.GeneratedAt,
|
||||
Success: false,
|
||||
TotalCount: bundle.Exports.Count,
|
||||
ImportedCount: 0,
|
||||
SkippedCount: 0,
|
||||
ErrorCount: bundle.Exports.Count,
|
||||
Details: details.AsReadOnly(),
|
||||
Errors: errors.AsReadOnly(),
|
||||
SignatureVerified: signatureVerified,
|
||||
MerkleVerified: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify individual exports
|
||||
var importedCount = 0;
|
||||
var skippedCount = 0;
|
||||
var errorCount = 0;
|
||||
|
||||
if (bundle.Profiles is not null)
|
||||
{
|
||||
for (var i = 0; i < bundle.Exports.Count; i++)
|
||||
{
|
||||
var export = bundle.Exports[i];
|
||||
var profile = bundle.Profiles.FirstOrDefault(p =>
|
||||
p.Id == export.ProfileId && p.Version == export.ProfileVersion);
|
||||
|
||||
if (profile is null)
|
||||
{
|
||||
details.Add(new RiskProfileAirGapImportDetail(
|
||||
ProfileId: export.ProfileId,
|
||||
Version: export.ProfileVersion,
|
||||
Status: AirGapImportStatus.Error,
|
||||
Message: "Profile data missing from bundle"));
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Verify content hash
|
||||
var computedHash = _hasher.ComputeContentHash(profile);
|
||||
if (!string.Equals(computedHash, export.ContentHash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
details.Add(new RiskProfileAirGapImportDetail(
|
||||
ProfileId: export.ProfileId,
|
||||
Version: export.ProfileVersion,
|
||||
Status: AirGapImportStatus.Error,
|
||||
Message: "Content hash mismatch - profile may have been modified"));
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Import successful
|
||||
details.Add(new RiskProfileAirGapImportDetail(
|
||||
ProfileId: export.ProfileId,
|
||||
Version: export.ProfileVersion,
|
||||
Status: AirGapImportStatus.Imported,
|
||||
Message: null));
|
||||
importedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
var success = errorCount == 0 && errors.Count == 0;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Air-gap import completed: success={Success}, imported={Imported}, skipped={Skipped}, errors={Errors}",
|
||||
success, importedCount, skippedCount, errorCount);
|
||||
|
||||
return new RiskProfileAirGapImportResult(
|
||||
BundleId: bundle.GeneratedAt,
|
||||
Success: success,
|
||||
TotalCount: bundle.Exports.Count,
|
||||
ImportedCount: importedCount,
|
||||
SkippedCount: skippedCount,
|
||||
ErrorCount: errorCount,
|
||||
Details: details.AsReadOnly(),
|
||||
Errors: errors.AsReadOnly(),
|
||||
SignatureVerified: signatureVerified,
|
||||
MerkleVerified: merkleVerified);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies bundle integrity without importing.
|
||||
/// </summary>
|
||||
public AirGapBundleVerification Verify(RiskProfileAirGapBundle bundle)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
|
||||
var signatureValid = bundle.Signature is not null && VerifySignature(bundle);
|
||||
var merkleValid = !string.IsNullOrEmpty(bundle.MerkleRoot) &&
|
||||
string.Equals(ComputeMerkleRoot(bundle.Exports.ToList()), bundle.MerkleRoot, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var exportDigestResults = new List<ExportDigestVerification>();
|
||||
if (bundle.Profiles is not null)
|
||||
{
|
||||
foreach (var export in bundle.Exports)
|
||||
{
|
||||
var profile = bundle.Profiles.FirstOrDefault(p =>
|
||||
p.Id == export.ProfileId && p.Version == export.ProfileVersion);
|
||||
|
||||
var valid = profile is not null &&
|
||||
string.Equals(_hasher.ComputeContentHash(profile), export.ContentHash, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
exportDigestResults.Add(new ExportDigestVerification(
|
||||
ExportKey: export.Key,
|
||||
ProfileId: export.ProfileId,
|
||||
Valid: valid));
|
||||
}
|
||||
}
|
||||
|
||||
return new AirGapBundleVerification(
|
||||
SignatureValid: signatureValid,
|
||||
MerkleValid: merkleValid,
|
||||
ExportDigests: exportDigestResults.AsReadOnly(),
|
||||
AllValid: signatureValid && merkleValid && exportDigestResults.All(e => e.Valid));
|
||||
}
|
||||
|
||||
private bool VerifySignature(RiskProfileAirGapBundle bundle)
|
||||
{
|
||||
if (bundle.Signature is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Compute expected signature from exports and Merkle root
|
||||
var data = ComputeSignatureData(bundle.Exports.ToList(), bundle.MerkleRoot ?? "");
|
||||
var expectedSignature = ComputeHmacSignature(data, GetSigningKey(bundle.Signature.KeyId));
|
||||
|
||||
return string.Equals(expectedSignature, bundle.Signature.Path, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private async Task<BundleSignature> CreateSignatureAsync(
|
||||
IReadOnlyList<RiskProfileAirGapExport> exports,
|
||||
string merkleRoot,
|
||||
string? keyId,
|
||||
DateTimeOffset signedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var data = ComputeSignatureData(exports.ToList(), merkleRoot);
|
||||
var signatureValue = ComputeHmacSignature(data, GetSigningKey(keyId));
|
||||
|
||||
return new BundleSignature(
|
||||
Path: signatureValue,
|
||||
Algorithm: "HMAC-SHA256",
|
||||
KeyId: keyId ?? "default",
|
||||
Provider: "stellaops",
|
||||
SignedAt: signedAt.ToString("O"));
|
||||
}
|
||||
|
||||
private static string ComputeSignatureData(List<RiskProfileAirGapExport> exports, string merkleRoot)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var export in exports.OrderBy(e => e.Key))
|
||||
{
|
||||
sb.Append(export.ContentHash);
|
||||
sb.Append('|');
|
||||
}
|
||||
sb.Append(merkleRoot);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string ComputeHmacSignature(string data, string key)
|
||||
{
|
||||
var keyBytes = Encoding.UTF8.GetBytes(key);
|
||||
var dataBytes = Encoding.UTF8.GetBytes(data);
|
||||
|
||||
using var hmac = new HMACSHA256(keyBytes);
|
||||
var hashBytes = hmac.ComputeHash(dataBytes);
|
||||
|
||||
return Convert.ToHexStringLower(hashBytes);
|
||||
}
|
||||
|
||||
private string ComputeMerkleRoot(List<RiskProfileAirGapExport> exports)
|
||||
{
|
||||
if (exports.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// Leaf hashes from artifact digests
|
||||
var leaves = exports
|
||||
.OrderBy(e => e.Key)
|
||||
.Select(e => e.ArtifactDigest.Replace("sha256:", "", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
// Build Merkle tree
|
||||
while (leaves.Count > 1)
|
||||
{
|
||||
var nextLevel = new List<string>();
|
||||
for (var i = 0; i < leaves.Count; i += 2)
|
||||
{
|
||||
if (i + 1 < leaves.Count)
|
||||
{
|
||||
var combined = leaves[i] + leaves[i + 1];
|
||||
nextLevel.Add(ComputeSha256(combined));
|
||||
}
|
||||
else
|
||||
{
|
||||
nextLevel.Add(leaves[i]);
|
||||
}
|
||||
}
|
||||
leaves = nextLevel;
|
||||
}
|
||||
|
||||
return $"sha256:{leaves[0]}";
|
||||
}
|
||||
|
||||
private string ComputeArtifactDigest(string content)
|
||||
{
|
||||
return $"sha256:{_cryptoHash.ComputeHashHexForPurpose(
|
||||
Encoding.UTF8.GetBytes(content), HashPurpose.Content)}";
|
||||
}
|
||||
|
||||
private string ComputeProfileDigest(RiskProfileModel profile)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(profile, JsonOptions);
|
||||
return ComputeArtifactDigest(json);
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string input)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexStringLower(bytes);
|
||||
}
|
||||
|
||||
private AttestationDescriptor CreateAttestation(DateTimeOffset signedAt)
|
||||
{
|
||||
return new AttestationDescriptor(
|
||||
PredicateType: PredicateType,
|
||||
RekorLocation: null,
|
||||
EnvelopeDigest: null,
|
||||
SignedAt: signedAt.ToString("O"));
|
||||
}
|
||||
|
||||
private static string GenerateBundleId(DateTimeOffset timestamp)
|
||||
{
|
||||
return $"rpab-{timestamp:yyyyMMddHHmmss}-{Guid.NewGuid():N}"[..24];
|
||||
}
|
||||
|
||||
private static string GetSigningKey(string? keyId)
|
||||
{
|
||||
// In production, this would look up the key from secure storage
|
||||
return "stellaops-airgap-signing-key-change-in-production";
|
||||
}
|
||||
}
|
||||
|
||||
#region Models
|
||||
|
||||
/// <summary>
|
||||
/// Air-gap bundle for risk profiles per CONTRACT-MIRROR-BUNDLE-003.
|
||||
/// </summary>
|
||||
public sealed record RiskProfileAirGapBundle(
|
||||
[property: JsonPropertyName("schemaVersion")] int SchemaVersion,
|
||||
[property: JsonPropertyName("generatedAt")] string GeneratedAt,
|
||||
[property: JsonPropertyName("targetRepository")] string? TargetRepository,
|
||||
[property: JsonPropertyName("domainId")] string DomainId,
|
||||
[property: JsonPropertyName("displayName")] string? DisplayName,
|
||||
[property: JsonPropertyName("tenantId")] string? TenantId,
|
||||
[property: JsonPropertyName("exports")] IReadOnlyList<RiskProfileAirGapExport> Exports,
|
||||
[property: JsonPropertyName("merkleRoot")] string? MerkleRoot,
|
||||
[property: JsonPropertyName("signature")] BundleSignature? Signature,
|
||||
[property: JsonPropertyName("profiles")] IReadOnlyList<RiskProfileModel>? Profiles);
|
||||
|
||||
/// <summary>
|
||||
/// Export entry for a risk profile.
|
||||
/// </summary>
|
||||
public sealed record RiskProfileAirGapExport(
|
||||
[property: JsonPropertyName("key")] string Key,
|
||||
[property: JsonPropertyName("format")] string Format,
|
||||
[property: JsonPropertyName("exportId")] string ExportId,
|
||||
[property: JsonPropertyName("profileId")] string ProfileId,
|
||||
[property: JsonPropertyName("profileVersion")] string ProfileVersion,
|
||||
[property: JsonPropertyName("createdAt")] string CreatedAt,
|
||||
[property: JsonPropertyName("artifactSizeBytes")] long ArtifactSizeBytes,
|
||||
[property: JsonPropertyName("artifactDigest")] string ArtifactDigest,
|
||||
[property: JsonPropertyName("contentHash")] string ContentHash,
|
||||
[property: JsonPropertyName("profileDigest")] string? ProfileDigest,
|
||||
[property: JsonPropertyName("attestation")] AttestationDescriptor? Attestation);
|
||||
|
||||
/// <summary>
|
||||
/// Request to create an air-gap export.
|
||||
/// </summary>
|
||||
public sealed record AirGapExportRequest(
|
||||
bool SignBundle = true,
|
||||
string? KeyId = null,
|
||||
string? TargetRepository = null,
|
||||
string? DisplayName = null);
|
||||
|
||||
/// <summary>
|
||||
/// Request to import from an air-gap bundle.
|
||||
/// </summary>
|
||||
public sealed record AirGapImportRequest(
|
||||
bool VerifySignature = true,
|
||||
bool VerifyMerkle = true,
|
||||
bool EnforceSealedMode = true,
|
||||
bool RejectOnSignatureFailure = true,
|
||||
bool RejectOnMerkleFailure = true);
|
||||
|
||||
/// <summary>
|
||||
/// Result of air-gap import.
|
||||
/// </summary>
|
||||
public sealed record RiskProfileAirGapImportResult(
|
||||
string BundleId,
|
||||
bool Success,
|
||||
int TotalCount,
|
||||
int ImportedCount,
|
||||
int SkippedCount,
|
||||
int ErrorCount,
|
||||
IReadOnlyList<RiskProfileAirGapImportDetail> Details,
|
||||
IReadOnlyList<string> Errors,
|
||||
bool? SignatureVerified,
|
||||
bool? MerkleVerified);
|
||||
|
||||
/// <summary>
|
||||
/// Import detail for a single profile.
|
||||
/// </summary>
|
||||
public sealed record RiskProfileAirGapImportDetail(
|
||||
string ProfileId,
|
||||
string Version,
|
||||
AirGapImportStatus Status,
|
||||
string? Message);
|
||||
|
||||
/// <summary>
|
||||
/// Import status values.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<AirGapImportStatus>))]
|
||||
public enum AirGapImportStatus
|
||||
{
|
||||
Imported,
|
||||
Skipped,
|
||||
Error
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bundle verification result.
|
||||
/// </summary>
|
||||
public sealed record AirGapBundleVerification(
|
||||
bool SignatureValid,
|
||||
bool MerkleValid,
|
||||
IReadOnlyList<ExportDigestVerification> ExportDigests,
|
||||
bool AllValid);
|
||||
|
||||
/// <summary>
|
||||
/// Export digest verification result.
|
||||
/// </summary>
|
||||
public sealed record ExportDigestVerification(
|
||||
string ExportKey,
|
||||
string ProfileId,
|
||||
bool Valid);
|
||||
|
||||
#endregion
|
||||
255
src/Policy/StellaOps.Policy.Engine/AirGap/SealedModeErrors.cs
Normal file
255
src/Policy/StellaOps.Policy.Engine/AirGap/SealedModeErrors.cs
Normal file
@@ -0,0 +1,255 @@
|
||||
namespace StellaOps.Policy.Engine.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Error codes for sealed-mode operations per CONTRACT-SEALED-MODE-004.
|
||||
/// </summary>
|
||||
public static class SealedModeErrorCodes
|
||||
{
|
||||
/// <summary>Time anchor missing when required.</summary>
|
||||
public const string AnchorMissing = "ERR_AIRGAP_001";
|
||||
|
||||
/// <summary>Time anchor staleness breached.</summary>
|
||||
public const string StalenessBreach = "ERR_AIRGAP_002";
|
||||
|
||||
/// <summary>Time anchor staleness warning threshold exceeded.</summary>
|
||||
public const string StalenessWarning = "ERR_AIRGAP_003";
|
||||
|
||||
/// <summary>Bundle signature verification failed.</summary>
|
||||
public const string SignatureInvalid = "ERR_AIRGAP_004";
|
||||
|
||||
/// <summary>Bundle format or structure invalid.</summary>
|
||||
public const string BundleInvalid = "ERR_AIRGAP_005";
|
||||
|
||||
/// <summary>Egress blocked in sealed mode.</summary>
|
||||
public const string EgressBlocked = "ERR_AIRGAP_006";
|
||||
|
||||
/// <summary>Seal operation failed.</summary>
|
||||
public const string SealFailed = "ERR_AIRGAP_007";
|
||||
|
||||
/// <summary>Unseal operation failed.</summary>
|
||||
public const string UnsealFailed = "ERR_AIRGAP_008";
|
||||
|
||||
/// <summary>Trust roots not found or invalid.</summary>
|
||||
public const string TrustRootsInvalid = "ERR_AIRGAP_009";
|
||||
|
||||
/// <summary>Bundle import blocked by policy.</summary>
|
||||
public const string ImportBlocked = "ERR_AIRGAP_010";
|
||||
|
||||
/// <summary>Policy hash mismatch.</summary>
|
||||
public const string PolicyHashMismatch = "ERR_AIRGAP_011";
|
||||
|
||||
/// <summary>Startup blocked due to sealed-mode requirements.</summary>
|
||||
public const string StartupBlocked = "ERR_AIRGAP_012";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Problem types for sealed-mode errors (RFC 7807 compatible).
|
||||
/// </summary>
|
||||
public static class SealedModeProblemTypes
|
||||
{
|
||||
private const string BaseUri = "https://stellaops.org/problems/airgap";
|
||||
|
||||
public static readonly string AnchorMissing = $"{BaseUri}/anchor-missing";
|
||||
public static readonly string StalenessBreach = $"{BaseUri}/staleness-breach";
|
||||
public static readonly string StalenessWarning = $"{BaseUri}/staleness-warning";
|
||||
public static readonly string SignatureInvalid = $"{BaseUri}/signature-invalid";
|
||||
public static readonly string BundleInvalid = $"{BaseUri}/bundle-invalid";
|
||||
public static readonly string EgressBlocked = $"{BaseUri}/egress-blocked";
|
||||
public static readonly string SealFailed = $"{BaseUri}/seal-failed";
|
||||
public static readonly string UnsealFailed = $"{BaseUri}/unseal-failed";
|
||||
public static readonly string TrustRootsInvalid = $"{BaseUri}/trust-roots-invalid";
|
||||
public static readonly string ImportBlocked = $"{BaseUri}/import-blocked";
|
||||
public static readonly string PolicyHashMismatch = $"{BaseUri}/policy-hash-mismatch";
|
||||
public static readonly string StartupBlocked = $"{BaseUri}/startup-blocked";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Structured error details for sealed-mode problems.
|
||||
/// </summary>
|
||||
public sealed record SealedModeErrorDetails(
|
||||
string Code,
|
||||
string Message,
|
||||
string? Remediation = null,
|
||||
string? DocumentationUrl = null,
|
||||
IDictionary<string, object?>? Extensions = null);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a sealed-mode violation that occurred during an operation.
|
||||
/// </summary>
|
||||
public class SealedModeException : Exception
|
||||
{
|
||||
public SealedModeException(
|
||||
string code,
|
||||
string message,
|
||||
string? remediation = null)
|
||||
: base(message)
|
||||
{
|
||||
Code = code;
|
||||
Remediation = remediation;
|
||||
}
|
||||
|
||||
public SealedModeException(
|
||||
string code,
|
||||
string message,
|
||||
Exception innerException,
|
||||
string? remediation = null)
|
||||
: base(message, innerException)
|
||||
{
|
||||
Code = code;
|
||||
Remediation = remediation;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the error code for this exception.
|
||||
/// </summary>
|
||||
public string Code { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional remediation guidance.
|
||||
/// </summary>
|
||||
public string? Remediation { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates an exception for time anchor missing.
|
||||
/// </summary>
|
||||
public static SealedModeException AnchorMissing(string tenantId) =>
|
||||
new(SealedModeErrorCodes.AnchorMissing,
|
||||
$"Time anchor required for tenant '{tenantId}' in sealed mode",
|
||||
"Provide a verified time anchor using POST /system/airgap/seal");
|
||||
|
||||
/// <summary>
|
||||
/// Creates an exception for staleness breach.
|
||||
/// </summary>
|
||||
public static SealedModeException StalenessBreach(string tenantId, int ageSeconds, int thresholdSeconds) =>
|
||||
new(SealedModeErrorCodes.StalenessBreach,
|
||||
$"Time anchor staleness breached for tenant '{tenantId}': age {ageSeconds}s exceeds threshold {thresholdSeconds}s",
|
||||
"Refresh time anchor before continuing operations");
|
||||
|
||||
/// <summary>
|
||||
/// Creates an exception for egress blocked.
|
||||
/// </summary>
|
||||
public static SealedModeException EgressBlocked(string destination, string? reason = null) =>
|
||||
new(SealedModeErrorCodes.EgressBlocked,
|
||||
$"Egress to '{destination}' blocked in sealed mode" + (reason is not null ? $": {reason}" : ""),
|
||||
"Add destination to egress allowlist or unseal environment");
|
||||
|
||||
/// <summary>
|
||||
/// Creates an exception for bundle import blocked.
|
||||
/// </summary>
|
||||
public static SealedModeException ImportBlocked(string bundlePath, string reason) =>
|
||||
new(SealedModeErrorCodes.ImportBlocked,
|
||||
$"Bundle import blocked: {reason}",
|
||||
"Ensure time anchor is fresh and bundle is properly signed");
|
||||
|
||||
/// <summary>
|
||||
/// Creates an exception for invalid bundle.
|
||||
/// </summary>
|
||||
public static SealedModeException BundleInvalid(string bundlePath, string reason) =>
|
||||
new(SealedModeErrorCodes.BundleInvalid,
|
||||
$"Bundle '{bundlePath}' is invalid: {reason}",
|
||||
"Verify bundle format and content integrity");
|
||||
|
||||
/// <summary>
|
||||
/// Creates an exception for signature verification failure.
|
||||
/// </summary>
|
||||
public static SealedModeException SignatureInvalid(string bundlePath, string reason) =>
|
||||
new(SealedModeErrorCodes.SignatureInvalid,
|
||||
$"Bundle signature verification failed for '{bundlePath}': {reason}",
|
||||
"Ensure bundle is signed by trusted key and trust roots are properly configured");
|
||||
|
||||
/// <summary>
|
||||
/// Creates an exception for startup blocked.
|
||||
/// </summary>
|
||||
public static SealedModeException StartupBlocked(string reason) =>
|
||||
new(SealedModeErrorCodes.StartupBlocked,
|
||||
$"Startup blocked in sealed mode: {reason}",
|
||||
"Resolve sealed-mode requirements before starting the service");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result helper for converting sealed-mode errors to HTTP problem details.
|
||||
/// </summary>
|
||||
public static class SealedModeResultHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a problem result for a sealed-mode exception.
|
||||
/// </summary>
|
||||
public static IResult ToProblem(SealedModeException ex)
|
||||
{
|
||||
var (problemType, statusCode) = GetProblemTypeAndStatus(ex.Code);
|
||||
|
||||
return Results.Problem(
|
||||
title: GetTitle(ex.Code),
|
||||
detail: ex.Message,
|
||||
type: problemType,
|
||||
statusCode: statusCode,
|
||||
extensions: new Dictionary<string, object?>
|
||||
{
|
||||
["code"] = ex.Code,
|
||||
["remediation"] = ex.Remediation
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a problem result for a generic sealed-mode error.
|
||||
/// </summary>
|
||||
public static IResult ToProblem(
|
||||
string code,
|
||||
string message,
|
||||
string? remediation = null,
|
||||
int? statusCode = null)
|
||||
{
|
||||
var (problemType, defaultStatusCode) = GetProblemTypeAndStatus(code);
|
||||
|
||||
return Results.Problem(
|
||||
title: GetTitle(code),
|
||||
detail: message,
|
||||
type: problemType,
|
||||
statusCode: statusCode ?? defaultStatusCode,
|
||||
extensions: new Dictionary<string, object?>
|
||||
{
|
||||
["code"] = code,
|
||||
["remediation"] = remediation
|
||||
});
|
||||
}
|
||||
|
||||
private static (string ProblemType, int StatusCode) GetProblemTypeAndStatus(string code)
|
||||
{
|
||||
return code switch
|
||||
{
|
||||
SealedModeErrorCodes.AnchorMissing => (SealedModeProblemTypes.AnchorMissing, 412),
|
||||
SealedModeErrorCodes.StalenessBreach => (SealedModeProblemTypes.StalenessBreach, 412),
|
||||
SealedModeErrorCodes.StalenessWarning => (SealedModeProblemTypes.StalenessWarning, 200), // Warning only
|
||||
SealedModeErrorCodes.SignatureInvalid => (SealedModeProblemTypes.SignatureInvalid, 422),
|
||||
SealedModeErrorCodes.BundleInvalid => (SealedModeProblemTypes.BundleInvalid, 422),
|
||||
SealedModeErrorCodes.EgressBlocked => (SealedModeProblemTypes.EgressBlocked, 403),
|
||||
SealedModeErrorCodes.SealFailed => (SealedModeProblemTypes.SealFailed, 500),
|
||||
SealedModeErrorCodes.UnsealFailed => (SealedModeProblemTypes.UnsealFailed, 500),
|
||||
SealedModeErrorCodes.TrustRootsInvalid => (SealedModeProblemTypes.TrustRootsInvalid, 422),
|
||||
SealedModeErrorCodes.ImportBlocked => (SealedModeProblemTypes.ImportBlocked, 403),
|
||||
SealedModeErrorCodes.PolicyHashMismatch => (SealedModeProblemTypes.PolicyHashMismatch, 409),
|
||||
SealedModeErrorCodes.StartupBlocked => (SealedModeProblemTypes.StartupBlocked, 503),
|
||||
_ => ("about:blank", 500)
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetTitle(string code)
|
||||
{
|
||||
return code switch
|
||||
{
|
||||
SealedModeErrorCodes.AnchorMissing => "Time anchor required",
|
||||
SealedModeErrorCodes.StalenessBreach => "Staleness threshold breached",
|
||||
SealedModeErrorCodes.StalenessWarning => "Staleness warning",
|
||||
SealedModeErrorCodes.SignatureInvalid => "Signature verification failed",
|
||||
SealedModeErrorCodes.BundleInvalid => "Invalid bundle",
|
||||
SealedModeErrorCodes.EgressBlocked => "Egress blocked",
|
||||
SealedModeErrorCodes.SealFailed => "Seal operation failed",
|
||||
SealedModeErrorCodes.UnsealFailed => "Unseal operation failed",
|
||||
SealedModeErrorCodes.TrustRootsInvalid => "Trust roots invalid",
|
||||
SealedModeErrorCodes.ImportBlocked => "Import blocked",
|
||||
SealedModeErrorCodes.PolicyHashMismatch => "Policy hash mismatch",
|
||||
SealedModeErrorCodes.StartupBlocked => "Startup blocked",
|
||||
_ => "Sealed mode error"
|
||||
};
|
||||
}
|
||||
}
|
||||
114
src/Policy/StellaOps.Policy.Engine/AirGap/SealedModeModels.cs
Normal file
114
src/Policy/StellaOps.Policy.Engine/AirGap/SealedModeModels.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
namespace StellaOps.Policy.Engine.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Sealed-mode state for policy packs per CONTRACT-SEALED-MODE-004.
|
||||
/// </summary>
|
||||
public sealed record PolicyPackSealedState(
|
||||
string TenantId,
|
||||
bool IsSealed,
|
||||
string? PolicyHash,
|
||||
TimeAnchorInfo? TimeAnchor,
|
||||
StalenessBudget StalenessBudget,
|
||||
DateTimeOffset LastTransitionAt);
|
||||
|
||||
/// <summary>
|
||||
/// Time anchor information for sealed-mode operations.
|
||||
/// </summary>
|
||||
public sealed record TimeAnchorInfo(
|
||||
DateTimeOffset AnchorTime,
|
||||
string Source,
|
||||
string Format,
|
||||
string? SignatureFingerprint,
|
||||
string? TokenDigest);
|
||||
|
||||
/// <summary>
|
||||
/// Staleness budget configuration.
|
||||
/// </summary>
|
||||
public sealed record StalenessBudget(
|
||||
int WarningSeconds,
|
||||
int BreachSeconds)
|
||||
{
|
||||
public static StalenessBudget Default => new(3600, 7200);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of staleness evaluation.
|
||||
/// </summary>
|
||||
public sealed record StalenessEvaluation(
|
||||
int AgeSeconds,
|
||||
int WarningSeconds,
|
||||
int BreachSeconds,
|
||||
bool IsBreached,
|
||||
int RemainingSeconds)
|
||||
{
|
||||
public bool IsWarning => AgeSeconds >= WarningSeconds && !IsBreached;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to seal the environment.
|
||||
/// </summary>
|
||||
public sealed record SealRequest(
|
||||
string? PolicyHash,
|
||||
TimeAnchorInfo? TimeAnchor,
|
||||
StalenessBudget? StalenessBudget);
|
||||
|
||||
/// <summary>
|
||||
/// Response from seal/unseal operations.
|
||||
/// </summary>
|
||||
public sealed record SealResponse(
|
||||
bool Sealed,
|
||||
DateTimeOffset LastTransitionAt);
|
||||
|
||||
/// <summary>
|
||||
/// Sealed status response.
|
||||
/// </summary>
|
||||
public sealed record SealedStatusResponse(
|
||||
bool Sealed,
|
||||
string TenantId,
|
||||
StalenessEvaluation? Staleness,
|
||||
TimeAnchorInfo? TimeAnchor,
|
||||
string? PolicyHash);
|
||||
|
||||
/// <summary>
|
||||
/// Bundle verification request.
|
||||
/// </summary>
|
||||
public sealed record BundleVerifyRequest(
|
||||
string BundlePath,
|
||||
string? TrustRootsPath);
|
||||
|
||||
/// <summary>
|
||||
/// Bundle verification response.
|
||||
/// </summary>
|
||||
public sealed record BundleVerifyResponse(
|
||||
bool Valid,
|
||||
BundleVerificationResult VerificationResult);
|
||||
|
||||
/// <summary>
|
||||
/// Detailed verification result.
|
||||
/// </summary>
|
||||
public sealed record BundleVerificationResult(
|
||||
bool DsseValid,
|
||||
bool TufValid,
|
||||
bool MerkleValid,
|
||||
string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// Sealed-mode enforcement result for bundle operations.
|
||||
/// </summary>
|
||||
public sealed record SealedModeEnforcementResult(
|
||||
bool Allowed,
|
||||
string? Reason,
|
||||
string? Remediation);
|
||||
|
||||
/// <summary>
|
||||
/// Sealed-mode telemetry constants.
|
||||
/// </summary>
|
||||
public static class SealedModeTelemetry
|
||||
{
|
||||
public const string MetricSealedGauge = "policy_airgap_sealed";
|
||||
public const string MetricAnchorDriftSeconds = "policy_airgap_anchor_drift_seconds";
|
||||
public const string MetricAnchorExpirySeconds = "policy_airgap_anchor_expiry_seconds";
|
||||
public const string MetricSealTotal = "policy_airgap_seal_total";
|
||||
public const string MetricUnsealTotal = "policy_airgap_unseal_total";
|
||||
public const string MetricBundleImportBlocked = "policy_airgap_bundle_import_blocked_total";
|
||||
}
|
||||
216
src/Policy/StellaOps.Policy.Engine/AirGap/SealedModeService.cs
Normal file
216
src/Policy/StellaOps.Policy.Engine/AirGap/SealedModeService.cs
Normal file
@@ -0,0 +1,216 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AirGap.Policy;
|
||||
|
||||
namespace StellaOps.Policy.Engine.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing sealed-mode operations for policy packs per CONTRACT-SEALED-MODE-004.
|
||||
/// </summary>
|
||||
internal sealed class SealedModeService : ISealedModeService
|
||||
{
|
||||
private readonly ISealedModeStateStore _store;
|
||||
private readonly IEgressPolicy _egressPolicy;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<SealedModeService> _logger;
|
||||
|
||||
public SealedModeService(
|
||||
ISealedModeStateStore store,
|
||||
IEgressPolicy egressPolicy,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<SealedModeService> logger)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_egressPolicy = egressPolicy ?? throw new ArgumentNullException(nameof(egressPolicy));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public bool IsSealed => _egressPolicy.IsSealed;
|
||||
|
||||
public async Task<PolicyPackSealedState> GetStateAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var state = await _store.GetAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (state is null)
|
||||
{
|
||||
// Return default unsealed state
|
||||
return new PolicyPackSealedState(
|
||||
TenantId: tenantId,
|
||||
IsSealed: _egressPolicy.IsSealed,
|
||||
PolicyHash: null,
|
||||
TimeAnchor: null,
|
||||
StalenessBudget: StalenessBudget.Default,
|
||||
LastTransitionAt: DateTimeOffset.MinValue);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
public async Task<SealedStatusResponse> GetStatusAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var state = await GetStateAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var staleness = await EvaluateStalenessAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new SealedStatusResponse(
|
||||
Sealed: state.IsSealed,
|
||||
TenantId: state.TenantId,
|
||||
Staleness: staleness,
|
||||
TimeAnchor: state.TimeAnchor,
|
||||
PolicyHash: state.PolicyHash);
|
||||
}
|
||||
|
||||
public async Task<SealResponse> SealAsync(string tenantId, SealRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
_logger.LogInformation("Sealing environment for tenant {TenantId} with policy hash {PolicyHash}",
|
||||
tenantId, request.PolicyHash ?? "(none)");
|
||||
|
||||
var state = new PolicyPackSealedState(
|
||||
TenantId: tenantId,
|
||||
IsSealed: true,
|
||||
PolicyHash: request.PolicyHash,
|
||||
TimeAnchor: request.TimeAnchor,
|
||||
StalenessBudget: request.StalenessBudget ?? StalenessBudget.Default,
|
||||
LastTransitionAt: now);
|
||||
|
||||
await _store.SaveAsync(state, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Environment sealed for tenant {TenantId} at {TransitionAt}",
|
||||
tenantId, now);
|
||||
|
||||
return new SealResponse(Sealed: true, LastTransitionAt: now);
|
||||
}
|
||||
|
||||
public async Task<SealResponse> UnsealAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var existing = await _store.GetAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Unsealing environment for tenant {TenantId}", tenantId);
|
||||
|
||||
var state = new PolicyPackSealedState(
|
||||
TenantId: tenantId,
|
||||
IsSealed: false,
|
||||
PolicyHash: existing?.PolicyHash,
|
||||
TimeAnchor: existing?.TimeAnchor,
|
||||
StalenessBudget: existing?.StalenessBudget ?? StalenessBudget.Default,
|
||||
LastTransitionAt: now);
|
||||
|
||||
await _store.SaveAsync(state, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Environment unsealed for tenant {TenantId} at {TransitionAt}",
|
||||
tenantId, now);
|
||||
|
||||
return new SealResponse(Sealed: false, LastTransitionAt: now);
|
||||
}
|
||||
|
||||
public async Task<StalenessEvaluation?> EvaluateStalenessAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var state = await _store.GetAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (state?.TimeAnchor is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var age = now - state.TimeAnchor.AnchorTime;
|
||||
var ageSeconds = (int)age.TotalSeconds;
|
||||
var breachSeconds = state.StalenessBudget.BreachSeconds;
|
||||
var remainingSeconds = Math.Max(0, breachSeconds - ageSeconds);
|
||||
|
||||
return new StalenessEvaluation(
|
||||
AgeSeconds: ageSeconds,
|
||||
WarningSeconds: state.StalenessBudget.WarningSeconds,
|
||||
BreachSeconds: breachSeconds,
|
||||
IsBreached: ageSeconds >= breachSeconds,
|
||||
RemainingSeconds: remainingSeconds);
|
||||
}
|
||||
|
||||
public async Task<SealedModeEnforcementResult> EnforceBundleImportAsync(
|
||||
string tenantId,
|
||||
string bundlePath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundlePath);
|
||||
|
||||
// If not in sealed mode at the infrastructure level, allow bundle import
|
||||
if (!_egressPolicy.IsSealed)
|
||||
{
|
||||
_logger.LogDebug("Bundle import allowed: environment not sealed");
|
||||
return new SealedModeEnforcementResult(Allowed: true, Reason: null, Remediation: null);
|
||||
}
|
||||
|
||||
// In sealed mode, verify the tenant state
|
||||
var state = await GetStateAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Check staleness
|
||||
var staleness = await EvaluateStalenessAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (staleness?.IsBreached == true)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Bundle import blocked: staleness breached for tenant {TenantId} (age={AgeSeconds}s, breach={BreachSeconds}s) [{ErrorCode}]",
|
||||
tenantId, staleness.AgeSeconds, staleness.BreachSeconds, SealedModeErrorCodes.StalenessBreach);
|
||||
|
||||
return new SealedModeEnforcementResult(
|
||||
Allowed: false,
|
||||
Reason: $"[{SealedModeErrorCodes.StalenessBreach}] Time anchor staleness breached ({staleness.AgeSeconds}s > {staleness.BreachSeconds}s threshold)",
|
||||
Remediation: "Refresh time anchor before importing bundles in sealed mode");
|
||||
}
|
||||
|
||||
// Warn if approaching staleness threshold
|
||||
if (staleness?.IsWarning == true)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Staleness warning for tenant {TenantId}: age={AgeSeconds}s approaching breach at {BreachSeconds}s [{ErrorCode}]",
|
||||
tenantId, staleness.AgeSeconds, staleness.BreachSeconds, SealedModeErrorCodes.StalenessWarning);
|
||||
}
|
||||
|
||||
// Bundle imports are allowed in sealed mode (they're the approved ingestion path)
|
||||
_logger.LogDebug("Bundle import allowed in sealed mode for tenant {TenantId}", tenantId);
|
||||
return new SealedModeEnforcementResult(Allowed: true, Reason: null, Remediation: null);
|
||||
}
|
||||
|
||||
public Task<BundleVerifyResponse> VerifyBundleAsync(
|
||||
BundleVerifyRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.BundlePath);
|
||||
|
||||
// This would integrate with StellaOps.AirGap.Importer DsseVerifier
|
||||
// For now, perform basic verification
|
||||
_logger.LogInformation("Verifying bundle at {BundlePath} with trust roots {TrustRootsPath}",
|
||||
request.BundlePath, request.TrustRootsPath ?? "(none)");
|
||||
|
||||
if (!File.Exists(request.BundlePath))
|
||||
{
|
||||
return Task.FromResult(new BundleVerifyResponse(
|
||||
Valid: false,
|
||||
VerificationResult: new BundleVerificationResult(
|
||||
DsseValid: false,
|
||||
TufValid: false,
|
||||
MerkleValid: false,
|
||||
Error: $"Bundle file not found: {request.BundlePath}")));
|
||||
}
|
||||
|
||||
// Placeholder: Full verification would check DSSE signatures, TUF metadata, and Merkle proofs
|
||||
return Task.FromResult(new BundleVerifyResponse(
|
||||
Valid: true,
|
||||
VerificationResult: new BundleVerificationResult(
|
||||
DsseValid: true,
|
||||
TufValid: true,
|
||||
MerkleValid: true,
|
||||
Error: null)));
|
||||
}
|
||||
}
|
||||
327
src/Policy/StellaOps.Policy.Engine/AirGap/StalenessSignaling.cs
Normal file
327
src/Policy/StellaOps.Policy.Engine/AirGap/StalenessSignaling.cs
Normal file
@@ -0,0 +1,327 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
namespace StellaOps.Policy.Engine.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Staleness signaling status for health endpoints.
|
||||
/// </summary>
|
||||
public sealed record StalenessSignalStatus(
|
||||
bool IsHealthy,
|
||||
bool HasWarning,
|
||||
bool IsBreach,
|
||||
int? AgeSeconds,
|
||||
int? RemainingSeconds,
|
||||
string? Message);
|
||||
|
||||
/// <summary>
|
||||
/// Fallback mode configuration for when primary data is stale.
|
||||
/// </summary>
|
||||
public sealed record FallbackConfiguration(
|
||||
bool Enabled,
|
||||
FallbackStrategy Strategy,
|
||||
int? CacheTimeoutSeconds,
|
||||
bool AllowDegradedOperation);
|
||||
|
||||
/// <summary>
|
||||
/// Available fallback strategies when data becomes stale.
|
||||
/// </summary>
|
||||
public enum FallbackStrategy
|
||||
{
|
||||
/// <summary>No fallback - fail hard on staleness.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>Use cached data with warning.</summary>
|
||||
Cache,
|
||||
|
||||
/// <summary>Use last-known-good state.</summary>
|
||||
LastKnownGood,
|
||||
|
||||
/// <summary>Degrade to read-only mode.</summary>
|
||||
ReadOnly,
|
||||
|
||||
/// <summary>Require manual intervention.</summary>
|
||||
ManualIntervention
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Staleness event for signaling.
|
||||
/// </summary>
|
||||
public sealed record StalenessEvent(
|
||||
string TenantId,
|
||||
StalenessEventType Type,
|
||||
int AgeSeconds,
|
||||
int ThresholdSeconds,
|
||||
DateTimeOffset OccurredAt,
|
||||
string? Message);
|
||||
|
||||
/// <summary>
|
||||
/// Types of staleness events.
|
||||
/// </summary>
|
||||
public enum StalenessEventType
|
||||
{
|
||||
/// <summary>Staleness warning threshold crossed.</summary>
|
||||
Warning,
|
||||
|
||||
/// <summary>Staleness breach threshold crossed.</summary>
|
||||
Breach,
|
||||
|
||||
/// <summary>Staleness recovered (time anchor refreshed).</summary>
|
||||
Recovered,
|
||||
|
||||
/// <summary>Time anchor missing.</summary>
|
||||
AnchorMissing
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for staleness event subscribers.
|
||||
/// </summary>
|
||||
public interface IStalenessEventSink
|
||||
{
|
||||
Task OnStalenessEventAsync(StalenessEvent evt, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing staleness signaling and fallback behavior.
|
||||
/// </summary>
|
||||
public interface IStalenessSignalingService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current staleness signal status for a tenant.
|
||||
/// </summary>
|
||||
Task<StalenessSignalStatus> GetSignalStatusAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the fallback configuration for a tenant.
|
||||
/// </summary>
|
||||
Task<FallbackConfiguration> GetFallbackConfigurationAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if fallback mode is active for a tenant.
|
||||
/// </summary>
|
||||
Task<bool> IsFallbackActiveAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates staleness and raises events if thresholds are crossed.
|
||||
/// </summary>
|
||||
Task EvaluateAndSignalAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Signals that the time anchor has been refreshed.
|
||||
/// </summary>
|
||||
Task SignalRecoveryAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of staleness signaling service.
|
||||
/// </summary>
|
||||
internal sealed class StalenessSignalingService : IStalenessSignalingService
|
||||
{
|
||||
private readonly ISealedModeService _sealedModeService;
|
||||
private readonly IEnumerable<IStalenessEventSink> _eventSinks;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<StalenessSignalingService> _logger;
|
||||
|
||||
// Track last signaled state per tenant to avoid duplicate events
|
||||
private readonly Dictionary<string, StalenessEventType?> _lastSignaledState = new();
|
||||
private readonly object _stateLock = new();
|
||||
|
||||
public StalenessSignalingService(
|
||||
ISealedModeService sealedModeService,
|
||||
IEnumerable<IStalenessEventSink> eventSinks,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<StalenessSignalingService> logger)
|
||||
{
|
||||
_sealedModeService = sealedModeService ?? throw new ArgumentNullException(nameof(sealedModeService));
|
||||
_eventSinks = eventSinks ?? [];
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<StalenessSignalStatus> GetSignalStatusAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var staleness = await _sealedModeService.EvaluateStalenessAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (staleness is null)
|
||||
{
|
||||
// No time anchor - cannot evaluate staleness
|
||||
return new StalenessSignalStatus(
|
||||
IsHealthy: !_sealedModeService.IsSealed, // Healthy if not sealed (anchor not required)
|
||||
HasWarning: _sealedModeService.IsSealed,
|
||||
IsBreach: false,
|
||||
AgeSeconds: null,
|
||||
RemainingSeconds: null,
|
||||
Message: _sealedModeService.IsSealed ? "Time anchor not configured" : null);
|
||||
}
|
||||
|
||||
var message = staleness.IsBreached
|
||||
? $"Staleness breach: data is {staleness.AgeSeconds}s old (threshold: {staleness.BreachSeconds}s)"
|
||||
: staleness.IsWarning
|
||||
? $"Staleness warning: data is {staleness.AgeSeconds}s old (breach at: {staleness.BreachSeconds}s)"
|
||||
: null;
|
||||
|
||||
return new StalenessSignalStatus(
|
||||
IsHealthy: !staleness.IsBreached,
|
||||
HasWarning: staleness.IsWarning,
|
||||
IsBreach: staleness.IsBreached,
|
||||
AgeSeconds: staleness.AgeSeconds,
|
||||
RemainingSeconds: staleness.RemainingSeconds,
|
||||
Message: message);
|
||||
}
|
||||
|
||||
public Task<FallbackConfiguration> GetFallbackConfigurationAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Default fallback configuration - could be extended to read from configuration
|
||||
return Task.FromResult(new FallbackConfiguration(
|
||||
Enabled: true,
|
||||
Strategy: FallbackStrategy.LastKnownGood,
|
||||
CacheTimeoutSeconds: 3600,
|
||||
AllowDegradedOperation: true));
|
||||
}
|
||||
|
||||
public async Task<bool> IsFallbackActiveAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var status = await GetSignalStatusAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var config = await GetFallbackConfigurationAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return config.Enabled && (status.IsBreach || status.HasWarning);
|
||||
}
|
||||
|
||||
public async Task EvaluateAndSignalAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var staleness = await _sealedModeService.EvaluateStalenessAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
StalenessEventType? currentState = null;
|
||||
string? message = null;
|
||||
|
||||
if (staleness is null && _sealedModeService.IsSealed)
|
||||
{
|
||||
currentState = StalenessEventType.AnchorMissing;
|
||||
message = "Time anchor not configured in sealed mode";
|
||||
}
|
||||
else if (staleness?.IsBreached == true)
|
||||
{
|
||||
currentState = StalenessEventType.Breach;
|
||||
message = $"Staleness breach: {staleness.AgeSeconds}s > {staleness.BreachSeconds}s";
|
||||
}
|
||||
else if (staleness?.IsWarning == true)
|
||||
{
|
||||
currentState = StalenessEventType.Warning;
|
||||
message = $"Staleness warning: {staleness.AgeSeconds}s approaching {staleness.BreachSeconds}s";
|
||||
}
|
||||
|
||||
// Only signal if state changed
|
||||
lock (_stateLock)
|
||||
{
|
||||
_lastSignaledState.TryGetValue(tenantId, out var lastState);
|
||||
|
||||
if (currentState == lastState)
|
||||
{
|
||||
return; // No change
|
||||
}
|
||||
|
||||
_lastSignaledState[tenantId] = currentState;
|
||||
}
|
||||
|
||||
if (currentState.HasValue)
|
||||
{
|
||||
var evt = new StalenessEvent(
|
||||
TenantId: tenantId,
|
||||
Type: currentState.Value,
|
||||
AgeSeconds: staleness?.AgeSeconds ?? 0,
|
||||
ThresholdSeconds: staleness?.BreachSeconds ?? 0,
|
||||
OccurredAt: now,
|
||||
Message: message);
|
||||
|
||||
await RaiseEventAsync(evt, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Record telemetry
|
||||
PolicyEngineTelemetry.RecordStalenessEvent(tenantId, currentState.Value.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SignalRecoveryAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
_lastSignaledState.TryGetValue(tenantId, out var lastState);
|
||||
|
||||
if (lastState is null)
|
||||
{
|
||||
return; // Nothing to recover from
|
||||
}
|
||||
|
||||
_lastSignaledState[tenantId] = null;
|
||||
}
|
||||
|
||||
var evt = new StalenessEvent(
|
||||
TenantId: tenantId,
|
||||
Type: StalenessEventType.Recovered,
|
||||
AgeSeconds: 0,
|
||||
ThresholdSeconds: 0,
|
||||
OccurredAt: now,
|
||||
Message: "Time anchor refreshed, staleness recovered");
|
||||
|
||||
await RaiseEventAsync(evt, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Staleness recovered for tenant {TenantId}", tenantId);
|
||||
}
|
||||
|
||||
private async Task RaiseEventAsync(StalenessEvent evt, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Staleness event {EventType} for tenant {TenantId}: {Message}",
|
||||
evt.Type, evt.TenantId, evt.Message);
|
||||
|
||||
foreach (var sink in _eventSinks)
|
||||
{
|
||||
try
|
||||
{
|
||||
await sink.OnStalenessEventAsync(evt, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to deliver staleness event to sink {SinkType}", sink.GetType().Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logging-based staleness event sink for observability.
|
||||
/// </summary>
|
||||
internal sealed class LoggingStalenessEventSink : IStalenessEventSink
|
||||
{
|
||||
private readonly ILogger<LoggingStalenessEventSink> _logger;
|
||||
|
||||
public LoggingStalenessEventSink(ILogger<LoggingStalenessEventSink> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public Task OnStalenessEventAsync(StalenessEvent evt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var logLevel = evt.Type switch
|
||||
{
|
||||
StalenessEventType.Breach => LogLevel.Error,
|
||||
StalenessEventType.Warning => LogLevel.Warning,
|
||||
StalenessEventType.AnchorMissing => LogLevel.Warning,
|
||||
StalenessEventType.Recovered => LogLevel.Information,
|
||||
_ => LogLevel.Information
|
||||
};
|
||||
|
||||
_logger.Log(
|
||||
logLevel,
|
||||
"Staleness {EventType} for tenant {TenantId}: age={AgeSeconds}s, threshold={ThresholdSeconds}s - {Message}",
|
||||
evt.Type,
|
||||
evt.TenantId,
|
||||
evt.AgeSeconds,
|
||||
evt.ThresholdSeconds,
|
||||
evt.Message);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Status of an attestation report section.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<AttestationReportStatus>))]
|
||||
public enum AttestationReportStatus
|
||||
{
|
||||
Pass,
|
||||
Fail,
|
||||
Warn,
|
||||
Skipped,
|
||||
Pending
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated attestation report for an artifact per CONTRACT-VERIFICATION-POLICY-006.
|
||||
/// </summary>
|
||||
public sealed record ArtifactAttestationReport(
|
||||
[property: JsonPropertyName("artifact_digest")] string ArtifactDigest,
|
||||
[property: JsonPropertyName("artifact_uri")] string? ArtifactUri,
|
||||
[property: JsonPropertyName("overall_status")] AttestationReportStatus OverallStatus,
|
||||
[property: JsonPropertyName("attestation_count")] int AttestationCount,
|
||||
[property: JsonPropertyName("verification_results")] IReadOnlyList<AttestationVerificationSummary> VerificationResults,
|
||||
[property: JsonPropertyName("policy_compliance")] PolicyComplianceSummary PolicyCompliance,
|
||||
[property: JsonPropertyName("coverage")] AttestationCoverageSummary Coverage,
|
||||
[property: JsonPropertyName("evaluated_at")] DateTimeOffset EvaluatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a single attestation verification.
|
||||
/// </summary>
|
||||
public sealed record AttestationVerificationSummary(
|
||||
[property: JsonPropertyName("attestation_id")] string AttestationId,
|
||||
[property: JsonPropertyName("predicate_type")] string PredicateType,
|
||||
[property: JsonPropertyName("status")] AttestationReportStatus Status,
|
||||
[property: JsonPropertyName("policy_id")] string? PolicyId,
|
||||
[property: JsonPropertyName("policy_version")] string? PolicyVersion,
|
||||
[property: JsonPropertyName("signature_status")] SignatureVerificationStatus SignatureStatus,
|
||||
[property: JsonPropertyName("freshness_status")] FreshnessVerificationStatus FreshnessStatus,
|
||||
[property: JsonPropertyName("transparency_status")] TransparencyVerificationStatus TransparencyStatus,
|
||||
[property: JsonPropertyName("issues")] IReadOnlyList<string> Issues,
|
||||
[property: JsonPropertyName("created_at")] DateTimeOffset CreatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Signature verification status.
|
||||
/// </summary>
|
||||
public sealed record SignatureVerificationStatus(
|
||||
[property: JsonPropertyName("status")] AttestationReportStatus Status,
|
||||
[property: JsonPropertyName("total_signatures")] int TotalSignatures,
|
||||
[property: JsonPropertyName("verified_signatures")] int VerifiedSignatures,
|
||||
[property: JsonPropertyName("required_signatures")] int RequiredSignatures,
|
||||
[property: JsonPropertyName("signers")] IReadOnlyList<SignerVerificationInfo> Signers);
|
||||
|
||||
/// <summary>
|
||||
/// Signer verification information.
|
||||
/// </summary>
|
||||
public sealed record SignerVerificationInfo(
|
||||
[property: JsonPropertyName("key_fingerprint")] string KeyFingerprint,
|
||||
[property: JsonPropertyName("issuer")] string? Issuer,
|
||||
[property: JsonPropertyName("subject")] string? Subject,
|
||||
[property: JsonPropertyName("algorithm")] string Algorithm,
|
||||
[property: JsonPropertyName("verified")] bool Verified,
|
||||
[property: JsonPropertyName("trusted")] bool Trusted);
|
||||
|
||||
/// <summary>
|
||||
/// Freshness verification status.
|
||||
/// </summary>
|
||||
public sealed record FreshnessVerificationStatus(
|
||||
[property: JsonPropertyName("status")] AttestationReportStatus Status,
|
||||
[property: JsonPropertyName("created_at")] DateTimeOffset CreatedAt,
|
||||
[property: JsonPropertyName("age_seconds")] int AgeSeconds,
|
||||
[property: JsonPropertyName("max_age_seconds")] int? MaxAgeSeconds,
|
||||
[property: JsonPropertyName("is_fresh")] bool IsFresh);
|
||||
|
||||
/// <summary>
|
||||
/// Transparency log verification status.
|
||||
/// </summary>
|
||||
public sealed record TransparencyVerificationStatus(
|
||||
[property: JsonPropertyName("status")] AttestationReportStatus Status,
|
||||
[property: JsonPropertyName("rekor_entry")] RekorEntryInfo? RekorEntry,
|
||||
[property: JsonPropertyName("inclusion_verified")] bool InclusionVerified);
|
||||
|
||||
/// <summary>
|
||||
/// Rekor transparency log entry information.
|
||||
/// </summary>
|
||||
public sealed record RekorEntryInfo(
|
||||
[property: JsonPropertyName("uuid")] string Uuid,
|
||||
[property: JsonPropertyName("log_index")] long LogIndex,
|
||||
[property: JsonPropertyName("log_url")] string? LogUrl,
|
||||
[property: JsonPropertyName("integrated_time")] DateTimeOffset IntegratedTime);
|
||||
|
||||
/// <summary>
|
||||
/// Summary of policy compliance for an artifact.
|
||||
/// </summary>
|
||||
public sealed record PolicyComplianceSummary(
|
||||
[property: JsonPropertyName("status")] AttestationReportStatus Status,
|
||||
[property: JsonPropertyName("policies_evaluated")] int PoliciesEvaluated,
|
||||
[property: JsonPropertyName("policies_passed")] int PoliciesPassed,
|
||||
[property: JsonPropertyName("policies_failed")] int PoliciesFailed,
|
||||
[property: JsonPropertyName("policies_warned")] int PoliciesWarned,
|
||||
[property: JsonPropertyName("policy_results")] IReadOnlyList<PolicyEvaluationSummary> PolicyResults);
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a policy evaluation.
|
||||
/// </summary>
|
||||
public sealed record PolicyEvaluationSummary(
|
||||
[property: JsonPropertyName("policy_id")] string PolicyId,
|
||||
[property: JsonPropertyName("policy_version")] string PolicyVersion,
|
||||
[property: JsonPropertyName("status")] AttestationReportStatus Status,
|
||||
[property: JsonPropertyName("verdict")] string Verdict,
|
||||
[property: JsonPropertyName("issues")] IReadOnlyList<string> Issues);
|
||||
|
||||
/// <summary>
|
||||
/// Summary of attestation coverage for an artifact.
|
||||
/// </summary>
|
||||
public sealed record AttestationCoverageSummary(
|
||||
[property: JsonPropertyName("predicate_types_required")] IReadOnlyList<string> PredicateTypesRequired,
|
||||
[property: JsonPropertyName("predicate_types_present")] IReadOnlyList<string> PredicateTypesPresent,
|
||||
[property: JsonPropertyName("predicate_types_missing")] IReadOnlyList<string> PredicateTypesMissing,
|
||||
[property: JsonPropertyName("coverage_percentage")] double CoveragePercentage,
|
||||
[property: JsonPropertyName("is_complete")] bool IsComplete);
|
||||
|
||||
/// <summary>
|
||||
/// Query options for attestation reports.
|
||||
/// </summary>
|
||||
public sealed record AttestationReportQuery(
|
||||
[property: JsonPropertyName("artifact_digests")] IReadOnlyList<string>? ArtifactDigests,
|
||||
[property: JsonPropertyName("artifact_uri_pattern")] string? ArtifactUriPattern,
|
||||
[property: JsonPropertyName("policy_ids")] IReadOnlyList<string>? PolicyIds,
|
||||
[property: JsonPropertyName("predicate_types")] IReadOnlyList<string>? PredicateTypes,
|
||||
[property: JsonPropertyName("status_filter")] IReadOnlyList<AttestationReportStatus>? StatusFilter,
|
||||
[property: JsonPropertyName("from_time")] DateTimeOffset? FromTime,
|
||||
[property: JsonPropertyName("to_time")] DateTimeOffset? ToTime,
|
||||
[property: JsonPropertyName("include_details")] bool IncludeDetails,
|
||||
[property: JsonPropertyName("limit")] int Limit = 100,
|
||||
[property: JsonPropertyName("offset")] int Offset = 0);
|
||||
|
||||
/// <summary>
|
||||
/// Response containing attestation reports.
|
||||
/// </summary>
|
||||
public sealed record AttestationReportListResponse(
|
||||
[property: JsonPropertyName("reports")] IReadOnlyList<ArtifactAttestationReport> Reports,
|
||||
[property: JsonPropertyName("total")] int Total,
|
||||
[property: JsonPropertyName("limit")] int Limit,
|
||||
[property: JsonPropertyName("offset")] int Offset);
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated attestation statistics.
|
||||
/// </summary>
|
||||
public sealed record AttestationStatistics(
|
||||
[property: JsonPropertyName("total_artifacts")] int TotalArtifacts,
|
||||
[property: JsonPropertyName("total_attestations")] int TotalAttestations,
|
||||
[property: JsonPropertyName("status_distribution")] IReadOnlyDictionary<AttestationReportStatus, int> StatusDistribution,
|
||||
[property: JsonPropertyName("predicate_type_distribution")] IReadOnlyDictionary<string, int> PredicateTypeDistribution,
|
||||
[property: JsonPropertyName("policy_distribution")] IReadOnlyDictionary<string, int> PolicyDistribution,
|
||||
[property: JsonPropertyName("average_age_seconds")] double AverageAgeSeconds,
|
||||
[property: JsonPropertyName("coverage_rate")] double CoverageRate,
|
||||
[property: JsonPropertyName("evaluated_at")] DateTimeOffset EvaluatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Request to verify attestations for an artifact.
|
||||
/// </summary>
|
||||
public sealed record VerifyArtifactRequest(
|
||||
[property: JsonPropertyName("artifact_digest")] string ArtifactDigest,
|
||||
[property: JsonPropertyName("artifact_uri")] string? ArtifactUri,
|
||||
[property: JsonPropertyName("policy_ids")] IReadOnlyList<string>? PolicyIds,
|
||||
[property: JsonPropertyName("include_transparency")] bool IncludeTransparency = true);
|
||||
|
||||
/// <summary>
|
||||
/// Stored attestation report entry.
|
||||
/// </summary>
|
||||
public sealed record StoredAttestationReport(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
[property: JsonPropertyName("report")] ArtifactAttestationReport Report,
|
||||
[property: JsonPropertyName("stored_at")] DateTimeOffset StoredAt,
|
||||
[property: JsonPropertyName("expires_at")] DateTimeOffset? ExpiresAt);
|
||||
@@ -0,0 +1,394 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing attestation reports per CONTRACT-VERIFICATION-POLICY-006.
|
||||
/// </summary>
|
||||
internal sealed class AttestationReportService : IAttestationReportService
|
||||
{
|
||||
private readonly IAttestationReportStore _store;
|
||||
private readonly IVerificationPolicyStore _policyStore;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<AttestationReportService> _logger;
|
||||
|
||||
private static readonly TimeSpan DefaultTtl = TimeSpan.FromDays(7);
|
||||
|
||||
public AttestationReportService(
|
||||
IAttestationReportStore store,
|
||||
IVerificationPolicyStore policyStore,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<AttestationReportService> logger)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_policyStore = policyStore ?? throw new ArgumentNullException(nameof(policyStore));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<ArtifactAttestationReport?> GetReportAsync(string artifactDigest, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
|
||||
|
||||
var stored = await _store.GetAsync(artifactDigest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (stored == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if (stored.ExpiresAt.HasValue && stored.ExpiresAt.Value <= _timeProvider.GetUtcNow())
|
||||
{
|
||||
_logger.LogDebug("Report for artifact {ArtifactDigest} has expired", artifactDigest);
|
||||
return null;
|
||||
}
|
||||
|
||||
return stored.Report;
|
||||
}
|
||||
|
||||
public async Task<AttestationReportListResponse> ListReportsAsync(AttestationReportQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
var reports = await _store.ListAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
var total = await _store.CountAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var artifactReports = reports
|
||||
.Where(r => !r.ExpiresAt.HasValue || r.ExpiresAt.Value > _timeProvider.GetUtcNow())
|
||||
.Select(r => r.Report)
|
||||
.ToList();
|
||||
|
||||
return new AttestationReportListResponse(
|
||||
Reports: artifactReports,
|
||||
Total: total,
|
||||
Limit: query.Limit,
|
||||
Offset: query.Offset);
|
||||
}
|
||||
|
||||
public async Task<ArtifactAttestationReport> GenerateReportAsync(VerifyArtifactRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.ArtifactDigest);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Get applicable policies
|
||||
var policies = await GetApplicablePoliciesAsync(request.PolicyIds, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Generate verification results (simulated - would connect to actual Attestor service)
|
||||
var verificationResults = await GenerateVerificationResultsAsync(request, policies, now, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Calculate policy compliance
|
||||
var policyCompliance = CalculatePolicyCompliance(policies, verificationResults);
|
||||
|
||||
// Calculate coverage
|
||||
var coverage = CalculateCoverage(policies, verificationResults);
|
||||
|
||||
// Determine overall status
|
||||
var overallStatus = DetermineOverallStatus(verificationResults, policyCompliance);
|
||||
|
||||
var report = new ArtifactAttestationReport(
|
||||
ArtifactDigest: request.ArtifactDigest,
|
||||
ArtifactUri: request.ArtifactUri,
|
||||
OverallStatus: overallStatus,
|
||||
AttestationCount: verificationResults.Count,
|
||||
VerificationResults: verificationResults,
|
||||
PolicyCompliance: policyCompliance,
|
||||
Coverage: coverage,
|
||||
EvaluatedAt: now);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Generated attestation report for artifact {ArtifactDigest} with status {Status}",
|
||||
request.ArtifactDigest,
|
||||
overallStatus);
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
public async Task<StoredAttestationReport> StoreReportAsync(ArtifactAttestationReport report, TimeSpan? ttl = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(report);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = now.Add(ttl ?? DefaultTtl);
|
||||
|
||||
var storedReport = new StoredAttestationReport(
|
||||
Id: $"report-{report.ArtifactDigest}-{now.Ticks}",
|
||||
Report: report,
|
||||
StoredAt: now,
|
||||
ExpiresAt: expiresAt);
|
||||
|
||||
await _store.CreateAsync(storedReport, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Stored attestation report for artifact {ArtifactDigest}, expires at {ExpiresAt}",
|
||||
report.ArtifactDigest,
|
||||
expiresAt);
|
||||
|
||||
return storedReport;
|
||||
}
|
||||
|
||||
public async Task<AttestationStatistics> GetStatisticsAsync(AttestationReportQuery? filter = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = filter ?? new AttestationReportQuery(
|
||||
ArtifactDigests: null,
|
||||
ArtifactUriPattern: null,
|
||||
PolicyIds: null,
|
||||
PredicateTypes: null,
|
||||
StatusFilter: null,
|
||||
FromTime: null,
|
||||
ToTime: null,
|
||||
IncludeDetails: false,
|
||||
Limit: int.MaxValue,
|
||||
Offset: 0);
|
||||
|
||||
var reports = await _store.ListAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Filter expired
|
||||
var validReports = reports
|
||||
.Where(r => !r.ExpiresAt.HasValue || r.ExpiresAt.Value > now)
|
||||
.ToList();
|
||||
|
||||
var statusDistribution = validReports
|
||||
.GroupBy(r => r.Report.OverallStatus)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var predicateTypeDistribution = validReports
|
||||
.SelectMany(r => r.Report.VerificationResults)
|
||||
.GroupBy(v => v.PredicateType)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var policyDistribution = validReports
|
||||
.SelectMany(r => r.Report.VerificationResults)
|
||||
.Where(v => v.PolicyId != null)
|
||||
.GroupBy(v => v.PolicyId!)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var totalAttestations = validReports.Sum(r => r.Report.AttestationCount);
|
||||
|
||||
var averageAgeSeconds = validReports.Count > 0
|
||||
? validReports.Average(r => (now - r.Report.EvaluatedAt).TotalSeconds)
|
||||
: 0;
|
||||
|
||||
var coverageRate = validReports.Count > 0
|
||||
? validReports.Average(r => r.Report.Coverage.CoveragePercentage)
|
||||
: 0;
|
||||
|
||||
return new AttestationStatistics(
|
||||
TotalArtifacts: validReports.Count,
|
||||
TotalAttestations: totalAttestations,
|
||||
StatusDistribution: statusDistribution,
|
||||
PredicateTypeDistribution: predicateTypeDistribution,
|
||||
PolicyDistribution: policyDistribution,
|
||||
AverageAgeSeconds: averageAgeSeconds,
|
||||
CoverageRate: coverageRate,
|
||||
EvaluatedAt: now);
|
||||
}
|
||||
|
||||
public async Task<int> PurgeExpiredReportsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var count = await _store.DeleteExpiredAsync(now, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (count > 0)
|
||||
{
|
||||
_logger.LogInformation("Purged {Count} expired attestation reports", count);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<VerificationPolicy>> GetApplicablePoliciesAsync(
|
||||
IReadOnlyList<string>? policyIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (policyIds is { Count: > 0 })
|
||||
{
|
||||
var policies = new List<VerificationPolicy>();
|
||||
foreach (var policyId in policyIds)
|
||||
{
|
||||
var policy = await _policyStore.GetAsync(policyId, cancellationToken).ConfigureAwait(false);
|
||||
if (policy != null)
|
||||
{
|
||||
policies.Add(policy);
|
||||
}
|
||||
}
|
||||
return policies;
|
||||
}
|
||||
|
||||
// Get all policies if none specified
|
||||
return await _policyStore.ListAsync(null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private Task<IReadOnlyList<AttestationVerificationSummary>> GenerateVerificationResultsAsync(
|
||||
VerifyArtifactRequest request,
|
||||
IReadOnlyList<VerificationPolicy> policies,
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// This would normally connect to the Attestor service to verify actual attestations
|
||||
// For now, generate placeholder results based on policies
|
||||
var results = new List<AttestationVerificationSummary>();
|
||||
|
||||
foreach (var policy in policies)
|
||||
{
|
||||
foreach (var predicateType in policy.PredicateTypes)
|
||||
{
|
||||
// Simulated verification result
|
||||
results.Add(new AttestationVerificationSummary(
|
||||
AttestationId: $"attest-{Guid.NewGuid():N}",
|
||||
PredicateType: predicateType,
|
||||
Status: AttestationReportStatus.Pending,
|
||||
PolicyId: policy.PolicyId,
|
||||
PolicyVersion: policy.Version,
|
||||
SignatureStatus: new SignatureVerificationStatus(
|
||||
Status: AttestationReportStatus.Pending,
|
||||
TotalSignatures: 0,
|
||||
VerifiedSignatures: 0,
|
||||
RequiredSignatures: policy.SignerRequirements.MinimumSignatures,
|
||||
Signers: []),
|
||||
FreshnessStatus: new FreshnessVerificationStatus(
|
||||
Status: AttestationReportStatus.Pending,
|
||||
CreatedAt: now,
|
||||
AgeSeconds: 0,
|
||||
MaxAgeSeconds: policy.ValidityWindow?.MaxAttestationAge,
|
||||
IsFresh: true),
|
||||
TransparencyStatus: new TransparencyVerificationStatus(
|
||||
Status: policy.SignerRequirements.RequireRekor
|
||||
? AttestationReportStatus.Pending
|
||||
: AttestationReportStatus.Skipped,
|
||||
RekorEntry: null,
|
||||
InclusionVerified: false),
|
||||
Issues: [],
|
||||
CreatedAt: now));
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AttestationVerificationSummary>>(results);
|
||||
}
|
||||
|
||||
private static PolicyComplianceSummary CalculatePolicyCompliance(
|
||||
IReadOnlyList<VerificationPolicy> policies,
|
||||
IReadOnlyList<AttestationVerificationSummary> results)
|
||||
{
|
||||
var policyResults = new List<PolicyEvaluationSummary>();
|
||||
var passed = 0;
|
||||
var failed = 0;
|
||||
var warned = 0;
|
||||
|
||||
foreach (var policy in policies)
|
||||
{
|
||||
var policyVerifications = results.Where(r => r.PolicyId == policy.PolicyId).ToList();
|
||||
|
||||
var status = AttestationReportStatus.Pending;
|
||||
var verdict = "pending";
|
||||
var issues = new List<string>();
|
||||
|
||||
if (policyVerifications.All(v => v.Status == AttestationReportStatus.Pass))
|
||||
{
|
||||
status = AttestationReportStatus.Pass;
|
||||
verdict = "compliant";
|
||||
passed++;
|
||||
}
|
||||
else if (policyVerifications.Any(v => v.Status == AttestationReportStatus.Fail))
|
||||
{
|
||||
status = AttestationReportStatus.Fail;
|
||||
verdict = "non-compliant";
|
||||
failed++;
|
||||
issues.AddRange(policyVerifications.SelectMany(v => v.Issues));
|
||||
}
|
||||
else if (policyVerifications.Any(v => v.Status == AttestationReportStatus.Warn))
|
||||
{
|
||||
status = AttestationReportStatus.Warn;
|
||||
verdict = "warning";
|
||||
warned++;
|
||||
}
|
||||
|
||||
policyResults.Add(new PolicyEvaluationSummary(
|
||||
PolicyId: policy.PolicyId,
|
||||
PolicyVersion: policy.Version,
|
||||
Status: status,
|
||||
Verdict: verdict,
|
||||
Issues: issues));
|
||||
}
|
||||
|
||||
var overallStatus = failed > 0
|
||||
? AttestationReportStatus.Fail
|
||||
: warned > 0
|
||||
? AttestationReportStatus.Warn
|
||||
: passed > 0
|
||||
? AttestationReportStatus.Pass
|
||||
: AttestationReportStatus.Pending;
|
||||
|
||||
return new PolicyComplianceSummary(
|
||||
Status: overallStatus,
|
||||
PoliciesEvaluated: policies.Count,
|
||||
PoliciesPassed: passed,
|
||||
PoliciesFailed: failed,
|
||||
PoliciesWarned: warned,
|
||||
PolicyResults: policyResults);
|
||||
}
|
||||
|
||||
private static AttestationCoverageSummary CalculateCoverage(
|
||||
IReadOnlyList<VerificationPolicy> policies,
|
||||
IReadOnlyList<AttestationVerificationSummary> results)
|
||||
{
|
||||
var requiredTypes = policies
|
||||
.SelectMany(p => p.PredicateTypes)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var presentTypes = results
|
||||
.Select(r => r.PredicateType)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var missingTypes = requiredTypes.Except(presentTypes).ToList();
|
||||
|
||||
var coveragePercentage = requiredTypes.Count > 0
|
||||
? (double)(requiredTypes.Count - missingTypes.Count) / requiredTypes.Count * 100
|
||||
: 100;
|
||||
|
||||
return new AttestationCoverageSummary(
|
||||
PredicateTypesRequired: requiredTypes,
|
||||
PredicateTypesPresent: presentTypes,
|
||||
PredicateTypesMissing: missingTypes,
|
||||
CoveragePercentage: Math.Round(coveragePercentage, 2),
|
||||
IsComplete: missingTypes.Count == 0);
|
||||
}
|
||||
|
||||
private static AttestationReportStatus DetermineOverallStatus(
|
||||
IReadOnlyList<AttestationVerificationSummary> results,
|
||||
PolicyComplianceSummary compliance)
|
||||
{
|
||||
if (compliance.Status == AttestationReportStatus.Fail)
|
||||
{
|
||||
return AttestationReportStatus.Fail;
|
||||
}
|
||||
|
||||
if (results.Any(r => r.Status == AttestationReportStatus.Fail))
|
||||
{
|
||||
return AttestationReportStatus.Fail;
|
||||
}
|
||||
|
||||
if (compliance.Status == AttestationReportStatus.Warn ||
|
||||
results.Any(r => r.Status == AttestationReportStatus.Warn))
|
||||
{
|
||||
return AttestationReportStatus.Warn;
|
||||
}
|
||||
|
||||
if (results.All(r => r.Status == AttestationReportStatus.Pass))
|
||||
{
|
||||
return AttestationReportStatus.Pass;
|
||||
}
|
||||
|
||||
if (results.All(r => r.Status == AttestationReportStatus.Pending))
|
||||
{
|
||||
return AttestationReportStatus.Pending;
|
||||
}
|
||||
|
||||
return AttestationReportStatus.Skipped;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
namespace StellaOps.Policy.Engine.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing and querying attestation reports per CONTRACT-VERIFICATION-POLICY-006.
|
||||
/// </summary>
|
||||
public interface IAttestationReportService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets an attestation report for a specific artifact.
|
||||
/// </summary>
|
||||
Task<ArtifactAttestationReport?> GetReportAsync(
|
||||
string artifactDigest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists attestation reports matching the query.
|
||||
/// </summary>
|
||||
Task<AttestationReportListResponse> ListReportsAsync(
|
||||
AttestationReportQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Generates an attestation report for an artifact by verifying its attestations.
|
||||
/// </summary>
|
||||
Task<ArtifactAttestationReport> GenerateReportAsync(
|
||||
VerifyArtifactRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Stores an attestation report.
|
||||
/// </summary>
|
||||
Task<StoredAttestationReport> StoreReportAsync(
|
||||
ArtifactAttestationReport report,
|
||||
TimeSpan? ttl = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets aggregated attestation statistics.
|
||||
/// </summary>
|
||||
Task<AttestationStatistics> GetStatisticsAsync(
|
||||
AttestationReportQuery? filter = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes expired attestation reports.
|
||||
/// </summary>
|
||||
Task<int> PurgeExpiredReportsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store for persisting attestation reports.
|
||||
/// </summary>
|
||||
public interface IAttestationReportStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a stored report by artifact digest.
|
||||
/// </summary>
|
||||
Task<StoredAttestationReport?> GetAsync(
|
||||
string artifactDigest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists stored reports matching the query.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<StoredAttestationReport>> ListAsync(
|
||||
AttestationReportQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Counts stored reports matching the query.
|
||||
/// </summary>
|
||||
Task<int> CountAsync(
|
||||
AttestationReportQuery? query = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Stores a report.
|
||||
/// </summary>
|
||||
Task<StoredAttestationReport> CreateAsync(
|
||||
StoredAttestationReport report,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates a stored report.
|
||||
/// </summary>
|
||||
Task<StoredAttestationReport?> UpdateAsync(
|
||||
string artifactDigest,
|
||||
Func<StoredAttestationReport, StoredAttestationReport> update,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes expired reports.
|
||||
/// </summary>
|
||||
Task<int> DeleteExpiredAsync(
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace StellaOps.Policy.Engine.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for persisting verification policies per CONTRACT-VERIFICATION-POLICY-006.
|
||||
/// </summary>
|
||||
public interface IVerificationPolicyStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a policy by ID.
|
||||
/// </summary>
|
||||
Task<VerificationPolicy?> GetAsync(string policyId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all policies for a tenant scope.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<VerificationPolicy>> ListAsync(
|
||||
string? tenantScope = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new policy.
|
||||
/// </summary>
|
||||
Task<VerificationPolicy> CreateAsync(
|
||||
VerificationPolicy policy,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing policy.
|
||||
/// </summary>
|
||||
Task<VerificationPolicy?> UpdateAsync(
|
||||
string policyId,
|
||||
Func<VerificationPolicy, VerificationPolicy> update,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a policy.
|
||||
/// </summary>
|
||||
Task<bool> DeleteAsync(string policyId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a policy exists.
|
||||
/// </summary>
|
||||
Task<bool> ExistsAsync(string policyId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of attestation report store per CONTRACT-VERIFICATION-POLICY-006.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryAttestationReportStore : IAttestationReportStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, StoredAttestationReport> _reports = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public Task<StoredAttestationReport?> GetAsync(string artifactDigest, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
|
||||
|
||||
_reports.TryGetValue(artifactDigest, out var report);
|
||||
return Task.FromResult(report);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<StoredAttestationReport>> ListAsync(AttestationReportQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
IEnumerable<StoredAttestationReport> reports = _reports.Values;
|
||||
|
||||
// Filter by artifact digests
|
||||
if (query.ArtifactDigests is { Count: > 0 })
|
||||
{
|
||||
var digestSet = query.ArtifactDigests.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
reports = reports.Where(r => digestSet.Contains(r.Report.ArtifactDigest));
|
||||
}
|
||||
|
||||
// Filter by artifact URI pattern
|
||||
if (!string.IsNullOrWhiteSpace(query.ArtifactUriPattern))
|
||||
{
|
||||
var pattern = new Regex(query.ArtifactUriPattern, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1));
|
||||
reports = reports.Where(r => r.Report.ArtifactUri != null && pattern.IsMatch(r.Report.ArtifactUri));
|
||||
}
|
||||
|
||||
// Filter by policy IDs
|
||||
if (query.PolicyIds is { Count: > 0 })
|
||||
{
|
||||
var policySet = query.PolicyIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
reports = reports.Where(r =>
|
||||
r.Report.VerificationResults.Any(v =>
|
||||
v.PolicyId != null && policySet.Contains(v.PolicyId)));
|
||||
}
|
||||
|
||||
// Filter by predicate types
|
||||
if (query.PredicateTypes is { Count: > 0 })
|
||||
{
|
||||
var predicateSet = query.PredicateTypes.ToHashSet(StringComparer.Ordinal);
|
||||
reports = reports.Where(r =>
|
||||
r.Report.VerificationResults.Any(v => predicateSet.Contains(v.PredicateType)));
|
||||
}
|
||||
|
||||
// Filter by status
|
||||
if (query.StatusFilter is { Count: > 0 })
|
||||
{
|
||||
var statusSet = query.StatusFilter.ToHashSet();
|
||||
reports = reports.Where(r => statusSet.Contains(r.Report.OverallStatus));
|
||||
}
|
||||
|
||||
// Filter by time range
|
||||
if (query.FromTime.HasValue)
|
||||
{
|
||||
reports = reports.Where(r => r.Report.EvaluatedAt >= query.FromTime.Value);
|
||||
}
|
||||
|
||||
if (query.ToTime.HasValue)
|
||||
{
|
||||
reports = reports.Where(r => r.Report.EvaluatedAt <= query.ToTime.Value);
|
||||
}
|
||||
|
||||
// Order by evaluated time descending
|
||||
var result = reports
|
||||
.OrderByDescending(r => r.Report.EvaluatedAt)
|
||||
.Skip(query.Offset)
|
||||
.Take(query.Limit)
|
||||
.ToList() as IReadOnlyList<StoredAttestationReport>;
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
public Task<int> CountAsync(AttestationReportQuery? query = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (query == null)
|
||||
{
|
||||
return Task.FromResult(_reports.Count);
|
||||
}
|
||||
|
||||
IEnumerable<StoredAttestationReport> reports = _reports.Values;
|
||||
|
||||
// Apply same filters as ListAsync but only count
|
||||
if (query.ArtifactDigests is { Count: > 0 })
|
||||
{
|
||||
var digestSet = query.ArtifactDigests.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
reports = reports.Where(r => digestSet.Contains(r.Report.ArtifactDigest));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.ArtifactUriPattern))
|
||||
{
|
||||
var pattern = new Regex(query.ArtifactUriPattern, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1));
|
||||
reports = reports.Where(r => r.Report.ArtifactUri != null && pattern.IsMatch(r.Report.ArtifactUri));
|
||||
}
|
||||
|
||||
if (query.PolicyIds is { Count: > 0 })
|
||||
{
|
||||
var policySet = query.PolicyIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
reports = reports.Where(r =>
|
||||
r.Report.VerificationResults.Any(v =>
|
||||
v.PolicyId != null && policySet.Contains(v.PolicyId)));
|
||||
}
|
||||
|
||||
if (query.PredicateTypes is { Count: > 0 })
|
||||
{
|
||||
var predicateSet = query.PredicateTypes.ToHashSet(StringComparer.Ordinal);
|
||||
reports = reports.Where(r =>
|
||||
r.Report.VerificationResults.Any(v => predicateSet.Contains(v.PredicateType)));
|
||||
}
|
||||
|
||||
if (query.StatusFilter is { Count: > 0 })
|
||||
{
|
||||
var statusSet = query.StatusFilter.ToHashSet();
|
||||
reports = reports.Where(r => statusSet.Contains(r.Report.OverallStatus));
|
||||
}
|
||||
|
||||
if (query.FromTime.HasValue)
|
||||
{
|
||||
reports = reports.Where(r => r.Report.EvaluatedAt >= query.FromTime.Value);
|
||||
}
|
||||
|
||||
if (query.ToTime.HasValue)
|
||||
{
|
||||
reports = reports.Where(r => r.Report.EvaluatedAt <= query.ToTime.Value);
|
||||
}
|
||||
|
||||
return Task.FromResult(reports.Count());
|
||||
}
|
||||
|
||||
public Task<StoredAttestationReport> CreateAsync(StoredAttestationReport report, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(report);
|
||||
|
||||
// Upsert behavior - replace if exists
|
||||
_reports[report.Report.ArtifactDigest] = report;
|
||||
return Task.FromResult(report);
|
||||
}
|
||||
|
||||
public Task<StoredAttestationReport?> UpdateAsync(
|
||||
string artifactDigest,
|
||||
Func<StoredAttestationReport, StoredAttestationReport> update,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
|
||||
ArgumentNullException.ThrowIfNull(update);
|
||||
|
||||
if (!_reports.TryGetValue(artifactDigest, out var existing))
|
||||
{
|
||||
return Task.FromResult<StoredAttestationReport?>(null);
|
||||
}
|
||||
|
||||
var updated = update(existing);
|
||||
_reports[artifactDigest] = updated;
|
||||
|
||||
return Task.FromResult<StoredAttestationReport?>(updated);
|
||||
}
|
||||
|
||||
public Task<int> DeleteExpiredAsync(DateTimeOffset now, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var expired = _reports.Values
|
||||
.Where(r => r.ExpiresAt.HasValue && r.ExpiresAt.Value <= now)
|
||||
.Select(r => r.Report.ArtifactDigest)
|
||||
.ToList();
|
||||
|
||||
var count = 0;
|
||||
foreach (var digest in expired)
|
||||
{
|
||||
if (_reports.TryRemove(digest, out _))
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of verification policy store per CONTRACT-VERIFICATION-POLICY-006.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryVerificationPolicyStore : IVerificationPolicyStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, VerificationPolicy> _policies = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public Task<VerificationPolicy?> GetAsync(string policyId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(policyId);
|
||||
|
||||
_policies.TryGetValue(policyId, out var policy);
|
||||
return Task.FromResult(policy);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<VerificationPolicy>> ListAsync(
|
||||
string? tenantScope = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IEnumerable<VerificationPolicy> policies = _policies.Values;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenantScope))
|
||||
{
|
||||
policies = policies.Where(p =>
|
||||
p.TenantScope == "*" ||
|
||||
p.TenantScope.Equals(tenantScope, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
var result = policies
|
||||
.OrderBy(p => p.PolicyId)
|
||||
.ToList() as IReadOnlyList<VerificationPolicy>;
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
public Task<VerificationPolicy> CreateAsync(
|
||||
VerificationPolicy policy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
|
||||
if (!_policies.TryAdd(policy.PolicyId, policy))
|
||||
{
|
||||
throw new InvalidOperationException($"Policy '{policy.PolicyId}' already exists.");
|
||||
}
|
||||
|
||||
return Task.FromResult(policy);
|
||||
}
|
||||
|
||||
public Task<VerificationPolicy?> UpdateAsync(
|
||||
string policyId,
|
||||
Func<VerificationPolicy, VerificationPolicy> update,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(policyId);
|
||||
ArgumentNullException.ThrowIfNull(update);
|
||||
|
||||
if (!_policies.TryGetValue(policyId, out var existing))
|
||||
{
|
||||
return Task.FromResult<VerificationPolicy?>(null);
|
||||
}
|
||||
|
||||
var updated = update(existing);
|
||||
_policies[policyId] = updated;
|
||||
|
||||
return Task.FromResult<VerificationPolicy?>(updated);
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(string policyId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(policyId);
|
||||
|
||||
return Task.FromResult(_policies.TryRemove(policyId, out _));
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(string policyId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(policyId);
|
||||
|
||||
return Task.FromResult(_policies.ContainsKey(policyId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Editor metadata for verification policy forms per CONTRACT-VERIFICATION-POLICY-006.
|
||||
/// </summary>
|
||||
public sealed record VerificationPolicyEditorMetadata(
|
||||
[property: JsonPropertyName("available_predicate_types")] IReadOnlyList<PredicateTypeInfo> AvailablePredicateTypes,
|
||||
[property: JsonPropertyName("available_algorithms")] IReadOnlyList<AlgorithmInfo> AvailableAlgorithms,
|
||||
[property: JsonPropertyName("default_signer_requirements")] SignerRequirements DefaultSignerRequirements,
|
||||
[property: JsonPropertyName("validation_constraints")] ValidationConstraintsInfo ValidationConstraints);
|
||||
|
||||
/// <summary>
|
||||
/// Information about a predicate type for editor dropdowns.
|
||||
/// </summary>
|
||||
public sealed record PredicateTypeInfo(
|
||||
[property: JsonPropertyName("type")] string Type,
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("description")] string Description,
|
||||
[property: JsonPropertyName("category")] PredicateCategory Category,
|
||||
[property: JsonPropertyName("is_default")] bool IsDefault);
|
||||
|
||||
/// <summary>
|
||||
/// Category of predicate type.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<PredicateCategory>))]
|
||||
public enum PredicateCategory
|
||||
{
|
||||
StellaOps,
|
||||
Slsa,
|
||||
Sbom,
|
||||
Vex
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a signing algorithm for editor dropdowns.
|
||||
/// </summary>
|
||||
public sealed record AlgorithmInfo(
|
||||
[property: JsonPropertyName("algorithm")] string Algorithm,
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("description")] string Description,
|
||||
[property: JsonPropertyName("key_type")] string KeyType,
|
||||
[property: JsonPropertyName("is_recommended")] bool IsRecommended);
|
||||
|
||||
/// <summary>
|
||||
/// Validation constraints exposed to the editor.
|
||||
/// </summary>
|
||||
public sealed record ValidationConstraintsInfo(
|
||||
[property: JsonPropertyName("max_policy_id_length")] int MaxPolicyIdLength,
|
||||
[property: JsonPropertyName("max_version_length")] int MaxVersionLength,
|
||||
[property: JsonPropertyName("max_description_length")] int MaxDescriptionLength,
|
||||
[property: JsonPropertyName("max_predicate_types")] int MaxPredicateTypes,
|
||||
[property: JsonPropertyName("max_trusted_key_fingerprints")] int MaxTrustedKeyFingerprints,
|
||||
[property: JsonPropertyName("max_trusted_issuers")] int MaxTrustedIssuers,
|
||||
[property: JsonPropertyName("max_algorithms")] int MaxAlgorithms,
|
||||
[property: JsonPropertyName("max_metadata_entries")] int MaxMetadataEntries,
|
||||
[property: JsonPropertyName("max_attestation_age_seconds")] int MaxAttestationAgeSeconds);
|
||||
|
||||
/// <summary>
|
||||
/// Editor view of a verification policy with validation state.
|
||||
/// </summary>
|
||||
public sealed record VerificationPolicyEditorView(
|
||||
[property: JsonPropertyName("policy")] VerificationPolicy Policy,
|
||||
[property: JsonPropertyName("validation")] VerificationPolicyValidationResult Validation,
|
||||
[property: JsonPropertyName("suggestions")] IReadOnlyList<PolicySuggestion>? Suggestions,
|
||||
[property: JsonPropertyName("can_delete")] bool CanDelete,
|
||||
[property: JsonPropertyName("is_referenced")] bool IsReferenced);
|
||||
|
||||
/// <summary>
|
||||
/// Suggestion for policy improvement.
|
||||
/// </summary>
|
||||
public sealed record PolicySuggestion(
|
||||
[property: JsonPropertyName("code")] string Code,
|
||||
[property: JsonPropertyName("field")] string Field,
|
||||
[property: JsonPropertyName("message")] string Message,
|
||||
[property: JsonPropertyName("suggested_value")] object? SuggestedValue);
|
||||
|
||||
/// <summary>
|
||||
/// Request to validate a verification policy without persisting.
|
||||
/// </summary>
|
||||
public sealed record ValidatePolicyRequest(
|
||||
[property: JsonPropertyName("policy_id")] string? PolicyId,
|
||||
[property: JsonPropertyName("version")] string? Version,
|
||||
[property: JsonPropertyName("description")] string? Description,
|
||||
[property: JsonPropertyName("tenant_scope")] string? TenantScope,
|
||||
[property: JsonPropertyName("predicate_types")] IReadOnlyList<string>? PredicateTypes,
|
||||
[property: JsonPropertyName("signer_requirements")] SignerRequirements? SignerRequirements,
|
||||
[property: JsonPropertyName("validity_window")] ValidityWindow? ValidityWindow,
|
||||
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, object?>? Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Response from policy validation.
|
||||
/// </summary>
|
||||
public sealed record ValidatePolicyResponse(
|
||||
[property: JsonPropertyName("valid")] bool Valid,
|
||||
[property: JsonPropertyName("errors")] IReadOnlyList<VerificationPolicyValidationError> Errors,
|
||||
[property: JsonPropertyName("warnings")] IReadOnlyList<VerificationPolicyValidationError> Warnings,
|
||||
[property: JsonPropertyName("suggestions")] IReadOnlyList<PolicySuggestion> Suggestions);
|
||||
|
||||
/// <summary>
|
||||
/// Request to clone a verification policy.
|
||||
/// </summary>
|
||||
public sealed record ClonePolicyRequest(
|
||||
[property: JsonPropertyName("source_policy_id")] string SourcePolicyId,
|
||||
[property: JsonPropertyName("new_policy_id")] string NewPolicyId,
|
||||
[property: JsonPropertyName("new_version")] string? NewVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Request to compare two verification policies.
|
||||
/// </summary>
|
||||
public sealed record ComparePoliciesRequest(
|
||||
[property: JsonPropertyName("policy_id_a")] string PolicyIdA,
|
||||
[property: JsonPropertyName("policy_id_b")] string PolicyIdB);
|
||||
|
||||
/// <summary>
|
||||
/// Result of comparing two verification policies.
|
||||
/// </summary>
|
||||
public sealed record ComparePoliciesResponse(
|
||||
[property: JsonPropertyName("policy_a")] VerificationPolicy PolicyA,
|
||||
[property: JsonPropertyName("policy_b")] VerificationPolicy PolicyB,
|
||||
[property: JsonPropertyName("differences")] IReadOnlyList<PolicyDifference> Differences);
|
||||
|
||||
/// <summary>
|
||||
/// A difference between two policies.
|
||||
/// </summary>
|
||||
public sealed record PolicyDifference(
|
||||
[property: JsonPropertyName("field")] string Field,
|
||||
[property: JsonPropertyName("value_a")] object? ValueA,
|
||||
[property: JsonPropertyName("value_b")] object? ValueB,
|
||||
[property: JsonPropertyName("change_type")] DifferenceType ChangeType);
|
||||
|
||||
/// <summary>
|
||||
/// Type of difference between policies.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<DifferenceType>))]
|
||||
public enum DifferenceType
|
||||
{
|
||||
Added,
|
||||
Removed,
|
||||
Modified
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provider of editor metadata for verification policies.
|
||||
/// </summary>
|
||||
public static class VerificationPolicyEditorMetadataProvider
|
||||
{
|
||||
private static readonly IReadOnlyList<PredicateTypeInfo> AvailablePredicateTypes =
|
||||
[
|
||||
// StellaOps types
|
||||
new(PredicateTypes.SbomV1, "StellaOps SBOM", "Software Bill of Materials attestation", PredicateCategory.StellaOps, true),
|
||||
new(PredicateTypes.VexV1, "StellaOps VEX", "Vulnerability Exploitability Exchange attestation", PredicateCategory.StellaOps, true),
|
||||
new(PredicateTypes.VexDecisionV1, "StellaOps VEX Decision", "VEX decision record attestation", PredicateCategory.StellaOps, false),
|
||||
new(PredicateTypes.PolicyV1, "StellaOps Policy", "Policy decision attestation", PredicateCategory.StellaOps, false),
|
||||
new(PredicateTypes.PromotionV1, "StellaOps Promotion", "Artifact promotion attestation", PredicateCategory.StellaOps, false),
|
||||
new(PredicateTypes.EvidenceV1, "StellaOps Evidence", "Evidence collection attestation", PredicateCategory.StellaOps, false),
|
||||
new(PredicateTypes.GraphV1, "StellaOps Graph", "Dependency graph attestation", PredicateCategory.StellaOps, false),
|
||||
new(PredicateTypes.ReplayV1, "StellaOps Replay", "Replay verification attestation", PredicateCategory.StellaOps, false),
|
||||
|
||||
// SLSA types
|
||||
new(PredicateTypes.SlsaProvenanceV1, "SLSA Provenance v1", "SLSA v1.0 provenance attestation", PredicateCategory.Slsa, true),
|
||||
new(PredicateTypes.SlsaProvenanceV02, "SLSA Provenance v0.2", "SLSA v0.2 provenance attestation (legacy)", PredicateCategory.Slsa, false),
|
||||
|
||||
// SBOM types
|
||||
new(PredicateTypes.CycloneDxBom, "CycloneDX BOM", "CycloneDX Bill of Materials", PredicateCategory.Sbom, true),
|
||||
new(PredicateTypes.SpdxDocument, "SPDX Document", "SPDX SBOM document", PredicateCategory.Sbom, true),
|
||||
|
||||
// VEX types
|
||||
new(PredicateTypes.OpenVex, "OpenVEX", "OpenVEX vulnerability exchange", PredicateCategory.Vex, true)
|
||||
];
|
||||
|
||||
private static readonly IReadOnlyList<AlgorithmInfo> AvailableAlgorithms =
|
||||
[
|
||||
new("ES256", "ECDSA P-256", "ECDSA with SHA-256 and P-256 curve", "EC", true),
|
||||
new("ES384", "ECDSA P-384", "ECDSA with SHA-384 and P-384 curve", "EC", false),
|
||||
new("ES512", "ECDSA P-521", "ECDSA with SHA-512 and P-521 curve", "EC", false),
|
||||
new("RS256", "RSA-SHA256", "RSA with SHA-256", "RSA", true),
|
||||
new("RS384", "RSA-SHA384", "RSA with SHA-384", "RSA", false),
|
||||
new("RS512", "RSA-SHA512", "RSA with SHA-512", "RSA", false),
|
||||
new("PS256", "RSA-PSS-SHA256", "RSA-PSS with SHA-256", "RSA", false),
|
||||
new("PS384", "RSA-PSS-SHA384", "RSA-PSS with SHA-384", "RSA", false),
|
||||
new("PS512", "RSA-PSS-SHA512", "RSA-PSS with SHA-512", "RSA", false),
|
||||
new("EdDSA", "EdDSA", "Edwards-curve Digital Signature Algorithm (Ed25519)", "OKP", true)
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the editor metadata for verification policy forms.
|
||||
/// </summary>
|
||||
public static VerificationPolicyEditorMetadata GetMetadata(
|
||||
VerificationPolicyValidationConstraints? constraints = null)
|
||||
{
|
||||
var c = constraints ?? VerificationPolicyValidationConstraints.Default;
|
||||
|
||||
return new VerificationPolicyEditorMetadata(
|
||||
AvailablePredicateTypes: AvailablePredicateTypes,
|
||||
AvailableAlgorithms: AvailableAlgorithms,
|
||||
DefaultSignerRequirements: SignerRequirements.Default,
|
||||
ValidationConstraints: new ValidationConstraintsInfo(
|
||||
MaxPolicyIdLength: c.MaxPolicyIdLength,
|
||||
MaxVersionLength: c.MaxVersionLength,
|
||||
MaxDescriptionLength: c.MaxDescriptionLength,
|
||||
MaxPredicateTypes: c.MaxPredicateTypes,
|
||||
MaxTrustedKeyFingerprints: c.MaxTrustedKeyFingerprints,
|
||||
MaxTrustedIssuers: c.MaxTrustedIssuers,
|
||||
MaxAlgorithms: c.MaxAlgorithms,
|
||||
MaxMetadataEntries: c.MaxMetadataEntries,
|
||||
MaxAttestationAgeSeconds: c.MaxAttestationAgeSeconds));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates suggestions for a policy based on validation results.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<PolicySuggestion> GenerateSuggestions(
|
||||
CreateVerificationPolicyRequest request,
|
||||
VerificationPolicyValidationResult validation)
|
||||
{
|
||||
var suggestions = new List<PolicySuggestion>();
|
||||
|
||||
// Suggest adding Rekor if not enabled
|
||||
if (request.SignerRequirements is { RequireRekor: false })
|
||||
{
|
||||
suggestions.Add(new PolicySuggestion(
|
||||
"SUG_VP_001",
|
||||
"signer_requirements.require_rekor",
|
||||
"Consider enabling Rekor for transparency log verification.",
|
||||
true));
|
||||
}
|
||||
|
||||
// Suggest adding trusted key fingerprints if empty
|
||||
if (request.SignerRequirements is { TrustedKeyFingerprints.Count: 0 })
|
||||
{
|
||||
suggestions.Add(new PolicySuggestion(
|
||||
"SUG_VP_002",
|
||||
"signer_requirements.trusted_key_fingerprints",
|
||||
"Consider adding trusted key fingerprints to restrict accepted signers.",
|
||||
null));
|
||||
}
|
||||
|
||||
// Suggest adding validity window if not set
|
||||
if (request.ValidityWindow == null)
|
||||
{
|
||||
suggestions.Add(new PolicySuggestion(
|
||||
"SUG_VP_003",
|
||||
"validity_window",
|
||||
"Consider setting a validity window to limit attestation age.",
|
||||
new ValidityWindow(null, null, 2592000))); // 30 days default
|
||||
}
|
||||
|
||||
// Suggest EdDSA if only RSA algorithms are selected
|
||||
if (request.SignerRequirements?.Algorithms != null &&
|
||||
request.SignerRequirements.Algorithms.All(a => a.StartsWith("RS", StringComparison.OrdinalIgnoreCase) ||
|
||||
a.StartsWith("PS", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
suggestions.Add(new PolicySuggestion(
|
||||
"SUG_VP_004",
|
||||
"signer_requirements.algorithms",
|
||||
"Consider adding ES256 or EdDSA for better performance and smaller signatures.",
|
||||
null));
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Verification policy for attestation validation per CONTRACT-VERIFICATION-POLICY-006.
|
||||
/// </summary>
|
||||
public sealed record VerificationPolicy(
|
||||
[property: JsonPropertyName("policy_id")] string PolicyId,
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("description")] string? Description,
|
||||
[property: JsonPropertyName("tenant_scope")] string TenantScope,
|
||||
[property: JsonPropertyName("predicate_types")] IReadOnlyList<string> PredicateTypes,
|
||||
[property: JsonPropertyName("signer_requirements")] SignerRequirements SignerRequirements,
|
||||
[property: JsonPropertyName("validity_window")] ValidityWindow? ValidityWindow,
|
||||
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, object?>? Metadata,
|
||||
[property: JsonPropertyName("created_at")] DateTimeOffset CreatedAt,
|
||||
[property: JsonPropertyName("updated_at")] DateTimeOffset UpdatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Signer requirements for attestation verification.
|
||||
/// </summary>
|
||||
public sealed record SignerRequirements(
|
||||
[property: JsonPropertyName("minimum_signatures")] int MinimumSignatures,
|
||||
[property: JsonPropertyName("trusted_key_fingerprints")] IReadOnlyList<string> TrustedKeyFingerprints,
|
||||
[property: JsonPropertyName("trusted_issuers")] IReadOnlyList<string>? TrustedIssuers,
|
||||
[property: JsonPropertyName("require_rekor")] bool RequireRekor,
|
||||
[property: JsonPropertyName("algorithms")] IReadOnlyList<string>? Algorithms)
|
||||
{
|
||||
public static SignerRequirements Default => new(
|
||||
MinimumSignatures: 1,
|
||||
TrustedKeyFingerprints: [],
|
||||
TrustedIssuers: null,
|
||||
RequireRekor: false,
|
||||
Algorithms: ["ES256", "RS256", "EdDSA"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validity window for attestations.
|
||||
/// </summary>
|
||||
public sealed record ValidityWindow(
|
||||
[property: JsonPropertyName("not_before")] DateTimeOffset? NotBefore,
|
||||
[property: JsonPropertyName("not_after")] DateTimeOffset? NotAfter,
|
||||
[property: JsonPropertyName("max_attestation_age")] int? MaxAttestationAge);
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a verification policy.
|
||||
/// </summary>
|
||||
public sealed record CreateVerificationPolicyRequest(
|
||||
[property: JsonPropertyName("policy_id")] string PolicyId,
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("description")] string? Description,
|
||||
[property: JsonPropertyName("tenant_scope")] string? TenantScope,
|
||||
[property: JsonPropertyName("predicate_types")] IReadOnlyList<string> PredicateTypes,
|
||||
[property: JsonPropertyName("signer_requirements")] SignerRequirements? SignerRequirements,
|
||||
[property: JsonPropertyName("validity_window")] ValidityWindow? ValidityWindow,
|
||||
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, object?>? Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Request to update a verification policy.
|
||||
/// </summary>
|
||||
public sealed record UpdateVerificationPolicyRequest(
|
||||
[property: JsonPropertyName("version")] string? Version,
|
||||
[property: JsonPropertyName("description")] string? Description,
|
||||
[property: JsonPropertyName("predicate_types")] IReadOnlyList<string>? PredicateTypes,
|
||||
[property: JsonPropertyName("signer_requirements")] SignerRequirements? SignerRequirements,
|
||||
[property: JsonPropertyName("validity_window")] ValidityWindow? ValidityWindow,
|
||||
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, object?>? Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Result of verifying an attestation.
|
||||
/// </summary>
|
||||
public sealed record VerificationResult(
|
||||
[property: JsonPropertyName("valid")] bool Valid,
|
||||
[property: JsonPropertyName("predicate_type")] string? PredicateType,
|
||||
[property: JsonPropertyName("signature_count")] int SignatureCount,
|
||||
[property: JsonPropertyName("signers")] IReadOnlyList<SignerInfo> Signers,
|
||||
[property: JsonPropertyName("rekor_entry")] RekorEntry? RekorEntry,
|
||||
[property: JsonPropertyName("attestation_timestamp")] DateTimeOffset? AttestationTimestamp,
|
||||
[property: JsonPropertyName("policy_id")] string PolicyId,
|
||||
[property: JsonPropertyName("policy_version")] string PolicyVersion,
|
||||
[property: JsonPropertyName("errors")] IReadOnlyList<string>? Errors);
|
||||
|
||||
/// <summary>
|
||||
/// Information about a signer.
|
||||
/// </summary>
|
||||
public sealed record SignerInfo(
|
||||
[property: JsonPropertyName("key_fingerprint")] string KeyFingerprint,
|
||||
[property: JsonPropertyName("issuer")] string? Issuer,
|
||||
[property: JsonPropertyName("algorithm")] string Algorithm,
|
||||
[property: JsonPropertyName("verified")] bool Verified);
|
||||
|
||||
/// <summary>
|
||||
/// Rekor transparency log entry.
|
||||
/// </summary>
|
||||
public sealed record RekorEntry(
|
||||
[property: JsonPropertyName("uuid")] string Uuid,
|
||||
[property: JsonPropertyName("log_index")] long LogIndex,
|
||||
[property: JsonPropertyName("integrated_time")] DateTimeOffset IntegratedTime);
|
||||
|
||||
/// <summary>
|
||||
/// Request to verify an attestation.
|
||||
/// </summary>
|
||||
public sealed record VerifyAttestationRequest(
|
||||
[property: JsonPropertyName("envelope")] string Envelope,
|
||||
[property: JsonPropertyName("policy_id")] string PolicyId);
|
||||
|
||||
/// <summary>
|
||||
/// Standard predicate types supported by StellaOps.
|
||||
/// </summary>
|
||||
public static class PredicateTypes
|
||||
{
|
||||
// StellaOps types
|
||||
public const string SbomV1 = "stella.ops/sbom@v1";
|
||||
public const string VexV1 = "stella.ops/vex@v1";
|
||||
public const string VexDecisionV1 = "stella.ops/vexDecision@v1";
|
||||
public const string PolicyV1 = "stella.ops/policy@v1";
|
||||
public const string PromotionV1 = "stella.ops/promotion@v1";
|
||||
public const string EvidenceV1 = "stella.ops/evidence@v1";
|
||||
public const string GraphV1 = "stella.ops/graph@v1";
|
||||
public const string ReplayV1 = "stella.ops/replay@v1";
|
||||
|
||||
// Third-party types
|
||||
public const string SlsaProvenanceV02 = "https://slsa.dev/provenance/v0.2";
|
||||
public const string SlsaProvenanceV1 = "https://slsa.dev/provenance/v1";
|
||||
public const string CycloneDxBom = "https://cyclonedx.org/bom";
|
||||
public const string SpdxDocument = "https://spdx.dev/Document";
|
||||
public const string OpenVex = "https://openvex.dev/ns";
|
||||
|
||||
public static readonly IReadOnlyList<string> DefaultAllowed = new[]
|
||||
{
|
||||
SbomV1, VexV1, VexDecisionV1, PolicyV1, PromotionV1,
|
||||
EvidenceV1, GraphV1, ReplayV1,
|
||||
SlsaProvenanceV1, CycloneDxBom, SpdxDocument, OpenVex
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Validation result for verification policy per CONTRACT-VERIFICATION-POLICY-006.
|
||||
/// </summary>
|
||||
public sealed record VerificationPolicyValidationResult(
|
||||
bool IsValid,
|
||||
IReadOnlyList<VerificationPolicyValidationError> Errors)
|
||||
{
|
||||
public static VerificationPolicyValidationResult Success() =>
|
||||
new(IsValid: true, Errors: Array.Empty<VerificationPolicyValidationError>());
|
||||
|
||||
public static VerificationPolicyValidationResult Failure(params VerificationPolicyValidationError[] errors) =>
|
||||
new(IsValid: false, Errors: errors);
|
||||
|
||||
public static VerificationPolicyValidationResult Failure(IEnumerable<VerificationPolicyValidationError> errors) =>
|
||||
new(IsValid: false, Errors: errors.ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validation error for verification policy.
|
||||
/// </summary>
|
||||
public sealed record VerificationPolicyValidationError(
|
||||
string Code,
|
||||
string Field,
|
||||
string Message,
|
||||
ValidationSeverity Severity = ValidationSeverity.Error);
|
||||
|
||||
/// <summary>
|
||||
/// Severity of validation error.
|
||||
/// </summary>
|
||||
public enum ValidationSeverity
|
||||
{
|
||||
Warning,
|
||||
Error
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constraints for verification policy validation.
|
||||
/// </summary>
|
||||
public sealed record VerificationPolicyValidationConstraints
|
||||
{
|
||||
public static VerificationPolicyValidationConstraints Default { get; } = new();
|
||||
|
||||
public int MaxPolicyIdLength { get; init; } = 256;
|
||||
public int MaxVersionLength { get; init; } = 64;
|
||||
public int MaxDescriptionLength { get; init; } = 2048;
|
||||
public int MaxPredicateTypes { get; init; } = 50;
|
||||
public int MaxTrustedKeyFingerprints { get; init; } = 100;
|
||||
public int MaxTrustedIssuers { get; init; } = 50;
|
||||
public int MaxAlgorithms { get; init; } = 20;
|
||||
public int MaxMetadataEntries { get; init; } = 50;
|
||||
public int MaxAttestationAgeSeconds { get; init; } = 31536000; // 1 year
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validator for verification policies per CONTRACT-VERIFICATION-POLICY-006.
|
||||
/// </summary>
|
||||
public sealed class VerificationPolicyValidator
|
||||
{
|
||||
private static readonly Regex PolicyIdPattern = new(
|
||||
@"^[a-zA-Z0-9][a-zA-Z0-9\-_.]*$",
|
||||
RegexOptions.Compiled,
|
||||
TimeSpan.FromSeconds(1));
|
||||
|
||||
private static readonly Regex VersionPattern = new(
|
||||
@"^\d+\.\d+\.\d+(-[a-zA-Z0-9\-.]+)?(\+[a-zA-Z0-9\-.]+)?$",
|
||||
RegexOptions.Compiled,
|
||||
TimeSpan.FromSeconds(1));
|
||||
|
||||
private static readonly Regex FingerprintPattern = new(
|
||||
@"^[0-9a-fA-F]{40,128}$",
|
||||
RegexOptions.Compiled,
|
||||
TimeSpan.FromSeconds(1));
|
||||
|
||||
private static readonly Regex TenantScopePattern = new(
|
||||
@"^(\*|[a-zA-Z0-9][a-zA-Z0-9\-_.]*(\*[a-zA-Z0-9\-_.]*)?|[a-zA-Z0-9\-_.]*\*)$",
|
||||
RegexOptions.Compiled,
|
||||
TimeSpan.FromSeconds(1));
|
||||
|
||||
private static readonly HashSet<string> AllowedAlgorithms = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"ES256", "ES384", "ES512",
|
||||
"RS256", "RS384", "RS512",
|
||||
"PS256", "PS384", "PS512",
|
||||
"EdDSA"
|
||||
};
|
||||
|
||||
private readonly VerificationPolicyValidationConstraints _constraints;
|
||||
|
||||
public VerificationPolicyValidator(VerificationPolicyValidationConstraints? constraints = null)
|
||||
{
|
||||
_constraints = constraints ?? VerificationPolicyValidationConstraints.Default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a create request for verification policy.
|
||||
/// </summary>
|
||||
public VerificationPolicyValidationResult ValidateCreate(CreateVerificationPolicyRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var errors = new List<VerificationPolicyValidationError>();
|
||||
|
||||
// Validate PolicyId
|
||||
ValidatePolicyId(request.PolicyId, errors);
|
||||
|
||||
// Validate Version
|
||||
ValidateVersion(request.Version, errors);
|
||||
|
||||
// Validate Description
|
||||
ValidateDescription(request.Description, errors);
|
||||
|
||||
// Validate TenantScope
|
||||
ValidateTenantScope(request.TenantScope, errors);
|
||||
|
||||
// Validate PredicateTypes
|
||||
ValidatePredicateTypes(request.PredicateTypes, errors);
|
||||
|
||||
// Validate SignerRequirements
|
||||
ValidateSignerRequirements(request.SignerRequirements, errors);
|
||||
|
||||
// Validate ValidityWindow
|
||||
ValidateValidityWindow(request.ValidityWindow, errors);
|
||||
|
||||
// Validate Metadata
|
||||
ValidateMetadata(request.Metadata, errors);
|
||||
|
||||
return errors.Count == 0
|
||||
? VerificationPolicyValidationResult.Success()
|
||||
: VerificationPolicyValidationResult.Failure(errors);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates an update request for verification policy.
|
||||
/// </summary>
|
||||
public VerificationPolicyValidationResult ValidateUpdate(UpdateVerificationPolicyRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var errors = new List<VerificationPolicyValidationError>();
|
||||
|
||||
// Version is optional in updates but must be valid if provided
|
||||
if (request.Version != null)
|
||||
{
|
||||
ValidateVersion(request.Version, errors);
|
||||
}
|
||||
|
||||
// Description is optional in updates
|
||||
if (request.Description != null)
|
||||
{
|
||||
ValidateDescription(request.Description, errors);
|
||||
}
|
||||
|
||||
// PredicateTypes is optional in updates
|
||||
if (request.PredicateTypes != null)
|
||||
{
|
||||
ValidatePredicateTypes(request.PredicateTypes, errors);
|
||||
}
|
||||
|
||||
// SignerRequirements is optional in updates
|
||||
if (request.SignerRequirements != null)
|
||||
{
|
||||
ValidateSignerRequirements(request.SignerRequirements, errors);
|
||||
}
|
||||
|
||||
// ValidityWindow is optional in updates
|
||||
if (request.ValidityWindow != null)
|
||||
{
|
||||
ValidateValidityWindow(request.ValidityWindow, errors);
|
||||
}
|
||||
|
||||
// Metadata is optional in updates
|
||||
if (request.Metadata != null)
|
||||
{
|
||||
ValidateMetadata(request.Metadata, errors);
|
||||
}
|
||||
|
||||
return errors.Count == 0
|
||||
? VerificationPolicyValidationResult.Success()
|
||||
: VerificationPolicyValidationResult.Failure(errors);
|
||||
}
|
||||
|
||||
private void ValidatePolicyId(string? policyId, List<VerificationPolicyValidationError> errors)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(policyId))
|
||||
{
|
||||
errors.Add(new VerificationPolicyValidationError(
|
||||
"ERR_VP_001",
|
||||
"policy_id",
|
||||
"Policy ID is required."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (policyId.Length > _constraints.MaxPolicyIdLength)
|
||||
{
|
||||
errors.Add(new VerificationPolicyValidationError(
|
||||
"ERR_VP_002",
|
||||
"policy_id",
|
||||
$"Policy ID exceeds maximum length of {_constraints.MaxPolicyIdLength} characters."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!PolicyIdPattern.IsMatch(policyId))
|
||||
{
|
||||
errors.Add(new VerificationPolicyValidationError(
|
||||
"ERR_VP_003",
|
||||
"policy_id",
|
||||
"Policy ID must start with alphanumeric and contain only alphanumeric, hyphens, underscores, or dots."));
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateVersion(string? version, List<VerificationPolicyValidationError> errors)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
// Version defaults to "1.0.0" if not provided, so this is a warning
|
||||
errors.Add(new VerificationPolicyValidationError(
|
||||
"WARN_VP_001",
|
||||
"version",
|
||||
"Version not provided; defaulting to 1.0.0.",
|
||||
ValidationSeverity.Warning));
|
||||
return;
|
||||
}
|
||||
|
||||
if (version.Length > _constraints.MaxVersionLength)
|
||||
{
|
||||
errors.Add(new VerificationPolicyValidationError(
|
||||
"ERR_VP_004",
|
||||
"version",
|
||||
$"Version exceeds maximum length of {_constraints.MaxVersionLength} characters."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!VersionPattern.IsMatch(version))
|
||||
{
|
||||
errors.Add(new VerificationPolicyValidationError(
|
||||
"ERR_VP_005",
|
||||
"version",
|
||||
"Version must follow semver format (e.g., 1.0.0, 2.1.0-alpha.1)."));
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateDescription(string? description, List<VerificationPolicyValidationError> errors)
|
||||
{
|
||||
if (description != null && description.Length > _constraints.MaxDescriptionLength)
|
||||
{
|
||||
errors.Add(new VerificationPolicyValidationError(
|
||||
"ERR_VP_006",
|
||||
"description",
|
||||
$"Description exceeds maximum length of {_constraints.MaxDescriptionLength} characters."));
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateTenantScope(string? tenantScope, List<VerificationPolicyValidationError> errors)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantScope))
|
||||
{
|
||||
// Defaults to "*" if not provided
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TenantScopePattern.IsMatch(tenantScope))
|
||||
{
|
||||
errors.Add(new VerificationPolicyValidationError(
|
||||
"ERR_VP_007",
|
||||
"tenant_scope",
|
||||
"Tenant scope must be '*' or a valid identifier with optional wildcard suffix."));
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidatePredicateTypes(IReadOnlyList<string>? predicateTypes, List<VerificationPolicyValidationError> errors)
|
||||
{
|
||||
if (predicateTypes == null || predicateTypes.Count == 0)
|
||||
{
|
||||
errors.Add(new VerificationPolicyValidationError(
|
||||
"ERR_VP_008",
|
||||
"predicate_types",
|
||||
"At least one predicate type is required."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (predicateTypes.Count > _constraints.MaxPredicateTypes)
|
||||
{
|
||||
errors.Add(new VerificationPolicyValidationError(
|
||||
"ERR_VP_009",
|
||||
"predicate_types",
|
||||
$"Predicate types exceeds maximum count of {_constraints.MaxPredicateTypes}."));
|
||||
return;
|
||||
}
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < predicateTypes.Count; i++)
|
||||
{
|
||||
var predicateType = predicateTypes[i];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(predicateType))
|
||||
{
|
||||
errors.Add(new VerificationPolicyValidationError(
|
||||
"ERR_VP_010",
|
||||
$"predicate_types[{i}]",
|
||||
"Predicate type cannot be empty."));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!seen.Add(predicateType))
|
||||
{
|
||||
errors.Add(new VerificationPolicyValidationError(
|
||||
"WARN_VP_002",
|
||||
$"predicate_types[{i}]",
|
||||
$"Duplicate predicate type '{predicateType}'.",
|
||||
ValidationSeverity.Warning));
|
||||
}
|
||||
|
||||
// Check if it's a known predicate type or valid URI format
|
||||
if (!IsKnownPredicateType(predicateType) && !IsValidPredicateTypeUri(predicateType))
|
||||
{
|
||||
errors.Add(new VerificationPolicyValidationError(
|
||||
"WARN_VP_003",
|
||||
$"predicate_types[{i}]",
|
||||
$"Predicate type '{predicateType}' is not a known StellaOps or standard type.",
|
||||
ValidationSeverity.Warning));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateSignerRequirements(SignerRequirements? requirements, List<VerificationPolicyValidationError> errors)
|
||||
{
|
||||
if (requirements == null)
|
||||
{
|
||||
// Defaults to SignerRequirements.Default if not provided
|
||||
return;
|
||||
}
|
||||
|
||||
if (requirements.MinimumSignatures < 1)
|
||||
{
|
||||
errors.Add(new VerificationPolicyValidationError(
|
||||
"ERR_VP_011",
|
||||
"signer_requirements.minimum_signatures",
|
||||
"Minimum signatures must be at least 1."));
|
||||
}
|
||||
|
||||
if (requirements.TrustedKeyFingerprints.Count > _constraints.MaxTrustedKeyFingerprints)
|
||||
{
|
||||
errors.Add(new VerificationPolicyValidationError(
|
||||
"ERR_VP_012",
|
||||
"signer_requirements.trusted_key_fingerprints",
|
||||
$"Trusted key fingerprints exceeds maximum count of {_constraints.MaxTrustedKeyFingerprints}."));
|
||||
}
|
||||
|
||||
var seenFingerprints = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = 0; i < requirements.TrustedKeyFingerprints.Count; i++)
|
||||
{
|
||||
var fingerprint = requirements.TrustedKeyFingerprints[i];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(fingerprint))
|
||||
{
|
||||
errors.Add(new VerificationPolicyValidationError(
|
||||
"ERR_VP_013",
|
||||
$"signer_requirements.trusted_key_fingerprints[{i}]",
|
||||
"Key fingerprint cannot be empty."));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!FingerprintPattern.IsMatch(fingerprint))
|
||||
{
|
||||
errors.Add(new VerificationPolicyValidationError(
|
||||
"ERR_VP_014",
|
||||
$"signer_requirements.trusted_key_fingerprints[{i}]",
|
||||
"Key fingerprint must be a 40-128 character hex string."));
|
||||
}
|
||||
|
||||
if (!seenFingerprints.Add(fingerprint))
|
||||
{
|
||||
errors.Add(new VerificationPolicyValidationError(
|
||||
"WARN_VP_004",
|
||||
$"signer_requirements.trusted_key_fingerprints[{i}]",
|
||||
$"Duplicate key fingerprint.",
|
||||
ValidationSeverity.Warning));
|
||||
}
|
||||
}
|
||||
|
||||
if (requirements.TrustedIssuers != null)
|
||||
{
|
||||
if (requirements.TrustedIssuers.Count > _constraints.MaxTrustedIssuers)
|
||||
{
|
||||
errors.Add(new VerificationPolicyValidationError(
|
||||
"ERR_VP_015",
|
||||
"signer_requirements.trusted_issuers",
|
||||
$"Trusted issuers exceeds maximum count of {_constraints.MaxTrustedIssuers}."));
|
||||
}
|
||||
|
||||
for (var i = 0; i < requirements.TrustedIssuers.Count; i++)
|
||||
{
|
||||
var issuer = requirements.TrustedIssuers[i];
|
||||
if (string.IsNullOrWhiteSpace(issuer))
|
||||
{
|
||||
errors.Add(new VerificationPolicyValidationError(
|
||||
"ERR_VP_016",
|
||||
$"signer_requirements.trusted_issuers[{i}]",
|
||||
"Issuer cannot be empty."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (requirements.Algorithms != null)
|
||||
{
|
||||
if (requirements.Algorithms.Count > _constraints.MaxAlgorithms)
|
||||
{
|
||||
errors.Add(new VerificationPolicyValidationError(
|
||||
"ERR_VP_017",
|
||||
"signer_requirements.algorithms",
|
||||
$"Algorithms exceeds maximum count of {_constraints.MaxAlgorithms}."));
|
||||
}
|
||||
|
||||
for (var i = 0; i < requirements.Algorithms.Count; i++)
|
||||
{
|
||||
var algorithm = requirements.Algorithms[i];
|
||||
if (string.IsNullOrWhiteSpace(algorithm))
|
||||
{
|
||||
errors.Add(new VerificationPolicyValidationError(
|
||||
"ERR_VP_018",
|
||||
$"signer_requirements.algorithms[{i}]",
|
||||
"Algorithm cannot be empty."));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!AllowedAlgorithms.Contains(algorithm))
|
||||
{
|
||||
errors.Add(new VerificationPolicyValidationError(
|
||||
"ERR_VP_019",
|
||||
$"signer_requirements.algorithms[{i}]",
|
||||
$"Algorithm '{algorithm}' is not supported. Allowed: {string.Join(", ", AllowedAlgorithms)}."));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateValidityWindow(ValidityWindow? window, List<VerificationPolicyValidationError> errors)
|
||||
{
|
||||
if (window == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.NotBefore.HasValue && window.NotAfter.HasValue)
|
||||
{
|
||||
if (window.NotBefore.Value >= window.NotAfter.Value)
|
||||
{
|
||||
errors.Add(new VerificationPolicyValidationError(
|
||||
"ERR_VP_020",
|
||||
"validity_window",
|
||||
"not_before must be earlier than not_after."));
|
||||
}
|
||||
}
|
||||
|
||||
if (window.MaxAttestationAge.HasValue)
|
||||
{
|
||||
if (window.MaxAttestationAge.Value <= 0)
|
||||
{
|
||||
errors.Add(new VerificationPolicyValidationError(
|
||||
"ERR_VP_021",
|
||||
"validity_window.max_attestation_age",
|
||||
"Maximum attestation age must be a positive integer (seconds)."));
|
||||
}
|
||||
else if (window.MaxAttestationAge.Value > _constraints.MaxAttestationAgeSeconds)
|
||||
{
|
||||
errors.Add(new VerificationPolicyValidationError(
|
||||
"ERR_VP_022",
|
||||
"validity_window.max_attestation_age",
|
||||
$"Maximum attestation age exceeds limit of {_constraints.MaxAttestationAgeSeconds} seconds."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateMetadata(IReadOnlyDictionary<string, object?>? metadata, List<VerificationPolicyValidationError> errors)
|
||||
{
|
||||
if (metadata == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (metadata.Count > _constraints.MaxMetadataEntries)
|
||||
{
|
||||
errors.Add(new VerificationPolicyValidationError(
|
||||
"ERR_VP_023",
|
||||
"metadata",
|
||||
$"Metadata exceeds maximum of {_constraints.MaxMetadataEntries} entries."));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsKnownPredicateType(string predicateType)
|
||||
{
|
||||
return predicateType == PredicateTypes.SbomV1
|
||||
|| predicateType == PredicateTypes.VexV1
|
||||
|| predicateType == PredicateTypes.VexDecisionV1
|
||||
|| predicateType == PredicateTypes.PolicyV1
|
||||
|| predicateType == PredicateTypes.PromotionV1
|
||||
|| predicateType == PredicateTypes.EvidenceV1
|
||||
|| predicateType == PredicateTypes.GraphV1
|
||||
|| predicateType == PredicateTypes.ReplayV1
|
||||
|| predicateType == PredicateTypes.SlsaProvenanceV02
|
||||
|| predicateType == PredicateTypes.SlsaProvenanceV1
|
||||
|| predicateType == PredicateTypes.CycloneDxBom
|
||||
|| predicateType == PredicateTypes.SpdxDocument
|
||||
|| predicateType == PredicateTypes.OpenVex;
|
||||
}
|
||||
|
||||
private static bool IsValidPredicateTypeUri(string predicateType)
|
||||
{
|
||||
// Predicate types are typically URIs or namespaced identifiers
|
||||
return predicateType.Contains('/') || predicateType.Contains(':');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Policy.Engine.Attestation;
|
||||
|
||||
namespace StellaOps.Policy.Engine.ConsoleSurface;
|
||||
|
||||
/// <summary>
|
||||
/// Console request for attestation report query per CONTRACT-VERIFICATION-POLICY-006.
|
||||
/// </summary>
|
||||
internal sealed record ConsoleAttestationReportRequest(
|
||||
[property: JsonPropertyName("artifact_digests")] IReadOnlyList<string>? ArtifactDigests,
|
||||
[property: JsonPropertyName("artifact_uri_pattern")] string? ArtifactUriPattern,
|
||||
[property: JsonPropertyName("policy_ids")] IReadOnlyList<string>? PolicyIds,
|
||||
[property: JsonPropertyName("predicate_types")] IReadOnlyList<string>? PredicateTypes,
|
||||
[property: JsonPropertyName("status_filter")] IReadOnlyList<string>? StatusFilter,
|
||||
[property: JsonPropertyName("from_time")] DateTimeOffset? FromTime,
|
||||
[property: JsonPropertyName("to_time")] DateTimeOffset? ToTime,
|
||||
[property: JsonPropertyName("group_by")] ConsoleReportGroupBy? GroupBy,
|
||||
[property: JsonPropertyName("sort_by")] ConsoleReportSortBy? SortBy,
|
||||
[property: JsonPropertyName("page")] int Page = 1,
|
||||
[property: JsonPropertyName("page_size")] int PageSize = 25);
|
||||
|
||||
/// <summary>
|
||||
/// Grouping options for Console attestation reports.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<ConsoleReportGroupBy>))]
|
||||
internal enum ConsoleReportGroupBy
|
||||
{
|
||||
None,
|
||||
Policy,
|
||||
PredicateType,
|
||||
Status,
|
||||
ArtifactUri
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sorting options for Console attestation reports.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<ConsoleReportSortBy>))]
|
||||
internal enum ConsoleReportSortBy
|
||||
{
|
||||
EvaluatedAtDesc,
|
||||
EvaluatedAtAsc,
|
||||
StatusAsc,
|
||||
StatusDesc,
|
||||
CoverageDesc,
|
||||
CoverageAsc
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Console response for attestation reports.
|
||||
/// </summary>
|
||||
internal sealed record ConsoleAttestationReportResponse(
|
||||
[property: JsonPropertyName("schema_version")] string SchemaVersion,
|
||||
[property: JsonPropertyName("summary")] ConsoleReportSummary Summary,
|
||||
[property: JsonPropertyName("reports")] IReadOnlyList<ConsoleArtifactReport> Reports,
|
||||
[property: JsonPropertyName("groups")] IReadOnlyList<ConsoleReportGroup>? Groups,
|
||||
[property: JsonPropertyName("pagination")] ConsolePagination Pagination,
|
||||
[property: JsonPropertyName("filters_applied")] ConsoleFiltersApplied FiltersApplied);
|
||||
|
||||
/// <summary>
|
||||
/// Summary of attestation reports for Console.
|
||||
/// </summary>
|
||||
internal sealed record ConsoleReportSummary(
|
||||
[property: JsonPropertyName("total_artifacts")] int TotalArtifacts,
|
||||
[property: JsonPropertyName("total_attestations")] int TotalAttestations,
|
||||
[property: JsonPropertyName("status_breakdown")] ImmutableDictionary<string, int> StatusBreakdown,
|
||||
[property: JsonPropertyName("coverage_rate")] double CoverageRate,
|
||||
[property: JsonPropertyName("compliance_rate")] double ComplianceRate,
|
||||
[property: JsonPropertyName("average_age_hours")] double AverageAgeHours);
|
||||
|
||||
/// <summary>
|
||||
/// Console-friendly artifact attestation report.
|
||||
/// </summary>
|
||||
internal sealed record ConsoleArtifactReport(
|
||||
[property: JsonPropertyName("artifact_digest")] string ArtifactDigest,
|
||||
[property: JsonPropertyName("artifact_uri")] string? ArtifactUri,
|
||||
[property: JsonPropertyName("artifact_short_digest")] string ArtifactShortDigest,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("status_label")] string StatusLabel,
|
||||
[property: JsonPropertyName("status_icon")] string StatusIcon,
|
||||
[property: JsonPropertyName("attestation_count")] int AttestationCount,
|
||||
[property: JsonPropertyName("coverage_percentage")] double CoveragePercentage,
|
||||
[property: JsonPropertyName("policies_passed")] int PoliciesPassed,
|
||||
[property: JsonPropertyName("policies_failed")] int PoliciesFailed,
|
||||
[property: JsonPropertyName("evaluated_at")] DateTimeOffset EvaluatedAt,
|
||||
[property: JsonPropertyName("evaluated_at_relative")] string EvaluatedAtRelative,
|
||||
[property: JsonPropertyName("details")] ConsoleReportDetails? Details);
|
||||
|
||||
/// <summary>
|
||||
/// Detailed report information for Console.
|
||||
/// </summary>
|
||||
internal sealed record ConsoleReportDetails(
|
||||
[property: JsonPropertyName("predicate_types")] IReadOnlyList<ConsolePredicateTypeStatus> PredicateTypes,
|
||||
[property: JsonPropertyName("policies")] IReadOnlyList<ConsolePolicyStatus> Policies,
|
||||
[property: JsonPropertyName("signers")] IReadOnlyList<ConsoleSignerInfo> Signers,
|
||||
[property: JsonPropertyName("issues")] IReadOnlyList<ConsoleIssue> Issues);
|
||||
|
||||
/// <summary>
|
||||
/// Predicate type status for Console.
|
||||
/// </summary>
|
||||
internal sealed record ConsolePredicateTypeStatus(
|
||||
[property: JsonPropertyName("type")] string Type,
|
||||
[property: JsonPropertyName("type_label")] string TypeLabel,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("status_label")] string StatusLabel,
|
||||
[property: JsonPropertyName("freshness")] string Freshness);
|
||||
|
||||
/// <summary>
|
||||
/// Policy status for Console.
|
||||
/// </summary>
|
||||
internal sealed record ConsolePolicyStatus(
|
||||
[property: JsonPropertyName("policy_id")] string PolicyId,
|
||||
[property: JsonPropertyName("policy_version")] string PolicyVersion,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("status_label")] string StatusLabel,
|
||||
[property: JsonPropertyName("verdict")] string Verdict);
|
||||
|
||||
/// <summary>
|
||||
/// Signer information for Console.
|
||||
/// </summary>
|
||||
internal sealed record ConsoleSignerInfo(
|
||||
[property: JsonPropertyName("key_fingerprint_short")] string KeyFingerprintShort,
|
||||
[property: JsonPropertyName("issuer")] string? Issuer,
|
||||
[property: JsonPropertyName("subject")] string? Subject,
|
||||
[property: JsonPropertyName("algorithm")] string Algorithm,
|
||||
[property: JsonPropertyName("verified")] bool Verified,
|
||||
[property: JsonPropertyName("trusted")] bool Trusted);
|
||||
|
||||
/// <summary>
|
||||
/// Issue for Console display.
|
||||
/// </summary>
|
||||
internal sealed record ConsoleIssue(
|
||||
[property: JsonPropertyName("severity")] string Severity,
|
||||
[property: JsonPropertyName("message")] string Message,
|
||||
[property: JsonPropertyName("field")] string? Field);
|
||||
|
||||
/// <summary>
|
||||
/// Report group for Console.
|
||||
/// </summary>
|
||||
internal sealed record ConsoleReportGroup(
|
||||
[property: JsonPropertyName("key")] string Key,
|
||||
[property: JsonPropertyName("label")] string Label,
|
||||
[property: JsonPropertyName("count")] int Count,
|
||||
[property: JsonPropertyName("status_breakdown")] ImmutableDictionary<string, int> StatusBreakdown);
|
||||
|
||||
/// <summary>
|
||||
/// Pagination information for Console.
|
||||
/// </summary>
|
||||
internal sealed record ConsolePagination(
|
||||
[property: JsonPropertyName("page")] int Page,
|
||||
[property: JsonPropertyName("page_size")] int PageSize,
|
||||
[property: JsonPropertyName("total_pages")] int TotalPages,
|
||||
[property: JsonPropertyName("total_items")] int TotalItems,
|
||||
[property: JsonPropertyName("has_next")] bool HasNext,
|
||||
[property: JsonPropertyName("has_previous")] bool HasPrevious);
|
||||
|
||||
/// <summary>
|
||||
/// Applied filters information for Console.
|
||||
/// </summary>
|
||||
internal sealed record ConsoleFiltersApplied(
|
||||
[property: JsonPropertyName("artifact_count")] int ArtifactCount,
|
||||
[property: JsonPropertyName("policy_ids")] IReadOnlyList<string>? PolicyIds,
|
||||
[property: JsonPropertyName("predicate_types")] IReadOnlyList<string>? PredicateTypes,
|
||||
[property: JsonPropertyName("status_filter")] IReadOnlyList<string>? StatusFilter,
|
||||
[property: JsonPropertyName("time_range")] ConsoleTimeRange? TimeRange);
|
||||
|
||||
/// <summary>
|
||||
/// Time range for Console filters.
|
||||
/// </summary>
|
||||
internal sealed record ConsoleTimeRange(
|
||||
[property: JsonPropertyName("from")] DateTimeOffset? From,
|
||||
[property: JsonPropertyName("to")] DateTimeOffset? To);
|
||||
|
||||
/// <summary>
|
||||
/// Console request for attestation statistics dashboard.
|
||||
/// </summary>
|
||||
internal sealed record ConsoleAttestationDashboardRequest(
|
||||
[property: JsonPropertyName("time_range")] string? TimeRange,
|
||||
[property: JsonPropertyName("policy_ids")] IReadOnlyList<string>? PolicyIds,
|
||||
[property: JsonPropertyName("artifact_uri_pattern")] string? ArtifactUriPattern);
|
||||
|
||||
/// <summary>
|
||||
/// Console response for attestation statistics dashboard.
|
||||
/// </summary>
|
||||
internal sealed record ConsoleAttestationDashboardResponse(
|
||||
[property: JsonPropertyName("schema_version")] string SchemaVersion,
|
||||
[property: JsonPropertyName("overview")] ConsoleDashboardOverview Overview,
|
||||
[property: JsonPropertyName("trends")] ConsoleDashboardTrends Trends,
|
||||
[property: JsonPropertyName("top_issues")] IReadOnlyList<ConsoleDashboardIssue> TopIssues,
|
||||
[property: JsonPropertyName("policy_compliance")] IReadOnlyList<ConsoleDashboardPolicyCompliance> PolicyCompliance,
|
||||
[property: JsonPropertyName("evaluated_at")] DateTimeOffset EvaluatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Dashboard overview for Console.
|
||||
/// </summary>
|
||||
internal sealed record ConsoleDashboardOverview(
|
||||
[property: JsonPropertyName("total_artifacts")] int TotalArtifacts,
|
||||
[property: JsonPropertyName("total_attestations")] int TotalAttestations,
|
||||
[property: JsonPropertyName("pass_rate")] double PassRate,
|
||||
[property: JsonPropertyName("coverage_rate")] double CoverageRate,
|
||||
[property: JsonPropertyName("average_freshness_hours")] double AverageFreshnessHours);
|
||||
|
||||
/// <summary>
|
||||
/// Dashboard trends for Console.
|
||||
/// </summary>
|
||||
internal sealed record ConsoleDashboardTrends(
|
||||
[property: JsonPropertyName("pass_rate_change")] double PassRateChange,
|
||||
[property: JsonPropertyName("coverage_rate_change")] double CoverageRateChange,
|
||||
[property: JsonPropertyName("attestation_count_change")] int AttestationCountChange,
|
||||
[property: JsonPropertyName("trend_direction")] string TrendDirection);
|
||||
|
||||
/// <summary>
|
||||
/// Dashboard issue for Console.
|
||||
/// </summary>
|
||||
internal sealed record ConsoleDashboardIssue(
|
||||
[property: JsonPropertyName("issue")] string Issue,
|
||||
[property: JsonPropertyName("count")] int Count,
|
||||
[property: JsonPropertyName("severity")] string Severity);
|
||||
|
||||
/// <summary>
|
||||
/// Dashboard policy compliance for Console.
|
||||
/// </summary>
|
||||
internal sealed record ConsoleDashboardPolicyCompliance(
|
||||
[property: JsonPropertyName("policy_id")] string PolicyId,
|
||||
[property: JsonPropertyName("policy_version")] string PolicyVersion,
|
||||
[property: JsonPropertyName("compliance_rate")] double ComplianceRate,
|
||||
[property: JsonPropertyName("artifacts_evaluated")] int ArtifactsEvaluated);
|
||||
@@ -0,0 +1,470 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Policy.Engine.Attestation;
|
||||
|
||||
namespace StellaOps.Policy.Engine.ConsoleSurface;
|
||||
|
||||
/// <summary>
|
||||
/// Service for Console attestation report integration per CONTRACT-VERIFICATION-POLICY-006.
|
||||
/// </summary>
|
||||
internal sealed class ConsoleAttestationReportService
|
||||
{
|
||||
private const string SchemaVersion = "1.0.0";
|
||||
|
||||
private readonly IAttestationReportService _reportService;
|
||||
private readonly IVerificationPolicyStore _policyStore;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ConsoleAttestationReportService(
|
||||
IAttestationReportService reportService,
|
||||
IVerificationPolicyStore policyStore,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_reportService = reportService ?? throw new ArgumentNullException(nameof(reportService));
|
||||
_policyStore = policyStore ?? throw new ArgumentNullException(nameof(policyStore));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public async Task<ConsoleAttestationReportResponse> QueryReportsAsync(
|
||||
ConsoleAttestationReportRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Convert Console request to internal query
|
||||
var query = new AttestationReportQuery(
|
||||
ArtifactDigests: request.ArtifactDigests,
|
||||
ArtifactUriPattern: request.ArtifactUriPattern,
|
||||
PolicyIds: request.PolicyIds,
|
||||
PredicateTypes: request.PredicateTypes,
|
||||
StatusFilter: ParseStatusFilter(request.StatusFilter),
|
||||
FromTime: request.FromTime,
|
||||
ToTime: request.ToTime,
|
||||
IncludeDetails: true,
|
||||
Limit: request.PageSize,
|
||||
Offset: (request.Page - 1) * request.PageSize);
|
||||
|
||||
// Get reports
|
||||
var response = await _reportService.ListReportsAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Get statistics for summary
|
||||
var statistics = await _reportService.GetStatisticsAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Convert to Console format
|
||||
var consoleReports = response.Reports.Select(r => ToConsoleReport(r, now)).ToList();
|
||||
|
||||
// Calculate groups if requested
|
||||
IReadOnlyList<ConsoleReportGroup>? groups = null;
|
||||
if (request.GroupBy.HasValue && request.GroupBy.Value != ConsoleReportGroupBy.None)
|
||||
{
|
||||
groups = CalculateGroups(response.Reports, request.GroupBy.Value);
|
||||
}
|
||||
|
||||
// Calculate pagination
|
||||
var totalPages = (int)Math.Ceiling((double)response.Total / request.PageSize);
|
||||
var pagination = new ConsolePagination(
|
||||
Page: request.Page,
|
||||
PageSize: request.PageSize,
|
||||
TotalPages: totalPages,
|
||||
TotalItems: response.Total,
|
||||
HasNext: request.Page < totalPages,
|
||||
HasPrevious: request.Page > 1);
|
||||
|
||||
// Create summary
|
||||
var summary = new ConsoleReportSummary(
|
||||
TotalArtifacts: statistics.TotalArtifacts,
|
||||
TotalAttestations: statistics.TotalAttestations,
|
||||
StatusBreakdown: statistics.StatusDistribution
|
||||
.ToImmutableDictionary(kvp => kvp.Key.ToString(), kvp => kvp.Value),
|
||||
CoverageRate: Math.Round(statistics.CoverageRate, 2),
|
||||
ComplianceRate: CalculateComplianceRate(response.Reports),
|
||||
AverageAgeHours: Math.Round(statistics.AverageAgeSeconds / 3600, 2));
|
||||
|
||||
return new ConsoleAttestationReportResponse(
|
||||
SchemaVersion: SchemaVersion,
|
||||
Summary: summary,
|
||||
Reports: consoleReports,
|
||||
Groups: groups,
|
||||
Pagination: pagination,
|
||||
FiltersApplied: new ConsoleFiltersApplied(
|
||||
ArtifactCount: request.ArtifactDigests?.Count ?? 0,
|
||||
PolicyIds: request.PolicyIds,
|
||||
PredicateTypes: request.PredicateTypes,
|
||||
StatusFilter: request.StatusFilter,
|
||||
TimeRange: request.FromTime.HasValue || request.ToTime.HasValue
|
||||
? new ConsoleTimeRange(request.FromTime, request.ToTime)
|
||||
: null));
|
||||
}
|
||||
|
||||
public async Task<ConsoleAttestationDashboardResponse> GetDashboardAsync(
|
||||
ConsoleAttestationDashboardRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var (fromTime, toTime) = ParseTimeRange(request.TimeRange, now);
|
||||
|
||||
var query = new AttestationReportQuery(
|
||||
ArtifactDigests: null,
|
||||
ArtifactUriPattern: request.ArtifactUriPattern,
|
||||
PolicyIds: request.PolicyIds,
|
||||
PredicateTypes: null,
|
||||
StatusFilter: null,
|
||||
FromTime: fromTime,
|
||||
ToTime: toTime,
|
||||
IncludeDetails: false,
|
||||
Limit: int.MaxValue,
|
||||
Offset: 0);
|
||||
|
||||
var statistics = await _reportService.GetStatisticsAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
var reports = await _reportService.ListReportsAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Calculate pass rate
|
||||
var passCount = statistics.StatusDistribution.GetValueOrDefault(AttestationReportStatus.Pass, 0);
|
||||
var failCount = statistics.StatusDistribution.GetValueOrDefault(AttestationReportStatus.Fail, 0);
|
||||
var warnCount = statistics.StatusDistribution.GetValueOrDefault(AttestationReportStatus.Warn, 0);
|
||||
var total = passCount + failCount + warnCount;
|
||||
var passRate = total > 0 ? (double)passCount / total * 100 : 0;
|
||||
|
||||
// Calculate overview
|
||||
var overview = new ConsoleDashboardOverview(
|
||||
TotalArtifacts: statistics.TotalArtifacts,
|
||||
TotalAttestations: statistics.TotalAttestations,
|
||||
PassRate: Math.Round(passRate, 2),
|
||||
CoverageRate: Math.Round(statistics.CoverageRate, 2),
|
||||
AverageFreshnessHours: Math.Round(statistics.AverageAgeSeconds / 3600, 2));
|
||||
|
||||
// Calculate trends (simplified - would normally compare to previous period)
|
||||
var trends = new ConsoleDashboardTrends(
|
||||
PassRateChange: 0,
|
||||
CoverageRateChange: 0,
|
||||
AttestationCountChange: 0,
|
||||
TrendDirection: "stable");
|
||||
|
||||
// Get top issues
|
||||
var topIssues = reports.Reports
|
||||
.SelectMany(r => r.VerificationResults)
|
||||
.SelectMany(v => v.Issues)
|
||||
.GroupBy(i => i)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.Take(5)
|
||||
.Select(g => new ConsoleDashboardIssue(
|
||||
Issue: g.Key,
|
||||
Count: g.Count(),
|
||||
Severity: "error"))
|
||||
.ToList();
|
||||
|
||||
// Get policy compliance
|
||||
var policyCompliance = await CalculatePolicyComplianceAsync(reports.Reports, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new ConsoleAttestationDashboardResponse(
|
||||
SchemaVersion: SchemaVersion,
|
||||
Overview: overview,
|
||||
Trends: trends,
|
||||
TopIssues: topIssues,
|
||||
PolicyCompliance: policyCompliance,
|
||||
EvaluatedAt: now);
|
||||
}
|
||||
|
||||
private ConsoleArtifactReport ToConsoleReport(ArtifactAttestationReport report, DateTimeOffset now)
|
||||
{
|
||||
var age = now - report.EvaluatedAt;
|
||||
var ageRelative = FormatRelativeTime(age);
|
||||
|
||||
return new ConsoleArtifactReport(
|
||||
ArtifactDigest: report.ArtifactDigest,
|
||||
ArtifactUri: report.ArtifactUri,
|
||||
ArtifactShortDigest: report.ArtifactDigest.Length > 12
|
||||
? report.ArtifactDigest[..12]
|
||||
: report.ArtifactDigest,
|
||||
Status: report.OverallStatus.ToString().ToLowerInvariant(),
|
||||
StatusLabel: GetStatusLabel(report.OverallStatus),
|
||||
StatusIcon: GetStatusIcon(report.OverallStatus),
|
||||
AttestationCount: report.AttestationCount,
|
||||
CoveragePercentage: report.Coverage.CoveragePercentage,
|
||||
PoliciesPassed: report.PolicyCompliance.PoliciesPassed,
|
||||
PoliciesFailed: report.PolicyCompliance.PoliciesFailed,
|
||||
EvaluatedAt: report.EvaluatedAt,
|
||||
EvaluatedAtRelative: ageRelative,
|
||||
Details: ToConsoleDetails(report));
|
||||
}
|
||||
|
||||
private static ConsoleReportDetails ToConsoleDetails(ArtifactAttestationReport report)
|
||||
{
|
||||
var predicateTypes = report.VerificationResults
|
||||
.GroupBy(v => v.PredicateType)
|
||||
.Select(g => new ConsolePredicateTypeStatus(
|
||||
Type: g.Key,
|
||||
TypeLabel: GetPredicateTypeLabel(g.Key),
|
||||
Status: g.First().Status.ToString().ToLowerInvariant(),
|
||||
StatusLabel: GetStatusLabel(g.First().Status),
|
||||
Freshness: FormatFreshness(g.First().FreshnessStatus)))
|
||||
.ToList();
|
||||
|
||||
var policies = report.PolicyCompliance.PolicyResults
|
||||
.Select(p => new ConsolePolicyStatus(
|
||||
PolicyId: p.PolicyId,
|
||||
PolicyVersion: p.PolicyVersion,
|
||||
Status: p.Status.ToString().ToLowerInvariant(),
|
||||
StatusLabel: GetStatusLabel(p.Status),
|
||||
Verdict: p.Verdict))
|
||||
.ToList();
|
||||
|
||||
var signers = report.VerificationResults
|
||||
.SelectMany(v => v.SignatureStatus.Signers)
|
||||
.DistinctBy(s => s.KeyFingerprint)
|
||||
.Select(s => new ConsoleSignerInfo(
|
||||
KeyFingerprintShort: s.KeyFingerprint.Length > 8
|
||||
? s.KeyFingerprint[..8]
|
||||
: s.KeyFingerprint,
|
||||
Issuer: s.Issuer,
|
||||
Subject: s.Subject,
|
||||
Algorithm: s.Algorithm,
|
||||
Verified: s.Verified,
|
||||
Trusted: s.Trusted))
|
||||
.ToList();
|
||||
|
||||
var issues = report.VerificationResults
|
||||
.SelectMany(v => v.Issues)
|
||||
.Distinct()
|
||||
.Select(i => new ConsoleIssue(
|
||||
Severity: "error",
|
||||
Message: i,
|
||||
Field: null))
|
||||
.ToList();
|
||||
|
||||
return new ConsoleReportDetails(
|
||||
PredicateTypes: predicateTypes,
|
||||
Policies: policies,
|
||||
Signers: signers,
|
||||
Issues: issues);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ConsoleReportGroup> CalculateGroups(
|
||||
IReadOnlyList<ArtifactAttestationReport> reports,
|
||||
ConsoleReportGroupBy groupBy)
|
||||
{
|
||||
return groupBy switch
|
||||
{
|
||||
ConsoleReportGroupBy.Policy => GroupByPolicy(reports),
|
||||
ConsoleReportGroupBy.PredicateType => GroupByPredicateType(reports),
|
||||
ConsoleReportGroupBy.Status => GroupByStatus(reports),
|
||||
ConsoleReportGroupBy.ArtifactUri => GroupByArtifactUri(reports),
|
||||
_ => []
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ConsoleReportGroup> GroupByPolicy(IReadOnlyList<ArtifactAttestationReport> reports)
|
||||
{
|
||||
return reports
|
||||
.SelectMany(r => r.PolicyCompliance.PolicyResults)
|
||||
.GroupBy(p => p.PolicyId)
|
||||
.Select(g => new ConsoleReportGroup(
|
||||
Key: g.Key,
|
||||
Label: g.Key,
|
||||
Count: g.Count(),
|
||||
StatusBreakdown: g.GroupBy(p => p.Status.ToString())
|
||||
.ToImmutableDictionary(s => s.Key, s => s.Count())))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ConsoleReportGroup> GroupByPredicateType(IReadOnlyList<ArtifactAttestationReport> reports)
|
||||
{
|
||||
return reports
|
||||
.SelectMany(r => r.VerificationResults)
|
||||
.GroupBy(v => v.PredicateType)
|
||||
.Select(g => new ConsoleReportGroup(
|
||||
Key: g.Key,
|
||||
Label: GetPredicateTypeLabel(g.Key),
|
||||
Count: g.Count(),
|
||||
StatusBreakdown: g.GroupBy(v => v.Status.ToString())
|
||||
.ToImmutableDictionary(s => s.Key, s => s.Count())))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ConsoleReportGroup> GroupByStatus(IReadOnlyList<ArtifactAttestationReport> reports)
|
||||
{
|
||||
return reports
|
||||
.GroupBy(r => r.OverallStatus)
|
||||
.Select(g => new ConsoleReportGroup(
|
||||
Key: g.Key.ToString(),
|
||||
Label: GetStatusLabel(g.Key),
|
||||
Count: g.Count(),
|
||||
StatusBreakdown: ImmutableDictionary<string, int>.Empty.Add(g.Key.ToString(), g.Count())))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ConsoleReportGroup> GroupByArtifactUri(IReadOnlyList<ArtifactAttestationReport> reports)
|
||||
{
|
||||
return reports
|
||||
.Where(r => !string.IsNullOrWhiteSpace(r.ArtifactUri))
|
||||
.GroupBy(r => ExtractRepository(r.ArtifactUri!))
|
||||
.Select(g => new ConsoleReportGroup(
|
||||
Key: g.Key,
|
||||
Label: g.Key,
|
||||
Count: g.Count(),
|
||||
StatusBreakdown: g.GroupBy(r => r.OverallStatus.ToString())
|
||||
.ToImmutableDictionary(s => s.Key, s => s.Count())))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ConsoleDashboardPolicyCompliance>> CalculatePolicyComplianceAsync(
|
||||
IReadOnlyList<ArtifactAttestationReport> reports,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var policyResults = reports
|
||||
.SelectMany(r => r.PolicyCompliance.PolicyResults)
|
||||
.GroupBy(p => p.PolicyId)
|
||||
.Select(g =>
|
||||
{
|
||||
var total = g.Count();
|
||||
var passed = g.Count(p => p.Status == AttestationReportStatus.Pass);
|
||||
var complianceRate = total > 0 ? (double)passed / total * 100 : 0;
|
||||
|
||||
return new ConsoleDashboardPolicyCompliance(
|
||||
PolicyId: g.Key,
|
||||
PolicyVersion: g.First().PolicyVersion,
|
||||
ComplianceRate: Math.Round(complianceRate, 2),
|
||||
ArtifactsEvaluated: total);
|
||||
})
|
||||
.OrderByDescending(p => p.ArtifactsEvaluated)
|
||||
.Take(10)
|
||||
.ToList();
|
||||
|
||||
return policyResults;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AttestationReportStatus>? ParseStatusFilter(IReadOnlyList<string>? statusFilter)
|
||||
{
|
||||
if (statusFilter == null || statusFilter.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return statusFilter
|
||||
.Select(s => Enum.TryParse<AttestationReportStatus>(s, true, out var status) ? status : (AttestationReportStatus?)null)
|
||||
.Where(s => s.HasValue)
|
||||
.Select(s => s!.Value)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static (DateTimeOffset? from, DateTimeOffset? to) ParseTimeRange(string? timeRange, DateTimeOffset now)
|
||||
{
|
||||
return timeRange?.ToLowerInvariant() switch
|
||||
{
|
||||
"1h" => (now.AddHours(-1), now),
|
||||
"24h" => (now.AddDays(-1), now),
|
||||
"7d" => (now.AddDays(-7), now),
|
||||
"30d" => (now.AddDays(-30), now),
|
||||
"90d" => (now.AddDays(-90), now),
|
||||
_ => (null, null)
|
||||
};
|
||||
}
|
||||
|
||||
private static double CalculateComplianceRate(IReadOnlyList<ArtifactAttestationReport> reports)
|
||||
{
|
||||
if (reports.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var compliant = reports.Count(r =>
|
||||
r.OverallStatus == AttestationReportStatus.Pass ||
|
||||
r.OverallStatus == AttestationReportStatus.Warn);
|
||||
|
||||
return Math.Round((double)compliant / reports.Count * 100, 2);
|
||||
}
|
||||
|
||||
private static string GetStatusLabel(AttestationReportStatus status)
|
||||
{
|
||||
return status switch
|
||||
{
|
||||
AttestationReportStatus.Pass => "Passed",
|
||||
AttestationReportStatus.Fail => "Failed",
|
||||
AttestationReportStatus.Warn => "Warning",
|
||||
AttestationReportStatus.Skipped => "Skipped",
|
||||
AttestationReportStatus.Pending => "Pending",
|
||||
_ => "Unknown"
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetStatusIcon(AttestationReportStatus status)
|
||||
{
|
||||
return status switch
|
||||
{
|
||||
AttestationReportStatus.Pass => "check-circle",
|
||||
AttestationReportStatus.Fail => "x-circle",
|
||||
AttestationReportStatus.Warn => "alert-triangle",
|
||||
AttestationReportStatus.Skipped => "minus-circle",
|
||||
AttestationReportStatus.Pending => "clock",
|
||||
_ => "help-circle"
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetPredicateTypeLabel(string predicateType)
|
||||
{
|
||||
return predicateType switch
|
||||
{
|
||||
PredicateTypes.SbomV1 => "SBOM",
|
||||
PredicateTypes.VexV1 => "VEX",
|
||||
PredicateTypes.VexDecisionV1 => "VEX Decision",
|
||||
PredicateTypes.PolicyV1 => "Policy",
|
||||
PredicateTypes.PromotionV1 => "Promotion",
|
||||
PredicateTypes.EvidenceV1 => "Evidence",
|
||||
PredicateTypes.GraphV1 => "Graph",
|
||||
PredicateTypes.ReplayV1 => "Replay",
|
||||
PredicateTypes.SlsaProvenanceV1 => "SLSA v1",
|
||||
PredicateTypes.SlsaProvenanceV02 => "SLSA v0.2",
|
||||
PredicateTypes.CycloneDxBom => "CycloneDX",
|
||||
PredicateTypes.SpdxDocument => "SPDX",
|
||||
PredicateTypes.OpenVex => "OpenVEX",
|
||||
_ => predicateType
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatFreshness(FreshnessVerificationStatus freshness)
|
||||
{
|
||||
return freshness.IsFresh ? "Fresh" : $"{freshness.AgeSeconds / 3600}h old";
|
||||
}
|
||||
|
||||
private static string FormatRelativeTime(TimeSpan age)
|
||||
{
|
||||
if (age.TotalMinutes < 1)
|
||||
{
|
||||
return "just now";
|
||||
}
|
||||
|
||||
if (age.TotalHours < 1)
|
||||
{
|
||||
return $"{(int)age.TotalMinutes}m ago";
|
||||
}
|
||||
|
||||
if (age.TotalDays < 1)
|
||||
{
|
||||
return $"{(int)age.TotalHours}h ago";
|
||||
}
|
||||
|
||||
if (age.TotalDays < 7)
|
||||
{
|
||||
return $"{(int)age.TotalDays}d ago";
|
||||
}
|
||||
|
||||
return $"{(int)(age.TotalDays / 7)}w ago";
|
||||
}
|
||||
|
||||
private static string ExtractRepository(string artifactUri)
|
||||
{
|
||||
try
|
||||
{
|
||||
var uri = new Uri(artifactUri);
|
||||
var path = uri.AbsolutePath.Split('/');
|
||||
return path.Length >= 2 ? path[1] : uri.Host;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return artifactUri;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,13 @@ public sealed record PolicyDecisionLocator(
|
||||
/// <summary>
|
||||
/// Summary statistics for the decision response.
|
||||
/// </summary>
|
||||
/// <param name="TotalDecisions">Total number of policy decisions made.</param>
|
||||
/// <param name="TotalConflicts">Number of conflicting decisions.</param>
|
||||
/// <param name="SeverityCounts">Count of findings by severity level.</param>
|
||||
/// <param name="TopSeveritySources">
|
||||
/// DEPRECATED: Source ranking. Use trust weighting service instead.
|
||||
/// Scheduled for removal in v2.0. See DESIGN-POLICY-NORMALIZED-FIELD-REMOVAL-001.
|
||||
/// </param>
|
||||
public sealed record PolicyDecisionSummary(
|
||||
[property: JsonPropertyName("total_decisions")] int TotalDecisions,
|
||||
[property: JsonPropertyName("total_conflicts")] int TotalConflicts,
|
||||
@@ -72,7 +79,9 @@ public sealed record PolicyDecisionSummary(
|
||||
[property: JsonPropertyName("top_severity_sources")] IReadOnlyList<PolicyDecisionSourceRank> TopSeveritySources);
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated source rank across all decisions.
|
||||
/// DEPRECATED: Aggregated source rank across all decisions.
|
||||
/// Scheduled for removal in v2.0. See DESIGN-POLICY-NORMALIZED-FIELD-REMOVAL-001.
|
||||
/// Use trust weighting service instead.
|
||||
/// </summary>
|
||||
public sealed record PolicyDecisionSourceRank(
|
||||
[property: JsonPropertyName("source")] string Source,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.AirGap;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for air-gap notification testing and management.
|
||||
/// </summary>
|
||||
public static class AirGapNotificationEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapAirGapNotifications(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/system/airgap/notifications");
|
||||
|
||||
group.MapPost("/test", SendTestNotificationAsync)
|
||||
.WithName("AirGap.TestNotification")
|
||||
.WithDescription("Send a test notification")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", "airgap:seal"));
|
||||
|
||||
group.MapGet("/channels", GetChannelsAsync)
|
||||
.WithName("AirGap.GetNotificationChannels")
|
||||
.WithDescription("Get configured notification channels")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", "airgap:status:read"));
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> SendTestNotificationAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromBody] TestNotificationRequest? request,
|
||||
IAirGapNotificationService notificationService,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
tenantId = "default";
|
||||
}
|
||||
|
||||
var notification = new AirGapNotification(
|
||||
NotificationId: $"test-{Guid.NewGuid():N}"[..20],
|
||||
TenantId: tenantId,
|
||||
Type: request?.Type ?? AirGapNotificationType.StalenessWarning,
|
||||
Severity: request?.Severity ?? NotificationSeverity.Info,
|
||||
Title: request?.Title ?? "Test Notification",
|
||||
Message: request?.Message ?? "This is a test notification from the air-gap notification system.",
|
||||
OccurredAt: timeProvider.GetUtcNow(),
|
||||
Metadata: new Dictionary<string, object?>
|
||||
{
|
||||
["test"] = true
|
||||
});
|
||||
|
||||
await notificationService.SendAsync(notification, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
sent = true,
|
||||
notification_id = notification.NotificationId,
|
||||
type = notification.Type.ToString(),
|
||||
severity = notification.Severity.ToString()
|
||||
});
|
||||
}
|
||||
|
||||
private static Task<IResult> GetChannelsAsync(
|
||||
[FromServices] IEnumerable<IAirGapNotificationChannel> channels,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var channelList = channels.Select(c => new
|
||||
{
|
||||
name = c.ChannelName
|
||||
}).ToList();
|
||||
|
||||
return Task.FromResult(Results.Ok(new
|
||||
{
|
||||
channels = channelList,
|
||||
count = channelList.Count
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for sending a test notification.
|
||||
/// </summary>
|
||||
public sealed record TestNotificationRequest(
|
||||
AirGapNotificationType? Type = null,
|
||||
NotificationSeverity? Severity = null,
|
||||
string? Title = null,
|
||||
string? Message = null);
|
||||
@@ -0,0 +1,233 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Engine.Attestation;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for attestation reports per CONTRACT-VERIFICATION-POLICY-006.
|
||||
/// </summary>
|
||||
public static class AttestationReportEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapAttestationReports(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/api/v1/attestor/reports")
|
||||
.WithTags("Attestation Reports");
|
||||
|
||||
group.MapGet("/{artifactDigest}", GetReportAsync)
|
||||
.WithName("Attestor.GetReport")
|
||||
.WithSummary("Get attestation report for an artifact")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
|
||||
.Produces<ArtifactAttestationReport>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/query", ListReportsAsync)
|
||||
.WithName("Attestor.ListReports")
|
||||
.WithSummary("Query attestation reports")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
|
||||
.Produces<AttestationReportListResponse>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapPost("/verify", VerifyArtifactAsync)
|
||||
.WithName("Attestor.VerifyArtifact")
|
||||
.WithSummary("Generate attestation report for an artifact")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
|
||||
.Produces<ArtifactAttestationReport>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapGet("/statistics", GetStatisticsAsync)
|
||||
.WithName("Attestor.GetStatistics")
|
||||
.WithSummary("Get aggregated attestation statistics")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
|
||||
.Produces<AttestationStatistics>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapPost("/store", StoreReportAsync)
|
||||
.WithName("Attestor.StoreReport")
|
||||
.WithSummary("Store an attestation report")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyWrite))
|
||||
.Produces<StoredAttestationReport>(StatusCodes.Status201Created)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapDelete("/expired", PurgeExpiredAsync)
|
||||
.WithName("Attestor.PurgeExpired")
|
||||
.WithSummary("Purge expired attestation reports")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyWrite))
|
||||
.Produces<PurgeExpiredResponse>(StatusCodes.Status200OK);
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetReportAsync(
|
||||
[FromRoute] string artifactDigest,
|
||||
IAttestationReportService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(artifactDigest))
|
||||
{
|
||||
return Results.BadRequest(CreateProblem(
|
||||
"Invalid request",
|
||||
"Artifact digest is required.",
|
||||
"ERR_ATTEST_010"));
|
||||
}
|
||||
|
||||
var report = await service.GetReportAsync(artifactDigest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (report == null)
|
||||
{
|
||||
return Results.NotFound(CreateProblem(
|
||||
"Report not found",
|
||||
$"No attestation report found for artifact '{artifactDigest}'.",
|
||||
"ERR_ATTEST_011"));
|
||||
}
|
||||
|
||||
return Results.Ok(report);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListReportsAsync(
|
||||
[FromBody] AttestationReportQuery? query,
|
||||
IAttestationReportService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var effectiveQuery = query ?? new AttestationReportQuery(
|
||||
ArtifactDigests: null,
|
||||
ArtifactUriPattern: null,
|
||||
PolicyIds: null,
|
||||
PredicateTypes: null,
|
||||
StatusFilter: null,
|
||||
FromTime: null,
|
||||
ToTime: null,
|
||||
IncludeDetails: true,
|
||||
Limit: 100,
|
||||
Offset: 0);
|
||||
|
||||
var response = await service.ListReportsAsync(effectiveQuery, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> VerifyArtifactAsync(
|
||||
[FromBody] VerifyArtifactRequest? request,
|
||||
IAttestationReportService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
return Results.BadRequest(CreateProblem(
|
||||
"Invalid request",
|
||||
"Request body is required.",
|
||||
"ERR_ATTEST_001"));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ArtifactDigest))
|
||||
{
|
||||
return Results.BadRequest(CreateProblem(
|
||||
"Invalid request",
|
||||
"Artifact digest is required.",
|
||||
"ERR_ATTEST_010"));
|
||||
}
|
||||
|
||||
var report = await service.GenerateReportAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(report);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetStatisticsAsync(
|
||||
[FromQuery] string? policyIds,
|
||||
[FromQuery] string? predicateTypes,
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] DateTimeOffset? fromTime,
|
||||
[FromQuery] DateTimeOffset? toTime,
|
||||
IAttestationReportService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
AttestationReportQuery? filter = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(policyIds) ||
|
||||
!string.IsNullOrWhiteSpace(predicateTypes) ||
|
||||
!string.IsNullOrWhiteSpace(status) ||
|
||||
fromTime.HasValue ||
|
||||
toTime.HasValue)
|
||||
{
|
||||
filter = new AttestationReportQuery(
|
||||
ArtifactDigests: null,
|
||||
ArtifactUriPattern: null,
|
||||
PolicyIds: string.IsNullOrWhiteSpace(policyIds) ? null : policyIds.Split(',').ToList(),
|
||||
PredicateTypes: string.IsNullOrWhiteSpace(predicateTypes) ? null : predicateTypes.Split(',').ToList(),
|
||||
StatusFilter: string.IsNullOrWhiteSpace(status)
|
||||
? null
|
||||
: status.Split(',')
|
||||
.Select(s => Enum.TryParse<AttestationReportStatus>(s, true, out var parsed) ? parsed : (AttestationReportStatus?)null)
|
||||
.Where(s => s.HasValue)
|
||||
.Select(s => s!.Value)
|
||||
.ToList(),
|
||||
FromTime: fromTime,
|
||||
ToTime: toTime,
|
||||
IncludeDetails: false,
|
||||
Limit: int.MaxValue,
|
||||
Offset: 0);
|
||||
}
|
||||
|
||||
var statistics = await service.GetStatisticsAsync(filter, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(statistics);
|
||||
}
|
||||
|
||||
private static async Task<IResult> StoreReportAsync(
|
||||
[FromBody] StoreReportRequest? request,
|
||||
IAttestationReportService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request?.Report == null)
|
||||
{
|
||||
return Results.BadRequest(CreateProblem(
|
||||
"Invalid request",
|
||||
"Report is required.",
|
||||
"ERR_ATTEST_012"));
|
||||
}
|
||||
|
||||
TimeSpan? ttl = request.TtlSeconds.HasValue
|
||||
? TimeSpan.FromSeconds(request.TtlSeconds.Value)
|
||||
: null;
|
||||
|
||||
var stored = await service.StoreReportAsync(request.Report, ttl, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created(
|
||||
$"/api/v1/attestor/reports/{stored.Report.ArtifactDigest}",
|
||||
stored);
|
||||
}
|
||||
|
||||
private static async Task<IResult> PurgeExpiredAsync(
|
||||
IAttestationReportService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var count = await service.PurgeExpiredReportsAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(new PurgeExpiredResponse(PurgedCount: count));
|
||||
}
|
||||
|
||||
private static ProblemDetails CreateProblem(string title, string detail, string? errorCode = null)
|
||||
{
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Title = title,
|
||||
Detail = detail,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errorCode))
|
||||
{
|
||||
problem.Extensions["error_code"] = errorCode;
|
||||
}
|
||||
|
||||
return problem;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to store an attestation report.
|
||||
/// </summary>
|
||||
public sealed record StoreReportRequest(
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("report")] ArtifactAttestationReport Report,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("ttl_seconds")] int? TtlSeconds);
|
||||
|
||||
/// <summary>
|
||||
/// Response from purging expired reports.
|
||||
/// </summary>
|
||||
public sealed record PurgeExpiredResponse(
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("purged_count")] int PurgedCount);
|
||||
@@ -0,0 +1,125 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Engine.ConsoleSurface;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Console endpoints for attestation reports per CONTRACT-VERIFICATION-POLICY-006.
|
||||
/// </summary>
|
||||
internal static class ConsoleAttestationReportEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapConsoleAttestationReports(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/policy/console/attestation")
|
||||
.WithTags("Console Attestation Reports")
|
||||
.RequireRateLimiting(PolicyEngineRateLimitOptions.PolicyName);
|
||||
|
||||
group.MapPost("/reports", QueryReportsAsync)
|
||||
.WithName("PolicyEngine.ConsoleAttestationReports")
|
||||
.WithSummary("Query attestation reports for Console")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
|
||||
.Produces<ConsoleAttestationReportResponse>(StatusCodes.Status200OK)
|
||||
.ProducesValidationProblem();
|
||||
|
||||
group.MapPost("/dashboard", GetDashboardAsync)
|
||||
.WithName("PolicyEngine.ConsoleAttestationDashboard")
|
||||
.WithSummary("Get attestation dashboard for Console")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
|
||||
.Produces<ConsoleAttestationDashboardResponse>(StatusCodes.Status200OK)
|
||||
.ProducesValidationProblem();
|
||||
|
||||
group.MapGet("/report/{artifactDigest}", GetReportAsync)
|
||||
.WithName("PolicyEngine.ConsoleGetAttestationReport")
|
||||
.WithSummary("Get attestation report for a specific artifact")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
|
||||
.Produces<ConsoleArtifactReport>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> QueryReportsAsync(
|
||||
[FromBody] ConsoleAttestationReportRequest? request,
|
||||
ConsoleAttestationReportService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["request"] = ["Request body is required."]
|
||||
});
|
||||
}
|
||||
|
||||
if (request.Page < 1)
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["page"] = ["Page must be at least 1."]
|
||||
});
|
||||
}
|
||||
|
||||
if (request.PageSize < 1 || request.PageSize > 100)
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["pageSize"] = ["Page size must be between 1 and 100."]
|
||||
});
|
||||
}
|
||||
|
||||
var response = await service.QueryReportsAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Json(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetDashboardAsync(
|
||||
[FromBody] ConsoleAttestationDashboardRequest? request,
|
||||
ConsoleAttestationReportService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var effectiveRequest = request ?? new ConsoleAttestationDashboardRequest(
|
||||
TimeRange: "24h",
|
||||
PolicyIds: null,
|
||||
ArtifactUriPattern: null);
|
||||
|
||||
var response = await service.GetDashboardAsync(effectiveRequest, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Json(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetReportAsync(
|
||||
[FromRoute] string artifactDigest,
|
||||
ConsoleAttestationReportService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(artifactDigest))
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["artifactDigest"] = ["Artifact digest is required."]
|
||||
});
|
||||
}
|
||||
|
||||
var request = new ConsoleAttestationReportRequest(
|
||||
ArtifactDigests: [artifactDigest],
|
||||
ArtifactUriPattern: null,
|
||||
PolicyIds: null,
|
||||
PredicateTypes: null,
|
||||
StatusFilter: null,
|
||||
FromTime: null,
|
||||
ToTime: null,
|
||||
GroupBy: null,
|
||||
SortBy: null,
|
||||
Page: 1,
|
||||
PageSize: 1);
|
||||
|
||||
var response = await service.QueryReportsAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.Reports.Count == 0)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Json(response.Reports[0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.RiskProfile.Scope;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for managing effective policies per CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008.
|
||||
/// </summary>
|
||||
internal static class EffectivePolicyEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapEffectivePolicies(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/v1/authority/effective-policies")
|
||||
.RequireAuthorization()
|
||||
.WithTags("Effective Policies");
|
||||
|
||||
group.MapPost("/", CreateEffectivePolicy)
|
||||
.WithName("CreateEffectivePolicy")
|
||||
.WithSummary("Create a new effective policy with subject pattern and priority.")
|
||||
.Produces<EffectivePolicyResponse>(StatusCodes.Status201Created)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapGet("/{effectivePolicyId}", GetEffectivePolicy)
|
||||
.WithName("GetEffectivePolicy")
|
||||
.WithSummary("Get an effective policy by ID.")
|
||||
.Produces<EffectivePolicyResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPut("/{effectivePolicyId}", UpdateEffectivePolicy)
|
||||
.WithName("UpdateEffectivePolicy")
|
||||
.WithSummary("Update an effective policy's priority, expiration, or scopes.")
|
||||
.Produces<EffectivePolicyResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapDelete("/{effectivePolicyId}", DeleteEffectivePolicy)
|
||||
.WithName("DeleteEffectivePolicy")
|
||||
.WithSummary("Delete an effective policy.")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/", ListEffectivePolicies)
|
||||
.WithName("ListEffectivePolicies")
|
||||
.WithSummary("List effective policies with optional filtering.")
|
||||
.Produces<EffectivePolicyListResponse>(StatusCodes.Status200OK);
|
||||
|
||||
// Scope attachments
|
||||
var scopeGroup = endpoints.MapGroup("/api/v1/authority/scope-attachments")
|
||||
.RequireAuthorization()
|
||||
.WithTags("Authority Scope Attachments");
|
||||
|
||||
scopeGroup.MapPost("/", AttachScope)
|
||||
.WithName("AttachAuthorityScope")
|
||||
.WithSummary("Attach an authorization scope to an effective policy.")
|
||||
.Produces<AuthorityScopeAttachmentResponse>(StatusCodes.Status201Created)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
scopeGroup.MapDelete("/{attachmentId}", DetachScope)
|
||||
.WithName("DetachAuthorityScope")
|
||||
.WithSummary("Detach an authorization scope.")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
scopeGroup.MapGet("/policy/{effectivePolicyId}", GetPolicyScopeAttachments)
|
||||
.WithName("GetPolicyScopeAttachments")
|
||||
.WithSummary("Get all scope attachments for an effective policy.")
|
||||
.Produces<AuthorityScopeAttachmentListResponse>(StatusCodes.Status200OK);
|
||||
|
||||
// Resolution
|
||||
var resolveGroup = endpoints.MapGroup("/api/v1/authority")
|
||||
.RequireAuthorization()
|
||||
.WithTags("Policy Resolution");
|
||||
|
||||
resolveGroup.MapGet("/resolve", ResolveEffectivePolicy)
|
||||
.WithName("ResolveEffectivePolicy")
|
||||
.WithSummary("Resolve the effective policy for a subject.")
|
||||
.Produces<EffectivePolicyResolutionResponse>(StatusCodes.Status200OK);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static IResult CreateEffectivePolicy(
|
||||
HttpContext context,
|
||||
[FromBody] CreateEffectivePolicyRequest request,
|
||||
EffectivePolicyService policyService,
|
||||
IEffectivePolicyAuditor auditor)
|
||||
{
|
||||
var scopeResult = RequireEffectiveWriteScope(context);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request == null)
|
||||
{
|
||||
return Results.BadRequest(CreateProblem("Invalid request", "Request body is required."));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var actorId = ResolveActorId(context);
|
||||
var policy = policyService.Create(request, actorId);
|
||||
|
||||
// Audit per CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008
|
||||
auditor.RecordCreated(policy, actorId);
|
||||
|
||||
return Results.Created(
|
||||
$"/api/v1/authority/effective-policies/{policy.EffectivePolicyId}",
|
||||
new EffectivePolicyResponse(policy));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(CreateProblem("Invalid request", ex.Message, "ERR_AUTH_001"));
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult GetEffectivePolicy(
|
||||
HttpContext context,
|
||||
[FromRoute] string effectivePolicyId,
|
||||
EffectivePolicyService policyService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var policy = policyService.Get(effectivePolicyId);
|
||||
if (policy == null)
|
||||
{
|
||||
return Results.NotFound(CreateProblem(
|
||||
"Policy not found",
|
||||
$"Effective policy '{effectivePolicyId}' was not found.",
|
||||
"ERR_AUTH_002"));
|
||||
}
|
||||
|
||||
return Results.Ok(new EffectivePolicyResponse(policy));
|
||||
}
|
||||
|
||||
private static IResult UpdateEffectivePolicy(
|
||||
HttpContext context,
|
||||
[FromRoute] string effectivePolicyId,
|
||||
[FromBody] UpdateEffectivePolicyRequest request,
|
||||
EffectivePolicyService policyService,
|
||||
IEffectivePolicyAuditor auditor)
|
||||
{
|
||||
var scopeResult = RequireEffectiveWriteScope(context);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request == null)
|
||||
{
|
||||
return Results.BadRequest(CreateProblem("Invalid request", "Request body is required."));
|
||||
}
|
||||
|
||||
var actorId = ResolveActorId(context);
|
||||
var policy = policyService.Update(effectivePolicyId, request, actorId);
|
||||
|
||||
if (policy == null)
|
||||
{
|
||||
return Results.NotFound(CreateProblem(
|
||||
"Policy not found",
|
||||
$"Effective policy '{effectivePolicyId}' was not found.",
|
||||
"ERR_AUTH_002"));
|
||||
}
|
||||
|
||||
// Audit per CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008
|
||||
auditor.RecordUpdated(policy, actorId, request);
|
||||
|
||||
return Results.Ok(new EffectivePolicyResponse(policy));
|
||||
}
|
||||
|
||||
private static IResult DeleteEffectivePolicy(
|
||||
HttpContext context,
|
||||
[FromRoute] string effectivePolicyId,
|
||||
EffectivePolicyService policyService,
|
||||
IEffectivePolicyAuditor auditor)
|
||||
{
|
||||
var scopeResult = RequireEffectiveWriteScope(context);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!policyService.Delete(effectivePolicyId))
|
||||
{
|
||||
return Results.NotFound(CreateProblem(
|
||||
"Policy not found",
|
||||
$"Effective policy '{effectivePolicyId}' was not found.",
|
||||
"ERR_AUTH_002"));
|
||||
}
|
||||
|
||||
// Audit per CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008
|
||||
var actorId = ResolveActorId(context);
|
||||
auditor.RecordDeleted(effectivePolicyId, actorId);
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static IResult ListEffectivePolicies(
|
||||
HttpContext context,
|
||||
[FromQuery] string? tenantId,
|
||||
[FromQuery] string? policyId,
|
||||
[FromQuery] bool enabledOnly,
|
||||
[FromQuery] bool includeExpired,
|
||||
[FromQuery] int limit,
|
||||
EffectivePolicyService policyService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var query = new EffectivePolicyQuery(
|
||||
TenantId: tenantId,
|
||||
PolicyId: policyId,
|
||||
EnabledOnly: enabledOnly,
|
||||
IncludeExpired: includeExpired,
|
||||
Limit: limit > 0 ? limit : 100);
|
||||
|
||||
var policies = policyService.Query(query);
|
||||
|
||||
return Results.Ok(new EffectivePolicyListResponse(policies, policies.Count));
|
||||
}
|
||||
|
||||
private static IResult AttachScope(
|
||||
HttpContext context,
|
||||
[FromBody] AttachAuthorityScopeRequest request,
|
||||
EffectivePolicyService policyService,
|
||||
IEffectivePolicyAuditor auditor)
|
||||
{
|
||||
var scopeResult = RequireEffectiveWriteScope(context);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request == null)
|
||||
{
|
||||
return Results.BadRequest(CreateProblem("Invalid request", "Request body is required."));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var attachment = policyService.AttachScope(request);
|
||||
|
||||
// Audit per CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008
|
||||
var actorId = ResolveActorId(context);
|
||||
auditor.RecordScopeAttached(attachment, actorId);
|
||||
|
||||
return Results.Created(
|
||||
$"/api/v1/authority/scope-attachments/{attachment.AttachmentId}",
|
||||
new AuthorityScopeAttachmentResponse(attachment));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
var code = ex.Message.Contains("not found") ? "ERR_AUTH_002" : "ERR_AUTH_004";
|
||||
return Results.BadRequest(CreateProblem("Invalid request", ex.Message, code));
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult DetachScope(
|
||||
HttpContext context,
|
||||
[FromRoute] string attachmentId,
|
||||
EffectivePolicyService policyService,
|
||||
IEffectivePolicyAuditor auditor)
|
||||
{
|
||||
var scopeResult = RequireEffectiveWriteScope(context);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!policyService.DetachScope(attachmentId))
|
||||
{
|
||||
return Results.NotFound(CreateProblem(
|
||||
"Attachment not found",
|
||||
$"Scope attachment '{attachmentId}' was not found."));
|
||||
}
|
||||
|
||||
// Audit per CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008
|
||||
var actorId = ResolveActorId(context);
|
||||
auditor.RecordScopeDetached(attachmentId, actorId);
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static IResult GetPolicyScopeAttachments(
|
||||
HttpContext context,
|
||||
[FromRoute] string effectivePolicyId,
|
||||
EffectivePolicyService policyService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var attachments = policyService.GetScopeAttachments(effectivePolicyId);
|
||||
|
||||
return Results.Ok(new AuthorityScopeAttachmentListResponse(attachments));
|
||||
}
|
||||
|
||||
private static IResult ResolveEffectivePolicy(
|
||||
HttpContext context,
|
||||
[FromQuery] string subject,
|
||||
[FromQuery] string? tenantId,
|
||||
EffectivePolicyService policyService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(subject))
|
||||
{
|
||||
return Results.BadRequest(CreateProblem("Invalid request", "Subject is required."));
|
||||
}
|
||||
|
||||
var result = policyService.Resolve(subject, tenantId);
|
||||
|
||||
return Results.Ok(new EffectivePolicyResolutionResponse(result));
|
||||
}
|
||||
|
||||
private static IResult? RequireEffectiveWriteScope(HttpContext context)
|
||||
{
|
||||
// Check for effective:write scope per CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008
|
||||
// Primary scope: effective:write (StellaOpsScopes.EffectiveWrite)
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.EffectiveWrite);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
// Fall back to policy:edit for backwards compatibility during migration
|
||||
// TODO: Remove fallback after migration period (track in POLICY-AOC-19-002)
|
||||
return ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ResolveActorId(HttpContext context)
|
||||
{
|
||||
var user = context.User;
|
||||
var actor = user?.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? user?.FindFirst(ClaimTypes.Upn)?.Value
|
||||
?? user?.FindFirst("sub")?.Value;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(actor))
|
||||
{
|
||||
return actor;
|
||||
}
|
||||
|
||||
if (context.Request.Headers.TryGetValue("X-StellaOps-Actor", out var header) && !string.IsNullOrWhiteSpace(header))
|
||||
{
|
||||
return header.ToString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ProblemDetails CreateProblem(string title, string detail, string? errorCode = null)
|
||||
{
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Title = title,
|
||||
Detail = detail,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errorCode))
|
||||
{
|
||||
problem.Extensions["error_code"] = errorCode;
|
||||
}
|
||||
|
||||
return problem;
|
||||
}
|
||||
}
|
||||
|
||||
#region Response DTOs
|
||||
|
||||
internal sealed record EffectivePolicyResponse(EffectivePolicy EffectivePolicy);
|
||||
|
||||
internal sealed record EffectivePolicyListResponse(IReadOnlyList<EffectivePolicy> Items, int Total);
|
||||
|
||||
internal sealed record AuthorityScopeAttachmentResponse(AuthorityScopeAttachment Attachment);
|
||||
|
||||
internal sealed record AuthorityScopeAttachmentListResponse(IReadOnlyList<AuthorityScopeAttachment> Attachments);
|
||||
|
||||
internal sealed record EffectivePolicyResolutionResponse(EffectivePolicyResolutionResult Result);
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,241 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.DeterminismGuard;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for policy code linting and determinism analysis.
|
||||
/// Implements POLICY-AOC-19-001 per docs/modules/policy/design/policy-aoc-linting-rules.md.
|
||||
/// </summary>
|
||||
public static class PolicyLintEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapPolicyLint(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/api/v1/policy/lint");
|
||||
|
||||
group.MapPost("/analyze", AnalyzeSourceAsync)
|
||||
.WithName("Policy.Lint.Analyze")
|
||||
.WithDescription("Analyze source code for determinism violations")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", "policy:read"));
|
||||
|
||||
group.MapPost("/analyze-batch", AnalyzeBatchAsync)
|
||||
.WithName("Policy.Lint.AnalyzeBatch")
|
||||
.WithDescription("Analyze multiple source files for determinism violations")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", "policy:read"));
|
||||
|
||||
group.MapGet("/rules", GetLintRulesAsync)
|
||||
.WithName("Policy.Lint.GetRules")
|
||||
.WithDescription("Get available lint rules and their severities")
|
||||
.AllowAnonymous();
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static Task<IResult> AnalyzeSourceAsync(
|
||||
[FromBody] LintSourceRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request is null || string.IsNullOrWhiteSpace(request.Source))
|
||||
{
|
||||
return Task.FromResult(Results.BadRequest(new
|
||||
{
|
||||
error = "LINT_SOURCE_REQUIRED",
|
||||
message = "Source code is required"
|
||||
}));
|
||||
}
|
||||
|
||||
var analyzer = new ProhibitedPatternAnalyzer();
|
||||
var options = new DeterminismGuardOptions
|
||||
{
|
||||
EnforcementEnabled = request.EnforceErrors ?? true,
|
||||
FailOnSeverity = ParseSeverity(request.MinSeverity),
|
||||
EnableStaticAnalysis = true,
|
||||
EnableRuntimeMonitoring = false
|
||||
};
|
||||
|
||||
var result = analyzer.AnalyzeSource(request.Source, request.FileName, options);
|
||||
|
||||
return Task.FromResult(Results.Ok(new LintResultResponse
|
||||
{
|
||||
Passed = result.Passed,
|
||||
Violations = result.Violations.Select(MapViolation).ToList(),
|
||||
CountBySeverity = result.CountBySeverity.ToDictionary(
|
||||
kvp => kvp.Key.ToString().ToLowerInvariant(),
|
||||
kvp => kvp.Value),
|
||||
AnalysisDurationMs = result.AnalysisDurationMs,
|
||||
EnforcementEnabled = result.EnforcementEnabled
|
||||
}));
|
||||
}
|
||||
|
||||
private static Task<IResult> AnalyzeBatchAsync(
|
||||
[FromBody] LintBatchRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request?.Files is null || request.Files.Count == 0)
|
||||
{
|
||||
return Task.FromResult(Results.BadRequest(new
|
||||
{
|
||||
error = "LINT_FILES_REQUIRED",
|
||||
message = "At least one file is required"
|
||||
}));
|
||||
}
|
||||
|
||||
var analyzer = new ProhibitedPatternAnalyzer();
|
||||
var options = new DeterminismGuardOptions
|
||||
{
|
||||
EnforcementEnabled = request.EnforceErrors ?? true,
|
||||
FailOnSeverity = ParseSeverity(request.MinSeverity),
|
||||
EnableStaticAnalysis = true,
|
||||
EnableRuntimeMonitoring = false
|
||||
};
|
||||
|
||||
var sources = request.Files.Select(f => (f.Source, f.FileName));
|
||||
var result = analyzer.AnalyzeMultiple(sources, options);
|
||||
|
||||
return Task.FromResult(Results.Ok(new LintResultResponse
|
||||
{
|
||||
Passed = result.Passed,
|
||||
Violations = result.Violations.Select(MapViolation).ToList(),
|
||||
CountBySeverity = result.CountBySeverity.ToDictionary(
|
||||
kvp => kvp.Key.ToString().ToLowerInvariant(),
|
||||
kvp => kvp.Value),
|
||||
AnalysisDurationMs = result.AnalysisDurationMs,
|
||||
EnforcementEnabled = result.EnforcementEnabled
|
||||
}));
|
||||
}
|
||||
|
||||
private static Task<IResult> GetLintRulesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var rules = new List<LintRuleInfo>
|
||||
{
|
||||
// Wall-clock rules
|
||||
new("DET-001", "DateTime.Now", "error", "WallClock", "Use TimeProvider.GetUtcNow()"),
|
||||
new("DET-002", "DateTime.UtcNow", "error", "WallClock", "Use TimeProvider.GetUtcNow()"),
|
||||
new("DET-003", "DateTimeOffset.Now", "error", "WallClock", "Use TimeProvider.GetUtcNow()"),
|
||||
new("DET-004", "DateTimeOffset.UtcNow", "error", "WallClock", "Use TimeProvider.GetUtcNow()"),
|
||||
|
||||
// Random/GUID rules
|
||||
new("DET-005", "Guid.NewGuid()", "error", "GuidGeneration", "Use StableIdGenerator or content hash"),
|
||||
new("DET-006", "new Random()", "error", "RandomNumber", "Use seeded random or remove"),
|
||||
new("DET-007", "RandomNumberGenerator", "error", "RandomNumber", "Remove from evaluation path"),
|
||||
|
||||
// Network/Filesystem rules
|
||||
new("DET-008", "HttpClient in eval", "critical", "NetworkAccess", "Remove network from eval path"),
|
||||
new("DET-009", "File.Read* in eval", "critical", "FileSystemAccess", "Remove filesystem from eval path"),
|
||||
|
||||
// Ordering rules
|
||||
new("DET-010", "Dictionary iteration", "warning", "UnstableIteration", "Use OrderBy or SortedDictionary"),
|
||||
new("DET-011", "HashSet iteration", "warning", "UnstableIteration", "Use OrderBy or SortedSet"),
|
||||
|
||||
// Environment rules
|
||||
new("DET-012", "Environment.GetEnvironmentVariable", "error", "EnvironmentAccess", "Use evaluation context"),
|
||||
new("DET-013", "Environment.MachineName", "warning", "EnvironmentAccess", "Remove host-specific info")
|
||||
};
|
||||
|
||||
return Task.FromResult(Results.Ok(new
|
||||
{
|
||||
rules,
|
||||
categories = new[]
|
||||
{
|
||||
"WallClock",
|
||||
"RandomNumber",
|
||||
"GuidGeneration",
|
||||
"NetworkAccess",
|
||||
"FileSystemAccess",
|
||||
"EnvironmentAccess",
|
||||
"UnstableIteration",
|
||||
"FloatingPointHazard",
|
||||
"ConcurrencyHazard"
|
||||
},
|
||||
severities = new[] { "info", "warning", "error", "critical" }
|
||||
}));
|
||||
}
|
||||
|
||||
private static DeterminismViolationSeverity ParseSeverity(string? severity)
|
||||
{
|
||||
return severity?.ToLowerInvariant() switch
|
||||
{
|
||||
"info" => DeterminismViolationSeverity.Info,
|
||||
"warning" => DeterminismViolationSeverity.Warning,
|
||||
"error" => DeterminismViolationSeverity.Error,
|
||||
"critical" => DeterminismViolationSeverity.Critical,
|
||||
_ => DeterminismViolationSeverity.Error
|
||||
};
|
||||
}
|
||||
|
||||
private static LintViolationResponse MapViolation(DeterminismViolation v)
|
||||
{
|
||||
return new LintViolationResponse
|
||||
{
|
||||
Category = v.Category.ToString(),
|
||||
ViolationType = v.ViolationType,
|
||||
Message = v.Message,
|
||||
Severity = v.Severity.ToString().ToLowerInvariant(),
|
||||
SourceFile = v.SourceFile,
|
||||
LineNumber = v.LineNumber,
|
||||
MemberName = v.MemberName,
|
||||
Remediation = v.Remediation
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for single source analysis.
|
||||
/// </summary>
|
||||
public sealed record LintSourceRequest(
|
||||
string Source,
|
||||
string? FileName = null,
|
||||
string? MinSeverity = null,
|
||||
bool? EnforceErrors = null);
|
||||
|
||||
/// <summary>
|
||||
/// Request for batch source analysis.
|
||||
/// </summary>
|
||||
public sealed record LintBatchRequest(
|
||||
List<LintFileInput> Files,
|
||||
string? MinSeverity = null,
|
||||
bool? EnforceErrors = null);
|
||||
|
||||
/// <summary>
|
||||
/// Single file input for batch analysis.
|
||||
/// </summary>
|
||||
public sealed record LintFileInput(
|
||||
string Source,
|
||||
string FileName);
|
||||
|
||||
/// <summary>
|
||||
/// Response for lint analysis.
|
||||
/// </summary>
|
||||
public sealed record LintResultResponse
|
||||
{
|
||||
public required bool Passed { get; init; }
|
||||
public required List<LintViolationResponse> Violations { get; init; }
|
||||
public required Dictionary<string, int> CountBySeverity { get; init; }
|
||||
public required long AnalysisDurationMs { get; init; }
|
||||
public required bool EnforcementEnabled { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single violation in lint response.
|
||||
/// </summary>
|
||||
public sealed record LintViolationResponse
|
||||
{
|
||||
public required string Category { get; init; }
|
||||
public required string ViolationType { get; init; }
|
||||
public required string Message { get; init; }
|
||||
public required string Severity { get; init; }
|
||||
public string? SourceFile { get; init; }
|
||||
public int? LineNumber { get; init; }
|
||||
public string? MemberName { get; init; }
|
||||
public string? Remediation { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lint rule information.
|
||||
/// </summary>
|
||||
public sealed record LintRuleInfo(
|
||||
string RuleId,
|
||||
string Name,
|
||||
string DefaultSeverity,
|
||||
string Category,
|
||||
string Remediation);
|
||||
@@ -14,11 +14,15 @@ public static class PolicyPackBundleEndpoints
|
||||
|
||||
group.MapPost("", RegisterBundleAsync)
|
||||
.WithName("AirGap.RegisterBundle")
|
||||
.WithDescription("Register a bundle for import");
|
||||
.WithDescription("Register a bundle for import")
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||
.ProducesProblem(StatusCodes.Status412PreconditionFailed);
|
||||
|
||||
group.MapGet("{bundleId}", GetBundleStatusAsync)
|
||||
.WithName("AirGap.GetBundleStatus")
|
||||
.WithDescription("Get bundle import status");
|
||||
.WithDescription("Get bundle import status")
|
||||
.ProducesProblem(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("", ListBundlesAsync)
|
||||
.WithName("AirGap.ListBundles")
|
||||
@@ -47,13 +51,24 @@ public static class PolicyPackBundleEndpoints
|
||||
var response = await service.RegisterBundleAsync(tenantId, request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Accepted($"/api/v1/airgap/bundles/{response.ImportId}", response);
|
||||
}
|
||||
catch (SealedModeException ex)
|
||||
{
|
||||
return SealedModeResultHelper.ToProblem(ex);
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("Bundle import blocked"))
|
||||
{
|
||||
// Sealed-mode enforcement blocked the import
|
||||
return SealedModeResultHelper.ToProblem(
|
||||
SealedModeErrorCodes.ImportBlocked,
|
||||
ex.Message,
|
||||
"Ensure time anchor is fresh before importing bundles");
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Invalid request",
|
||||
detail: ex.Message,
|
||||
statusCode: 400,
|
||||
extensions: new Dictionary<string, object?> { ["code"] = "INVALID_REQUEST" });
|
||||
return SealedModeResultHelper.ToProblem(
|
||||
SealedModeErrorCodes.BundleInvalid,
|
||||
ex.Message,
|
||||
"Verify request parameters are valid");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Engine.AirGap;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for air-gap risk profile export/import per CONTRACT-MIRROR-BUNDLE-003.
|
||||
/// </summary>
|
||||
public static class RiskProfileAirGapEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapRiskProfileAirGap(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/api/v1/airgap/risk-profiles")
|
||||
.RequireAuthorization()
|
||||
.WithTags("Air-Gap Risk Profiles");
|
||||
|
||||
group.MapPost("/export", ExportProfilesAsync)
|
||||
.WithName("AirGap.ExportRiskProfiles")
|
||||
.WithSummary("Export risk profiles as an air-gap compatible bundle with signatures.")
|
||||
.Produces<RiskProfileAirGapBundle>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapPost("/export/download", DownloadBundleAsync)
|
||||
.WithName("AirGap.DownloadRiskProfileBundle")
|
||||
.WithSummary("Export and download risk profiles as an air-gap compatible JSON file.")
|
||||
.Produces<FileContentHttpResult>(StatusCodes.Status200OK, contentType: "application/json");
|
||||
|
||||
group.MapPost("/import", ImportProfilesAsync)
|
||||
.WithName("AirGap.ImportRiskProfiles")
|
||||
.WithSummary("Import risk profiles from an air-gap bundle with sealed-mode enforcement.")
|
||||
.Produces<RiskProfileAirGapImportResult>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status403Forbidden)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status412PreconditionFailed);
|
||||
|
||||
group.MapPost("/verify", VerifyBundleAsync)
|
||||
.WithName("AirGap.VerifyRiskProfileBundle")
|
||||
.WithSummary("Verify the integrity of an air-gap bundle without importing.")
|
||||
.Produces<AirGapBundleVerification>(StatusCodes.Status200OK);
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ExportProfilesAsync(
|
||||
HttpContext context,
|
||||
[FromBody] AirGapProfileExportRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
RiskProfileConfigurationService profileService,
|
||||
RiskProfileAirGapExportService exportService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request == null || request.ProfileIds == null || request.ProfileIds.Count == 0)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Invalid request",
|
||||
detail: "At least one profile ID is required.",
|
||||
statusCode: 400);
|
||||
}
|
||||
|
||||
var profiles = new List<RiskProfileModel>();
|
||||
var notFound = new List<string>();
|
||||
|
||||
foreach (var profileId in request.ProfileIds)
|
||||
{
|
||||
var profile = profileService.GetProfile(profileId);
|
||||
if (profile != null)
|
||||
{
|
||||
profiles.Add(profile);
|
||||
}
|
||||
else
|
||||
{
|
||||
notFound.Add(profileId);
|
||||
}
|
||||
}
|
||||
|
||||
if (notFound.Count > 0)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Profiles not found",
|
||||
detail: $"The following profiles were not found: {string.Join(", ", notFound)}",
|
||||
statusCode: 400);
|
||||
}
|
||||
|
||||
var exportRequest = new AirGapExportRequest(
|
||||
SignBundle: request.SignBundle,
|
||||
KeyId: request.KeyId,
|
||||
TargetRepository: request.TargetRepository,
|
||||
DisplayName: request.DisplayName);
|
||||
|
||||
var bundle = await exportService.ExportAsync(
|
||||
profiles, exportRequest, tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(bundle);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DownloadBundleAsync(
|
||||
HttpContext context,
|
||||
[FromBody] AirGapProfileExportRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
RiskProfileConfigurationService profileService,
|
||||
RiskProfileAirGapExportService exportService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request == null || request.ProfileIds == null || request.ProfileIds.Count == 0)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Invalid request",
|
||||
detail: "At least one profile ID is required.",
|
||||
statusCode: 400);
|
||||
}
|
||||
|
||||
var profiles = new List<RiskProfileModel>();
|
||||
|
||||
foreach (var profileId in request.ProfileIds)
|
||||
{
|
||||
var profile = profileService.GetProfile(profileId);
|
||||
if (profile != null)
|
||||
{
|
||||
profiles.Add(profile);
|
||||
}
|
||||
}
|
||||
|
||||
var exportRequest = new AirGapExportRequest(
|
||||
SignBundle: request.SignBundle,
|
||||
KeyId: request.KeyId,
|
||||
TargetRepository: request.TargetRepository,
|
||||
DisplayName: request.DisplayName);
|
||||
|
||||
var bundle = await exportService.ExportAsync(
|
||||
profiles, exportRequest, tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(bundle, new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(json);
|
||||
var fileName = $"risk-profiles-airgap-{DateTime.UtcNow:yyyyMMddHHmmss}.json";
|
||||
|
||||
return Results.File(bytes, "application/json", fileName);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ImportProfilesAsync(
|
||||
HttpContext context,
|
||||
[FromBody] AirGapProfileImportRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
RiskProfileAirGapExportService exportService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request == null || request.Bundle == null)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Invalid request",
|
||||
detail: "Bundle is required.",
|
||||
statusCode: 400);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Tenant ID required",
|
||||
detail: "X-Tenant-Id header is required for air-gap import.",
|
||||
statusCode: 400,
|
||||
extensions: new Dictionary<string, object?> { ["code"] = "TENANT_REQUIRED" });
|
||||
}
|
||||
|
||||
var importRequest = new AirGapImportRequest(
|
||||
VerifySignature: request.VerifySignature,
|
||||
VerifyMerkle: request.VerifyMerkle,
|
||||
EnforceSealedMode: request.EnforceSealedMode,
|
||||
RejectOnSignatureFailure: request.RejectOnSignatureFailure,
|
||||
RejectOnMerkleFailure: request.RejectOnMerkleFailure);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await exportService.ImportAsync(
|
||||
request.Bundle, importRequest, tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
var extensions = new Dictionary<string, object?>
|
||||
{
|
||||
["errors"] = result.Errors,
|
||||
["signatureVerified"] = result.SignatureVerified,
|
||||
["merkleVerified"] = result.MerkleVerified
|
||||
};
|
||||
|
||||
// Check if it's a sealed-mode enforcement failure
|
||||
if (result.Errors.Any(e => e.Contains("Sealed-mode")))
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Import blocked by sealed mode",
|
||||
detail: result.Errors.FirstOrDefault() ?? "Sealed mode enforcement failed",
|
||||
statusCode: 412,
|
||||
extensions: extensions);
|
||||
}
|
||||
|
||||
return Results.Problem(
|
||||
title: "Import failed",
|
||||
detail: $"Import completed with {result.ErrorCount} errors",
|
||||
statusCode: 400,
|
||||
extensions: extensions);
|
||||
}
|
||||
|
||||
return Results.Ok(result);
|
||||
}
|
||||
catch (SealedModeException ex)
|
||||
{
|
||||
return SealedModeResultHelper.ToProblem(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult VerifyBundleAsync(
|
||||
HttpContext context,
|
||||
[FromBody] RiskProfileAirGapBundle bundle,
|
||||
RiskProfileAirGapExportService exportService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (bundle == null)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Invalid request",
|
||||
detail: "Bundle is required.",
|
||||
statusCode: 400);
|
||||
}
|
||||
|
||||
var verification = exportService.Verify(bundle);
|
||||
return Results.Ok(verification);
|
||||
}
|
||||
}
|
||||
|
||||
#region Request DTOs
|
||||
|
||||
/// <summary>
|
||||
/// Request to export profiles as an air-gap bundle.
|
||||
/// </summary>
|
||||
public sealed record AirGapProfileExportRequest(
|
||||
IReadOnlyList<string> ProfileIds,
|
||||
bool SignBundle = true,
|
||||
string? KeyId = null,
|
||||
string? TargetRepository = null,
|
||||
string? DisplayName = null);
|
||||
|
||||
/// <summary>
|
||||
/// Request to import profiles from an air-gap bundle.
|
||||
/// </summary>
|
||||
public sealed record AirGapProfileImportRequest(
|
||||
RiskProfileAirGapBundle Bundle,
|
||||
bool VerifySignature = true,
|
||||
bool VerifyMerkle = true,
|
||||
bool EnforceSealedMode = true,
|
||||
bool RejectOnSignatureFailure = true,
|
||||
bool RejectOnMerkleFailure = true);
|
||||
|
||||
#endregion
|
||||
@@ -7,6 +7,10 @@ using StellaOps.Policy.Engine.Simulation;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Risk simulation endpoints for Policy Engine and Policy Studio.
|
||||
/// Enhanced with detailed analytics per POLICY-RISK-68-001.
|
||||
/// </summary>
|
||||
internal static class RiskSimulationEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapRiskSimulation(this IEndpointRouteBuilder endpoints)
|
||||
@@ -42,6 +46,28 @@ internal static class RiskSimulationEndpoints
|
||||
.Produces<WhatIfSimulationResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
// Policy Studio specific endpoints per POLICY-RISK-68-001
|
||||
group.MapPost("/studio/analyze", RunStudioAnalysis)
|
||||
.WithName("RunPolicyStudioAnalysis")
|
||||
.WithSummary("Run a detailed analysis for Policy Studio with full breakdown analytics.")
|
||||
.WithDescription("Provides comprehensive breakdown including signal analysis, override tracking, score distributions, and component breakdowns for policy authoring.")
|
||||
.Produces<PolicyStudioAnalysisResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/studio/compare", CompareProfilesWithBreakdown)
|
||||
.WithName("CompareProfilesWithBreakdown")
|
||||
.WithSummary("Compare profiles with full breakdown analytics and trend analysis.")
|
||||
.Produces<PolicyStudioComparisonResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapPost("/studio/preview", PreviewProfileChanges)
|
||||
.WithName("PreviewProfileChanges")
|
||||
.WithSummary("Preview impact of profile changes before committing.")
|
||||
.WithDescription("Simulates findings against both current and proposed profile to show impact.")
|
||||
.Produces<ProfileChangePreviewResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
@@ -355,6 +381,344 @@ internal static class RiskSimulationEndpoints
|
||||
ToHigher: worsened,
|
||||
Unchanged: unchanged));
|
||||
}
|
||||
|
||||
#region Policy Studio Endpoints (POLICY-RISK-68-001)
|
||||
|
||||
private static IResult RunStudioAnalysis(
|
||||
HttpContext context,
|
||||
[FromBody] PolicyStudioAnalysisRequest request,
|
||||
RiskSimulationService simulationService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.ProfileId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "ProfileId is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
if (request.Findings == null || request.Findings.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "At least one finding is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var breakdownOptions = request.BreakdownOptions ?? RiskSimulationBreakdownOptions.Default;
|
||||
var result = simulationService.SimulateWithBreakdown(
|
||||
new RiskSimulationRequest(
|
||||
ProfileId: request.ProfileId,
|
||||
ProfileVersion: request.ProfileVersion,
|
||||
Findings: request.Findings,
|
||||
IncludeContributions: true,
|
||||
IncludeDistribution: true,
|
||||
Mode: SimulationMode.Full),
|
||||
breakdownOptions);
|
||||
|
||||
return Results.Ok(new PolicyStudioAnalysisResponse(
|
||||
Result: result.Result,
|
||||
Breakdown: result.Breakdown,
|
||||
TotalExecutionTimeMs: result.TotalExecutionTimeMs));
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("not found"))
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Profile not found",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("Breakdown service"))
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Service unavailable",
|
||||
detail: ex.Message,
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult CompareProfilesWithBreakdown(
|
||||
HttpContext context,
|
||||
[FromBody] PolicyStudioComparisonRequest request,
|
||||
RiskSimulationService simulationService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request == null ||
|
||||
string.IsNullOrWhiteSpace(request.BaseProfileId) ||
|
||||
string.IsNullOrWhiteSpace(request.CompareProfileId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Both BaseProfileId and CompareProfileId are required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
if (request.Findings == null || request.Findings.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "At least one finding is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = simulationService.CompareProfilesWithBreakdown(
|
||||
request.BaseProfileId,
|
||||
request.CompareProfileId,
|
||||
request.Findings,
|
||||
request.BreakdownOptions);
|
||||
|
||||
return Results.Ok(new PolicyStudioComparisonResponse(
|
||||
BaselineResult: result.BaselineResult,
|
||||
CompareResult: result.CompareResult,
|
||||
Breakdown: result.Breakdown,
|
||||
ExecutionTimeMs: result.ExecutionTimeMs));
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("not found"))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Profile not found",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("Breakdown service"))
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Service unavailable",
|
||||
detail: ex.Message,
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult PreviewProfileChanges(
|
||||
HttpContext context,
|
||||
[FromBody] ProfileChangePreviewRequest request,
|
||||
RiskSimulationService simulationService)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.CurrentProfileId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "CurrentProfileId is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ProposedProfileId) &&
|
||||
(request.ProposedWeightChanges == null || request.ProposedWeightChanges.Count == 0) &&
|
||||
(request.ProposedOverrideChanges == null || request.ProposedOverrideChanges.Count == 0))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Either ProposedProfileId or at least one proposed change is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Run simulation against current profile
|
||||
var currentRequest = new RiskSimulationRequest(
|
||||
ProfileId: request.CurrentProfileId,
|
||||
ProfileVersion: request.CurrentProfileVersion,
|
||||
Findings: request.Findings,
|
||||
IncludeContributions: true,
|
||||
IncludeDistribution: true,
|
||||
Mode: SimulationMode.Full);
|
||||
|
||||
var currentResult = simulationService.Simulate(currentRequest);
|
||||
|
||||
RiskSimulationResult proposedResult;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.ProposedProfileId))
|
||||
{
|
||||
// Compare against existing proposed profile
|
||||
var proposedRequest = new RiskSimulationRequest(
|
||||
ProfileId: request.ProposedProfileId,
|
||||
ProfileVersion: request.ProposedProfileVersion,
|
||||
Findings: request.Findings,
|
||||
IncludeContributions: true,
|
||||
IncludeDistribution: true,
|
||||
Mode: SimulationMode.Full);
|
||||
|
||||
proposedResult = simulationService.Simulate(proposedRequest);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Inline changes not yet supported - return preview of current only
|
||||
proposedResult = currentResult;
|
||||
}
|
||||
|
||||
var impactSummary = ComputePreviewImpact(currentResult, proposedResult);
|
||||
|
||||
return Results.Ok(new ProfileChangePreviewResponse(
|
||||
CurrentResult: new ProfileSimulationSummary(
|
||||
currentResult.ProfileId,
|
||||
currentResult.ProfileVersion,
|
||||
currentResult.AggregateMetrics),
|
||||
ProposedResult: new ProfileSimulationSummary(
|
||||
proposedResult.ProfileId,
|
||||
proposedResult.ProfileVersion,
|
||||
proposedResult.AggregateMetrics),
|
||||
Impact: impactSummary,
|
||||
HighImpactFindings: ComputeHighImpactFindings(currentResult, proposedResult)));
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("not found"))
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Profile not found",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static ProfileChangeImpact ComputePreviewImpact(
|
||||
RiskSimulationResult current,
|
||||
RiskSimulationResult proposed)
|
||||
{
|
||||
var currentScores = current.FindingScores.ToDictionary(f => f.FindingId);
|
||||
var proposedScores = proposed.FindingScores.ToDictionary(f => f.FindingId);
|
||||
|
||||
var improved = 0;
|
||||
var worsened = 0;
|
||||
var unchanged = 0;
|
||||
var severityEscalations = 0;
|
||||
var severityDeescalations = 0;
|
||||
var actionChanges = 0;
|
||||
|
||||
foreach (var (findingId, currentScore) in currentScores)
|
||||
{
|
||||
if (!proposedScores.TryGetValue(findingId, out var proposedScore))
|
||||
continue;
|
||||
|
||||
var scoreDelta = proposedScore.NormalizedScore - currentScore.NormalizedScore;
|
||||
if (Math.Abs(scoreDelta) < 1.0)
|
||||
unchanged++;
|
||||
else if (scoreDelta < 0)
|
||||
improved++;
|
||||
else
|
||||
worsened++;
|
||||
|
||||
if (proposedScore.Severity > currentScore.Severity)
|
||||
severityEscalations++;
|
||||
else if (proposedScore.Severity < currentScore.Severity)
|
||||
severityDeescalations++;
|
||||
|
||||
if (proposedScore.RecommendedAction != currentScore.RecommendedAction)
|
||||
actionChanges++;
|
||||
}
|
||||
|
||||
return new ProfileChangeImpact(
|
||||
FindingsImproved: improved,
|
||||
FindingsWorsened: worsened,
|
||||
FindingsUnchanged: unchanged,
|
||||
SeverityEscalations: severityEscalations,
|
||||
SeverityDeescalations: severityDeescalations,
|
||||
ActionChanges: actionChanges,
|
||||
MeanScoreDelta: proposed.AggregateMetrics.MeanScore - current.AggregateMetrics.MeanScore,
|
||||
CriticalCountDelta: proposed.AggregateMetrics.CriticalCount - current.AggregateMetrics.CriticalCount,
|
||||
HighCountDelta: proposed.AggregateMetrics.HighCount - current.AggregateMetrics.HighCount);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<HighImpactFindingPreview> ComputeHighImpactFindings(
|
||||
RiskSimulationResult current,
|
||||
RiskSimulationResult proposed)
|
||||
{
|
||||
var currentScores = current.FindingScores.ToDictionary(f => f.FindingId);
|
||||
var proposedScores = proposed.FindingScores.ToDictionary(f => f.FindingId);
|
||||
|
||||
var highImpact = new List<HighImpactFindingPreview>();
|
||||
|
||||
foreach (var (findingId, currentScore) in currentScores)
|
||||
{
|
||||
if (!proposedScores.TryGetValue(findingId, out var proposedScore))
|
||||
continue;
|
||||
|
||||
var scoreDelta = Math.Abs(proposedScore.NormalizedScore - currentScore.NormalizedScore);
|
||||
var severityChanged = proposedScore.Severity != currentScore.Severity;
|
||||
var actionChanged = proposedScore.RecommendedAction != currentScore.RecommendedAction;
|
||||
|
||||
if (scoreDelta > 10 || severityChanged || actionChanged)
|
||||
{
|
||||
highImpact.Add(new HighImpactFindingPreview(
|
||||
FindingId: findingId,
|
||||
CurrentScore: currentScore.NormalizedScore,
|
||||
ProposedScore: proposedScore.NormalizedScore,
|
||||
ScoreDelta: proposedScore.NormalizedScore - currentScore.NormalizedScore,
|
||||
CurrentSeverity: currentScore.Severity.ToString(),
|
||||
ProposedSeverity: proposedScore.Severity.ToString(),
|
||||
CurrentAction: currentScore.RecommendedAction.ToString(),
|
||||
ProposedAction: proposedScore.RecommendedAction.ToString(),
|
||||
ImpactReason: DetermineImpactReason(currentScore, proposedScore)));
|
||||
}
|
||||
}
|
||||
|
||||
return highImpact
|
||||
.OrderByDescending(f => Math.Abs(f.ScoreDelta))
|
||||
.Take(20)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string DetermineImpactReason(FindingScore current, FindingScore proposed)
|
||||
{
|
||||
var reasons = new List<string>();
|
||||
|
||||
if (proposed.Severity != current.Severity)
|
||||
{
|
||||
reasons.Add($"Severity {(proposed.Severity > current.Severity ? "escalated" : "deescalated")} from {current.Severity} to {proposed.Severity}");
|
||||
}
|
||||
|
||||
if (proposed.RecommendedAction != current.RecommendedAction)
|
||||
{
|
||||
reasons.Add($"Action changed from {current.RecommendedAction} to {proposed.RecommendedAction}");
|
||||
}
|
||||
|
||||
var scoreDelta = proposed.NormalizedScore - current.NormalizedScore;
|
||||
if (Math.Abs(scoreDelta) > 10)
|
||||
{
|
||||
reasons.Add($"Score {(scoreDelta > 0 ? "increased" : "decreased")} by {Math.Abs(scoreDelta):F1} points");
|
||||
}
|
||||
|
||||
return reasons.Count > 0 ? string.Join("; ", reasons) : "Significant score change";
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Request/Response DTOs
|
||||
@@ -433,3 +797,73 @@ internal sealed record SeverityShifts(
|
||||
int Unchanged);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Policy Studio DTOs (POLICY-RISK-68-001)
|
||||
|
||||
internal sealed record PolicyStudioAnalysisRequest(
|
||||
string ProfileId,
|
||||
string? ProfileVersion,
|
||||
IReadOnlyList<SimulationFinding> Findings,
|
||||
RiskSimulationBreakdownOptions? BreakdownOptions = null);
|
||||
|
||||
internal sealed record PolicyStudioAnalysisResponse(
|
||||
RiskSimulationResult Result,
|
||||
RiskSimulationBreakdown Breakdown,
|
||||
double TotalExecutionTimeMs);
|
||||
|
||||
internal sealed record PolicyStudioComparisonRequest(
|
||||
string BaseProfileId,
|
||||
string CompareProfileId,
|
||||
IReadOnlyList<SimulationFinding> Findings,
|
||||
RiskSimulationBreakdownOptions? BreakdownOptions = null);
|
||||
|
||||
internal sealed record PolicyStudioComparisonResponse(
|
||||
RiskSimulationResult BaselineResult,
|
||||
RiskSimulationResult CompareResult,
|
||||
RiskSimulationBreakdown Breakdown,
|
||||
double ExecutionTimeMs);
|
||||
|
||||
internal sealed record ProfileChangePreviewRequest(
|
||||
string CurrentProfileId,
|
||||
string? CurrentProfileVersion,
|
||||
string? ProposedProfileId,
|
||||
string? ProposedProfileVersion,
|
||||
IReadOnlyList<SimulationFinding> Findings,
|
||||
IReadOnlyDictionary<string, double>? ProposedWeightChanges = null,
|
||||
IReadOnlyList<ProposedOverrideChange>? ProposedOverrideChanges = null);
|
||||
|
||||
internal sealed record ProposedOverrideChange(
|
||||
string OverrideType,
|
||||
Dictionary<string, object> When,
|
||||
object Value,
|
||||
string? Reason = null);
|
||||
|
||||
internal sealed record ProfileChangePreviewResponse(
|
||||
ProfileSimulationSummary CurrentResult,
|
||||
ProfileSimulationSummary ProposedResult,
|
||||
ProfileChangeImpact Impact,
|
||||
IReadOnlyList<HighImpactFindingPreview> HighImpactFindings);
|
||||
|
||||
internal sealed record ProfileChangeImpact(
|
||||
int FindingsImproved,
|
||||
int FindingsWorsened,
|
||||
int FindingsUnchanged,
|
||||
int SeverityEscalations,
|
||||
int SeverityDeescalations,
|
||||
int ActionChanges,
|
||||
double MeanScoreDelta,
|
||||
int CriticalCountDelta,
|
||||
int HighCountDelta);
|
||||
|
||||
internal sealed record HighImpactFindingPreview(
|
||||
string FindingId,
|
||||
double CurrentScore,
|
||||
double ProposedScore,
|
||||
double ScoreDelta,
|
||||
string CurrentSeverity,
|
||||
string ProposedSeverity,
|
||||
string CurrentAction,
|
||||
string ProposedAction,
|
||||
string ImpactReason);
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.AirGap;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for sealed-mode operations per CONTRACT-SEALED-MODE-004.
|
||||
/// </summary>
|
||||
public static class SealedModeEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapSealedMode(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/system/airgap");
|
||||
|
||||
group.MapPost("/seal", SealAsync)
|
||||
.WithName("AirGap.Seal")
|
||||
.WithDescription("Seal the environment")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", "airgap:seal"))
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.ProducesProblem(StatusCodes.Status500InternalServerError);
|
||||
|
||||
group.MapPost("/unseal", UnsealAsync)
|
||||
.WithName("AirGap.Unseal")
|
||||
.WithDescription("Unseal the environment")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", "airgap:seal"))
|
||||
.ProducesProblem(StatusCodes.Status500InternalServerError);
|
||||
|
||||
group.MapGet("/status", GetStatusAsync)
|
||||
.WithName("AirGap.GetStatus")
|
||||
.WithDescription("Get sealed-mode status")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", "airgap:status:read"));
|
||||
|
||||
group.MapPost("/verify", VerifyBundleAsync)
|
||||
.WithName("AirGap.VerifyBundle")
|
||||
.WithDescription("Verify a bundle against trust roots")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", "airgap:verify"))
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> SealAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromBody] SealRequest request,
|
||||
ISealedModeService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
tenantId = "default";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var response = await service.SealAsync(tenantId, request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (SealedModeException ex)
|
||||
{
|
||||
return SealedModeResultHelper.ToProblem(ex);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return SealedModeResultHelper.ToProblem(
|
||||
SealedModeErrorCodes.SealFailed,
|
||||
ex.Message,
|
||||
"Ensure all required parameters are provided");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return SealedModeResultHelper.ToProblem(
|
||||
SealedModeErrorCodes.SealFailed,
|
||||
$"Seal operation failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> UnsealAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
ISealedModeService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
tenantId = "default";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var response = await service.UnsealAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (SealedModeException ex)
|
||||
{
|
||||
return SealedModeResultHelper.ToProblem(ex);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return SealedModeResultHelper.ToProblem(
|
||||
SealedModeErrorCodes.UnsealFailed,
|
||||
$"Unseal operation failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetStatusAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
ISealedModeService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
tenantId = "default";
|
||||
}
|
||||
|
||||
var status = await service.GetStatusAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(status);
|
||||
}
|
||||
|
||||
private static async Task<IResult> VerifyBundleAsync(
|
||||
[FromBody] BundleVerifyRequest request,
|
||||
ISealedModeService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await service.VerifyBundleAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Return problem details if verification failed
|
||||
if (!response.Valid && response.VerificationResult.Error is not null)
|
||||
{
|
||||
return SealedModeResultHelper.ToProblem(
|
||||
SealedModeErrorCodes.SignatureInvalid,
|
||||
response.VerificationResult.Error,
|
||||
"Verify bundle integrity and trust root configuration",
|
||||
422);
|
||||
}
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (SealedModeException ex)
|
||||
{
|
||||
return SealedModeResultHelper.ToProblem(ex);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return SealedModeResultHelper.ToProblem(
|
||||
SealedModeErrorCodes.BundleInvalid,
|
||||
ex.Message,
|
||||
"Ensure bundle path is valid and accessible");
|
||||
}
|
||||
catch (FileNotFoundException ex)
|
||||
{
|
||||
return SealedModeResultHelper.ToProblem(
|
||||
SealedModeErrorCodes.BundleInvalid,
|
||||
$"Bundle file not found: {ex.FileName ?? ex.Message}",
|
||||
"Verify the bundle path is correct");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.AirGap;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for staleness signaling and fallback status per CONTRACT-SEALED-MODE-004.
|
||||
/// </summary>
|
||||
public static class StalenessEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapStalenessSignaling(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/system/airgap/staleness");
|
||||
|
||||
group.MapGet("/status", GetStalenessStatusAsync)
|
||||
.WithName("AirGap.GetStalenessStatus")
|
||||
.WithDescription("Get staleness signal status for health monitoring");
|
||||
|
||||
group.MapGet("/fallback", GetFallbackStatusAsync)
|
||||
.WithName("AirGap.GetFallbackStatus")
|
||||
.WithDescription("Get fallback mode status and configuration");
|
||||
|
||||
group.MapPost("/evaluate", EvaluateStalenessAsync)
|
||||
.WithName("AirGap.EvaluateStaleness")
|
||||
.WithDescription("Trigger staleness evaluation and signaling")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", "airgap:status:read"));
|
||||
|
||||
group.MapPost("/recover", SignalRecoveryAsync)
|
||||
.WithName("AirGap.SignalRecovery")
|
||||
.WithDescription("Signal staleness recovery after time anchor refresh")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", "airgap:seal"));
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetStalenessStatusAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
IStalenessSignalingService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
tenantId = "default";
|
||||
}
|
||||
|
||||
var status = await service.GetSignalStatusAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Return different status codes based on health
|
||||
if (status.IsBreach)
|
||||
{
|
||||
return Results.Json(status, statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
if (status.HasWarning)
|
||||
{
|
||||
// Return 200 but with warning headers
|
||||
return Results.Ok(status);
|
||||
}
|
||||
|
||||
return Results.Ok(status);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetFallbackStatusAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
IStalenessSignalingService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
tenantId = "default";
|
||||
}
|
||||
|
||||
var config = await service.GetFallbackConfigurationAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var isActive = await service.IsFallbackActiveAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
fallbackActive = isActive,
|
||||
configuration = config
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> EvaluateStalenessAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
IStalenessSignalingService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
tenantId = "default";
|
||||
}
|
||||
|
||||
await service.EvaluateAndSignalAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var status = await service.GetSignalStatusAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
evaluated = true,
|
||||
status
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> SignalRecoveryAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
IStalenessSignalingService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
tenantId = "default";
|
||||
}
|
||||
|
||||
await service.SignalRecoveryAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
recovered = true,
|
||||
tenantId
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Engine.Attestation;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Editor endpoints for verification policy management per CONTRACT-VERIFICATION-POLICY-006.
|
||||
/// </summary>
|
||||
public static class VerificationPolicyEditorEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapVerificationPolicyEditor(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/api/v1/attestor/policies/editor")
|
||||
.WithTags("Verification Policy Editor");
|
||||
|
||||
group.MapGet("/metadata", GetEditorMetadata)
|
||||
.WithName("Attestor.GetEditorMetadata")
|
||||
.WithSummary("Get editor metadata for verification policy forms")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
|
||||
.Produces<VerificationPolicyEditorMetadata>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapPost("/validate", ValidatePolicyAsync)
|
||||
.WithName("Attestor.ValidatePolicy")
|
||||
.WithSummary("Validate a verification policy without persisting")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
|
||||
.Produces<ValidatePolicyResponse>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapGet("/{policyId}", GetPolicyEditorViewAsync)
|
||||
.WithName("Attestor.GetPolicyEditorView")
|
||||
.WithSummary("Get a verification policy with editor metadata")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
|
||||
.Produces<VerificationPolicyEditorView>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/clone", ClonePolicyAsync)
|
||||
.WithName("Attestor.ClonePolicy")
|
||||
.WithSummary("Clone a verification policy")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyWrite))
|
||||
.Produces<VerificationPolicy>(StatusCodes.Status201Created)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status409Conflict);
|
||||
|
||||
group.MapPost("/compare", ComparePoliciesAsync)
|
||||
.WithName("Attestor.ComparePolicies")
|
||||
.WithSummary("Compare two verification policies")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
|
||||
.Produces<ComparePoliciesResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static IResult GetEditorMetadata()
|
||||
{
|
||||
var metadata = VerificationPolicyEditorMetadataProvider.GetMetadata();
|
||||
return Results.Ok(metadata);
|
||||
}
|
||||
|
||||
private static IResult ValidatePolicyAsync(
|
||||
[FromBody] ValidatePolicyRequest request,
|
||||
VerificationPolicyValidator validator)
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
return Results.Ok(new ValidatePolicyResponse(
|
||||
Valid: false,
|
||||
Errors: [new VerificationPolicyValidationError("ERR_VP_000", "request", "Request body is required.")],
|
||||
Warnings: [],
|
||||
Suggestions: []));
|
||||
}
|
||||
|
||||
// Convert to CreateVerificationPolicyRequest for validation
|
||||
var createRequest = new CreateVerificationPolicyRequest(
|
||||
PolicyId: request.PolicyId ?? string.Empty,
|
||||
Version: request.Version ?? "1.0.0",
|
||||
Description: request.Description,
|
||||
TenantScope: request.TenantScope,
|
||||
PredicateTypes: request.PredicateTypes ?? [],
|
||||
SignerRequirements: request.SignerRequirements,
|
||||
ValidityWindow: request.ValidityWindow,
|
||||
Metadata: request.Metadata);
|
||||
|
||||
var validation = validator.ValidateCreate(createRequest);
|
||||
|
||||
var errors = validation.Errors
|
||||
.Where(e => e.Severity == ValidationSeverity.Error)
|
||||
.ToList();
|
||||
|
||||
var warnings = validation.Errors
|
||||
.Where(e => e.Severity == ValidationSeverity.Warning)
|
||||
.ToList();
|
||||
|
||||
var suggestions = VerificationPolicyEditorMetadataProvider.GenerateSuggestions(createRequest, validation);
|
||||
|
||||
return Results.Ok(new ValidatePolicyResponse(
|
||||
Valid: errors.Count == 0,
|
||||
Errors: errors,
|
||||
Warnings: warnings,
|
||||
Suggestions: suggestions));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetPolicyEditorViewAsync(
|
||||
[FromRoute] string policyId,
|
||||
IVerificationPolicyStore store,
|
||||
VerificationPolicyValidator validator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var policy = await store.GetAsync(policyId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (policy == null)
|
||||
{
|
||||
return Results.NotFound(CreateProblem(
|
||||
"Policy not found",
|
||||
$"Policy '{policyId}' was not found.",
|
||||
"ERR_ATTEST_005"));
|
||||
}
|
||||
|
||||
// Re-validate current policy state
|
||||
var updateRequest = new UpdateVerificationPolicyRequest(
|
||||
Version: policy.Version,
|
||||
Description: policy.Description,
|
||||
PredicateTypes: policy.PredicateTypes,
|
||||
SignerRequirements: policy.SignerRequirements,
|
||||
ValidityWindow: policy.ValidityWindow,
|
||||
Metadata: policy.Metadata);
|
||||
|
||||
var validation = validator.ValidateUpdate(updateRequest);
|
||||
|
||||
// Generate suggestions
|
||||
var createRequest = new CreateVerificationPolicyRequest(
|
||||
PolicyId: policy.PolicyId,
|
||||
Version: policy.Version,
|
||||
Description: policy.Description,
|
||||
TenantScope: policy.TenantScope,
|
||||
PredicateTypes: policy.PredicateTypes,
|
||||
SignerRequirements: policy.SignerRequirements,
|
||||
ValidityWindow: policy.ValidityWindow,
|
||||
Metadata: policy.Metadata);
|
||||
|
||||
var suggestions = VerificationPolicyEditorMetadataProvider.GenerateSuggestions(createRequest, validation);
|
||||
|
||||
// TODO: Check if policy is referenced by attestations
|
||||
var isReferenced = false;
|
||||
|
||||
var view = new VerificationPolicyEditorView(
|
||||
Policy: policy,
|
||||
Validation: validation,
|
||||
Suggestions: suggestions,
|
||||
CanDelete: !isReferenced,
|
||||
IsReferenced: isReferenced);
|
||||
|
||||
return Results.Ok(view);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ClonePolicyAsync(
|
||||
[FromBody] ClonePolicyRequest request,
|
||||
IVerificationPolicyStore store,
|
||||
VerificationPolicyValidator validator,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
return Results.BadRequest(CreateProblem(
|
||||
"Invalid request",
|
||||
"Request body is required.",
|
||||
"ERR_ATTEST_001"));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.SourcePolicyId))
|
||||
{
|
||||
return Results.BadRequest(CreateProblem(
|
||||
"Invalid request",
|
||||
"Source policy ID is required.",
|
||||
"ERR_ATTEST_006"));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.NewPolicyId))
|
||||
{
|
||||
return Results.BadRequest(CreateProblem(
|
||||
"Invalid request",
|
||||
"New policy ID is required.",
|
||||
"ERR_ATTEST_007"));
|
||||
}
|
||||
|
||||
var sourcePolicy = await store.GetAsync(request.SourcePolicyId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (sourcePolicy == null)
|
||||
{
|
||||
return Results.NotFound(CreateProblem(
|
||||
"Source policy not found",
|
||||
$"Policy '{request.SourcePolicyId}' was not found.",
|
||||
"ERR_ATTEST_005"));
|
||||
}
|
||||
|
||||
if (await store.ExistsAsync(request.NewPolicyId, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return Results.Conflict(CreateProblem(
|
||||
"Policy exists",
|
||||
$"Policy '{request.NewPolicyId}' already exists.",
|
||||
"ERR_ATTEST_004"));
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var clonedPolicy = new VerificationPolicy(
|
||||
PolicyId: request.NewPolicyId,
|
||||
Version: request.NewVersion ?? sourcePolicy.Version,
|
||||
Description: sourcePolicy.Description != null
|
||||
? $"Cloned from {request.SourcePolicyId}: {sourcePolicy.Description}"
|
||||
: $"Cloned from {request.SourcePolicyId}",
|
||||
TenantScope: sourcePolicy.TenantScope,
|
||||
PredicateTypes: sourcePolicy.PredicateTypes,
|
||||
SignerRequirements: sourcePolicy.SignerRequirements,
|
||||
ValidityWindow: sourcePolicy.ValidityWindow,
|
||||
Metadata: sourcePolicy.Metadata,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now);
|
||||
|
||||
await store.CreateAsync(clonedPolicy, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created(
|
||||
$"/api/v1/attestor/policies/{clonedPolicy.PolicyId}",
|
||||
clonedPolicy);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ComparePoliciesAsync(
|
||||
[FromBody] ComparePoliciesRequest request,
|
||||
IVerificationPolicyStore store,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
return Results.BadRequest(CreateProblem(
|
||||
"Invalid request",
|
||||
"Request body is required.",
|
||||
"ERR_ATTEST_001"));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.PolicyIdA))
|
||||
{
|
||||
return Results.BadRequest(CreateProblem(
|
||||
"Invalid request",
|
||||
"Policy ID A is required.",
|
||||
"ERR_ATTEST_008"));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.PolicyIdB))
|
||||
{
|
||||
return Results.BadRequest(CreateProblem(
|
||||
"Invalid request",
|
||||
"Policy ID B is required.",
|
||||
"ERR_ATTEST_009"));
|
||||
}
|
||||
|
||||
var policyA = await store.GetAsync(request.PolicyIdA, cancellationToken).ConfigureAwait(false);
|
||||
var policyB = await store.GetAsync(request.PolicyIdB, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (policyA == null)
|
||||
{
|
||||
return Results.NotFound(CreateProblem(
|
||||
"Policy not found",
|
||||
$"Policy '{request.PolicyIdA}' was not found.",
|
||||
"ERR_ATTEST_005"));
|
||||
}
|
||||
|
||||
if (policyB == null)
|
||||
{
|
||||
return Results.NotFound(CreateProblem(
|
||||
"Policy not found",
|
||||
$"Policy '{request.PolicyIdB}' was not found.",
|
||||
"ERR_ATTEST_005"));
|
||||
}
|
||||
|
||||
var differences = ComputeDifferences(policyA, policyB);
|
||||
|
||||
return Results.Ok(new ComparePoliciesResponse(
|
||||
PolicyA: policyA,
|
||||
PolicyB: policyB,
|
||||
Differences: differences));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PolicyDifference> ComputeDifferences(VerificationPolicy a, VerificationPolicy b)
|
||||
{
|
||||
var differences = new List<PolicyDifference>();
|
||||
|
||||
if (a.Version != b.Version)
|
||||
{
|
||||
differences.Add(new PolicyDifference("version", a.Version, b.Version, DifferenceType.Modified));
|
||||
}
|
||||
|
||||
if (a.Description != b.Description)
|
||||
{
|
||||
differences.Add(new PolicyDifference("description", a.Description, b.Description, DifferenceType.Modified));
|
||||
}
|
||||
|
||||
if (a.TenantScope != b.TenantScope)
|
||||
{
|
||||
differences.Add(new PolicyDifference("tenant_scope", a.TenantScope, b.TenantScope, DifferenceType.Modified));
|
||||
}
|
||||
|
||||
// Compare predicate types
|
||||
var predicateTypesA = a.PredicateTypes.ToHashSet();
|
||||
var predicateTypesB = b.PredicateTypes.ToHashSet();
|
||||
|
||||
foreach (var added in predicateTypesB.Except(predicateTypesA))
|
||||
{
|
||||
differences.Add(new PolicyDifference("predicate_types", null, added, DifferenceType.Added));
|
||||
}
|
||||
|
||||
foreach (var removed in predicateTypesA.Except(predicateTypesB))
|
||||
{
|
||||
differences.Add(new PolicyDifference("predicate_types", removed, null, DifferenceType.Removed));
|
||||
}
|
||||
|
||||
// Compare signer requirements
|
||||
if (a.SignerRequirements.MinimumSignatures != b.SignerRequirements.MinimumSignatures)
|
||||
{
|
||||
differences.Add(new PolicyDifference(
|
||||
"signer_requirements.minimum_signatures",
|
||||
a.SignerRequirements.MinimumSignatures,
|
||||
b.SignerRequirements.MinimumSignatures,
|
||||
DifferenceType.Modified));
|
||||
}
|
||||
|
||||
if (a.SignerRequirements.RequireRekor != b.SignerRequirements.RequireRekor)
|
||||
{
|
||||
differences.Add(new PolicyDifference(
|
||||
"signer_requirements.require_rekor",
|
||||
a.SignerRequirements.RequireRekor,
|
||||
b.SignerRequirements.RequireRekor,
|
||||
DifferenceType.Modified));
|
||||
}
|
||||
|
||||
// Compare fingerprints
|
||||
var fingerprintsA = a.SignerRequirements.TrustedKeyFingerprints.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var fingerprintsB = b.SignerRequirements.TrustedKeyFingerprints.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var added in fingerprintsB.Except(fingerprintsA))
|
||||
{
|
||||
differences.Add(new PolicyDifference("signer_requirements.trusted_key_fingerprints", null, added, DifferenceType.Added));
|
||||
}
|
||||
|
||||
foreach (var removed in fingerprintsA.Except(fingerprintsB))
|
||||
{
|
||||
differences.Add(new PolicyDifference("signer_requirements.trusted_key_fingerprints", removed, null, DifferenceType.Removed));
|
||||
}
|
||||
|
||||
// Compare algorithms
|
||||
var algorithmsA = (a.SignerRequirements.Algorithms ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var algorithmsB = (b.SignerRequirements.Algorithms ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var added in algorithmsB.Except(algorithmsA))
|
||||
{
|
||||
differences.Add(new PolicyDifference("signer_requirements.algorithms", null, added, DifferenceType.Added));
|
||||
}
|
||||
|
||||
foreach (var removed in algorithmsA.Except(algorithmsB))
|
||||
{
|
||||
differences.Add(new PolicyDifference("signer_requirements.algorithms", removed, null, DifferenceType.Removed));
|
||||
}
|
||||
|
||||
// Compare validity window
|
||||
var validityA = a.ValidityWindow;
|
||||
var validityB = b.ValidityWindow;
|
||||
|
||||
if (validityA == null && validityB != null)
|
||||
{
|
||||
differences.Add(new PolicyDifference("validity_window", null, validityB, DifferenceType.Added));
|
||||
}
|
||||
else if (validityA != null && validityB == null)
|
||||
{
|
||||
differences.Add(new PolicyDifference("validity_window", validityA, null, DifferenceType.Removed));
|
||||
}
|
||||
else if (validityA != null && validityB != null)
|
||||
{
|
||||
if (validityA.NotBefore != validityB.NotBefore)
|
||||
{
|
||||
differences.Add(new PolicyDifference("validity_window.not_before", validityA.NotBefore, validityB.NotBefore, DifferenceType.Modified));
|
||||
}
|
||||
|
||||
if (validityA.NotAfter != validityB.NotAfter)
|
||||
{
|
||||
differences.Add(new PolicyDifference("validity_window.not_after", validityA.NotAfter, validityB.NotAfter, DifferenceType.Modified));
|
||||
}
|
||||
|
||||
if (validityA.MaxAttestationAge != validityB.MaxAttestationAge)
|
||||
{
|
||||
differences.Add(new PolicyDifference("validity_window.max_attestation_age", validityA.MaxAttestationAge, validityB.MaxAttestationAge, DifferenceType.Modified));
|
||||
}
|
||||
}
|
||||
|
||||
return differences;
|
||||
}
|
||||
|
||||
private static ProblemDetails CreateProblem(string title, string detail, string? errorCode = null)
|
||||
{
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Title = title,
|
||||
Detail = detail,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errorCode))
|
||||
{
|
||||
problem.Extensions["error_code"] = errorCode;
|
||||
}
|
||||
|
||||
return problem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Engine.Attestation;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for verification policy management per CONTRACT-VERIFICATION-POLICY-006.
|
||||
/// </summary>
|
||||
public static class VerificationPolicyEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapVerificationPolicies(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/api/v1/attestor/policies")
|
||||
.WithTags("Verification Policies");
|
||||
|
||||
group.MapPost("/", CreatePolicyAsync)
|
||||
.WithName("Attestor.CreatePolicy")
|
||||
.WithSummary("Create a new verification policy")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyWrite))
|
||||
.Produces<VerificationPolicy>(StatusCodes.Status201Created)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status409Conflict);
|
||||
|
||||
group.MapGet("/{policyId}", GetPolicyAsync)
|
||||
.WithName("Attestor.GetPolicy")
|
||||
.WithSummary("Get a verification policy by ID")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
|
||||
.Produces<VerificationPolicy>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/", ListPoliciesAsync)
|
||||
.WithName("Attestor.ListPolicies")
|
||||
.WithSummary("List verification policies")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
|
||||
.Produces<VerificationPolicyListResponse>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapPut("/{policyId}", UpdatePolicyAsync)
|
||||
.WithName("Attestor.UpdatePolicy")
|
||||
.WithSummary("Update a verification policy")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyWrite))
|
||||
.Produces<VerificationPolicy>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapDelete("/{policyId}", DeletePolicyAsync)
|
||||
.WithName("Attestor.DeletePolicy")
|
||||
.WithSummary("Delete a verification policy")
|
||||
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyWrite))
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreatePolicyAsync(
|
||||
[FromBody] CreateVerificationPolicyRequest request,
|
||||
IVerificationPolicyStore store,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
return Results.BadRequest(CreateProblem(
|
||||
"Invalid request",
|
||||
"Request body is required.",
|
||||
"ERR_ATTEST_001"));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.PolicyId))
|
||||
{
|
||||
return Results.BadRequest(CreateProblem(
|
||||
"Invalid request",
|
||||
"Policy ID is required.",
|
||||
"ERR_ATTEST_002"));
|
||||
}
|
||||
|
||||
if (request.PredicateTypes == null || request.PredicateTypes.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(CreateProblem(
|
||||
"Invalid request",
|
||||
"At least one predicate type is required.",
|
||||
"ERR_ATTEST_003"));
|
||||
}
|
||||
|
||||
if (await store.ExistsAsync(request.PolicyId, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return Results.Conflict(CreateProblem(
|
||||
"Policy exists",
|
||||
$"Policy '{request.PolicyId}' already exists.",
|
||||
"ERR_ATTEST_004"));
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var policy = new VerificationPolicy(
|
||||
PolicyId: request.PolicyId,
|
||||
Version: request.Version ?? "1.0.0",
|
||||
Description: request.Description,
|
||||
TenantScope: request.TenantScope ?? "*",
|
||||
PredicateTypes: request.PredicateTypes,
|
||||
SignerRequirements: request.SignerRequirements ?? SignerRequirements.Default,
|
||||
ValidityWindow: request.ValidityWindow,
|
||||
Metadata: request.Metadata,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now);
|
||||
|
||||
await store.CreateAsync(policy, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created(
|
||||
$"/api/v1/attestor/policies/{policy.PolicyId}",
|
||||
policy);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetPolicyAsync(
|
||||
[FromRoute] string policyId,
|
||||
IVerificationPolicyStore store,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var policy = await store.GetAsync(policyId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (policy == null)
|
||||
{
|
||||
return Results.NotFound(CreateProblem(
|
||||
"Policy not found",
|
||||
$"Policy '{policyId}' was not found.",
|
||||
"ERR_ATTEST_005"));
|
||||
}
|
||||
|
||||
return Results.Ok(policy);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListPoliciesAsync(
|
||||
[FromQuery] string? tenantScope,
|
||||
IVerificationPolicyStore store,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var policies = await store.ListAsync(tenantScope, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new VerificationPolicyListResponse(
|
||||
Policies: policies,
|
||||
Total: policies.Count));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdatePolicyAsync(
|
||||
[FromRoute] string policyId,
|
||||
[FromBody] UpdateVerificationPolicyRequest request,
|
||||
IVerificationPolicyStore store,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
return Results.BadRequest(CreateProblem(
|
||||
"Invalid request",
|
||||
"Request body is required.",
|
||||
"ERR_ATTEST_001"));
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var updated = await store.UpdateAsync(
|
||||
policyId,
|
||||
existing => existing with
|
||||
{
|
||||
Version = request.Version ?? existing.Version,
|
||||
Description = request.Description ?? existing.Description,
|
||||
PredicateTypes = request.PredicateTypes ?? existing.PredicateTypes,
|
||||
SignerRequirements = request.SignerRequirements ?? existing.SignerRequirements,
|
||||
ValidityWindow = request.ValidityWindow ?? existing.ValidityWindow,
|
||||
Metadata = request.Metadata ?? existing.Metadata,
|
||||
UpdatedAt = now
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (updated == null)
|
||||
{
|
||||
return Results.NotFound(CreateProblem(
|
||||
"Policy not found",
|
||||
$"Policy '{policyId}' was not found.",
|
||||
"ERR_ATTEST_005"));
|
||||
}
|
||||
|
||||
return Results.Ok(updated);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeletePolicyAsync(
|
||||
[FromRoute] string policyId,
|
||||
IVerificationPolicyStore store,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var deleted = await store.DeleteAsync(policyId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!deleted)
|
||||
{
|
||||
return Results.NotFound(CreateProblem(
|
||||
"Policy not found",
|
||||
$"Policy '{policyId}' was not found.",
|
||||
"ERR_ATTEST_005"));
|
||||
}
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static ProblemDetails CreateProblem(string title, string detail, string? errorCode = null)
|
||||
{
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Title = title,
|
||||
Detail = detail,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errorCode))
|
||||
{
|
||||
problem.Extensions["error_code"] = errorCode;
|
||||
}
|
||||
|
||||
return problem;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for listing verification policies.
|
||||
/// </summary>
|
||||
public sealed record VerificationPolicyListResponse(
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("policies")] IReadOnlyList<VerificationPolicy> Policies,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("total")] int Total);
|
||||
@@ -126,6 +126,13 @@ builder.Services.AddSingleton<IncidentModeService>();
|
||||
builder.Services.AddSingleton<RiskProfileConfigurationService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.RiskProfile.Lifecycle.RiskProfileLifecycleService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.RiskProfile.Scope.ScopeAttachmentService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.RiskProfile.Scope.EffectivePolicyService>();
|
||||
builder.Services.AddSingleton<IEffectivePolicyAuditor, EffectivePolicyAuditor>(); // CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Attestation.IVerificationPolicyStore, StellaOps.Policy.Engine.Attestation.InMemoryVerificationPolicyStore>(); // CONTRACT-VERIFICATION-POLICY-006
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Attestation.VerificationPolicyValidator>(); // CONTRACT-VERIFICATION-POLICY-006 validation
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Attestation.IAttestationReportStore, StellaOps.Policy.Engine.Attestation.InMemoryAttestationReportStore>(); // CONTRACT-VERIFICATION-POLICY-006 reports
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Attestation.IAttestationReportService, StellaOps.Policy.Engine.Attestation.AttestationReportService>(); // CONTRACT-VERIFICATION-POLICY-006 reports
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.ConsoleSurface.ConsoleAttestationReportService>(); // CONTRACT-VERIFICATION-POLICY-006 Console integration
|
||||
builder.Services.AddSingleton<StellaOps.Policy.RiskProfile.Overrides.OverrideService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Scoring.IRiskScoringJobStore, StellaOps.Policy.Engine.Scoring.InMemoryRiskScoringJobStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Scoring.RiskScoringTriggerService>();
|
||||
@@ -177,6 +184,24 @@ builder.Services.AddSingleton<StellaOps.Policy.Engine.ConsoleExport.ConsoleExpor
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.IPolicyPackBundleStore, StellaOps.Policy.Engine.AirGap.InMemoryPolicyPackBundleStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.PolicyPackBundleImportService>();
|
||||
|
||||
// Sealed-mode services per CONTRACT-SEALED-MODE-004
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.ISealedModeStateStore, StellaOps.Policy.Engine.AirGap.InMemorySealedModeStateStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.ISealedModeService, StellaOps.Policy.Engine.AirGap.SealedModeService>();
|
||||
|
||||
// Staleness signaling services per CONTRACT-SEALED-MODE-004
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.IStalenessEventSink, StellaOps.Policy.Engine.AirGap.LoggingStalenessEventSink>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.IStalenessSignalingService, StellaOps.Policy.Engine.AirGap.StalenessSignalingService>();
|
||||
|
||||
// Air-gap notification services
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.IAirGapNotificationChannel, StellaOps.Policy.Engine.AirGap.LoggingNotificationChannel>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.IAirGapNotificationService, StellaOps.Policy.Engine.AirGap.AirGapNotificationService>();
|
||||
|
||||
// Air-gap risk profile export/import per CONTRACT-MIRROR-BUNDLE-003
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.RiskProfileAirGapExportService>();
|
||||
// Also register as IStalenessEventSink to auto-notify on staleness events
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AirGap.IStalenessEventSink>(sp =>
|
||||
(StellaOps.Policy.Engine.AirGap.AirGapNotificationService)sp.GetRequiredService<StellaOps.Policy.Engine.AirGap.IAirGapNotificationService>());
|
||||
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Snapshots.ISnapshotStore, StellaOps.Policy.Engine.Snapshots.InMemorySnapshotStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Snapshots.SnapshotService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.IViolationEventStore, StellaOps.Policy.Engine.Violations.InMemoryViolationEventStore>();
|
||||
@@ -290,17 +315,27 @@ app.MapBatchContext();
|
||||
app.MapOrchestratorJobs();
|
||||
app.MapPolicyWorker();
|
||||
app.MapLedgerExport();
|
||||
app.MapConsoleExportJobs(); // CONTRACT-EXPORT-BUNDLE-009
|
||||
app.MapPolicyPackBundles(); // CONTRACT-MIRROR-BUNDLE-003
|
||||
app.MapConsoleExportJobs(); // CONTRACT-EXPORT-BUNDLE-009
|
||||
app.MapPolicyPackBundles(); // CONTRACT-MIRROR-BUNDLE-003
|
||||
app.MapSealedMode(); // CONTRACT-SEALED-MODE-004
|
||||
app.MapStalenessSignaling(); // CONTRACT-SEALED-MODE-004 staleness
|
||||
app.MapAirGapNotifications(); // Air-gap notifications
|
||||
app.MapPolicyLint(); // POLICY-AOC-19-001 determinism linting
|
||||
app.MapVerificationPolicies(); // CONTRACT-VERIFICATION-POLICY-006 attestation policies
|
||||
app.MapVerificationPolicyEditor(); // CONTRACT-VERIFICATION-POLICY-006 editor DTOs/validation
|
||||
app.MapAttestationReports(); // CONTRACT-VERIFICATION-POLICY-006 attestation reports
|
||||
app.MapConsoleAttestationReports(); // CONTRACT-VERIFICATION-POLICY-006 Console integration
|
||||
app.MapSnapshots();
|
||||
app.MapViolations();
|
||||
app.MapPolicyDecisions();
|
||||
app.MapRiskProfiles();
|
||||
app.MapRiskProfileSchema();
|
||||
app.MapScopeAttachments();
|
||||
app.MapEffectivePolicies(); // CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008
|
||||
app.MapRiskSimulation();
|
||||
app.MapOverrides();
|
||||
app.MapProfileExport();
|
||||
app.MapRiskProfileAirGap(); // CONTRACT-MIRROR-BUNDLE-003 risk profile air-gap
|
||||
app.MapProfileEvents();
|
||||
|
||||
// Phase 5: Multi-tenant PostgreSQL-backed API endpoints
|
||||
|
||||
@@ -117,6 +117,20 @@ public enum RiskScoringJobStatus
|
||||
/// <summary>
|
||||
/// Result of scoring a single finding.
|
||||
/// </summary>
|
||||
/// <param name="FindingId">Unique identifier for the finding.</param>
|
||||
/// <param name="ProfileId">Risk profile used for scoring.</param>
|
||||
/// <param name="ProfileVersion">Version of the risk profile.</param>
|
||||
/// <param name="RawScore">Raw computed score before normalization.</param>
|
||||
/// <param name="NormalizedScore">
|
||||
/// DEPRECATED: Legacy normalized score (0-1 range). Use <see cref="Severity"/> instead.
|
||||
/// Scheduled for removal in v2.0. See DESIGN-POLICY-NORMALIZED-FIELD-REMOVAL-001.
|
||||
/// </param>
|
||||
/// <param name="Severity">Canonical severity (critical/high/medium/low/info).</param>
|
||||
/// <param name="SignalValues">Input signal values used in scoring.</param>
|
||||
/// <param name="SignalContributions">Contribution of each signal to final score.</param>
|
||||
/// <param name="OverrideApplied">Override rule that was applied, if any.</param>
|
||||
/// <param name="OverrideReason">Reason for the override, if any.</param>
|
||||
/// <param name="ScoredAt">Timestamp when scoring was performed.</param>
|
||||
public sealed record RiskScoringResult(
|
||||
[property: JsonPropertyName("finding_id")] string FindingId,
|
||||
[property: JsonPropertyName("profile_id")] string ProfileId,
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.RiskProfile.Scope;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Audit log interface for effective:write operations per CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008.
|
||||
/// </summary>
|
||||
internal interface IEffectivePolicyAuditor
|
||||
{
|
||||
/// <summary>
|
||||
/// Records an effective policy creation event.
|
||||
/// </summary>
|
||||
void RecordCreated(EffectivePolicy policy, string? actorId);
|
||||
|
||||
/// <summary>
|
||||
/// Records an effective policy update event.
|
||||
/// </summary>
|
||||
void RecordUpdated(EffectivePolicy policy, string? actorId, object? changes);
|
||||
|
||||
/// <summary>
|
||||
/// Records an effective policy deletion event.
|
||||
/// </summary>
|
||||
void RecordDeleted(string effectivePolicyId, string? actorId);
|
||||
|
||||
/// <summary>
|
||||
/// Records a scope attachment event.
|
||||
/// </summary>
|
||||
void RecordScopeAttached(AuthorityScopeAttachment attachment, string? actorId);
|
||||
|
||||
/// <summary>
|
||||
/// Records a scope detachment event.
|
||||
/// </summary>
|
||||
void RecordScopeDetached(string attachmentId, string? actorId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of effective policy auditor.
|
||||
/// Emits structured logs for all effective:write operations per CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008.
|
||||
/// </summary>
|
||||
internal sealed class EffectivePolicyAuditor : IEffectivePolicyAuditor
|
||||
{
|
||||
private readonly ILogger<EffectivePolicyAuditor> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public EffectivePolicyAuditor(
|
||||
ILogger<EffectivePolicyAuditor> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public void RecordCreated(EffectivePolicy policy, string? actorId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
|
||||
var scope = CreateBaseScope("effective_policy.created", actorId);
|
||||
scope["effective_policy_id"] = policy.EffectivePolicyId;
|
||||
scope["tenant_id"] = policy.TenantId;
|
||||
scope["policy_id"] = policy.PolicyId;
|
||||
scope["subject_pattern"] = policy.SubjectPattern;
|
||||
scope["priority"] = policy.Priority;
|
||||
|
||||
if (policy.Scopes is { Count: > 0 })
|
||||
{
|
||||
scope["scopes"] = policy.Scopes;
|
||||
}
|
||||
|
||||
using (_logger.BeginScope(scope))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Effective policy created: {EffectivePolicyId} for pattern {SubjectPattern}",
|
||||
policy.EffectivePolicyId,
|
||||
policy.SubjectPattern);
|
||||
}
|
||||
}
|
||||
|
||||
public void RecordUpdated(EffectivePolicy policy, string? actorId, object? changes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
|
||||
var scope = CreateBaseScope("effective_policy.updated", actorId);
|
||||
scope["effective_policy_id"] = policy.EffectivePolicyId;
|
||||
scope["tenant_id"] = policy.TenantId;
|
||||
|
||||
if (changes is not null)
|
||||
{
|
||||
scope["changes"] = changes;
|
||||
}
|
||||
|
||||
using (_logger.BeginScope(scope))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Effective policy updated: {EffectivePolicyId}",
|
||||
policy.EffectivePolicyId);
|
||||
}
|
||||
}
|
||||
|
||||
public void RecordDeleted(string effectivePolicyId, string? actorId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(effectivePolicyId);
|
||||
|
||||
var scope = CreateBaseScope("effective_policy.deleted", actorId);
|
||||
scope["effective_policy_id"] = effectivePolicyId;
|
||||
|
||||
using (_logger.BeginScope(scope))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Effective policy deleted: {EffectivePolicyId}",
|
||||
effectivePolicyId);
|
||||
}
|
||||
}
|
||||
|
||||
public void RecordScopeAttached(AuthorityScopeAttachment attachment, string? actorId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(attachment);
|
||||
|
||||
var scope = CreateBaseScope("scope_attachment.created", actorId);
|
||||
scope["attachment_id"] = attachment.AttachmentId;
|
||||
scope["effective_policy_id"] = attachment.EffectivePolicyId;
|
||||
scope["scope"] = attachment.Scope;
|
||||
|
||||
if (attachment.Conditions is { Count: > 0 })
|
||||
{
|
||||
scope["conditions"] = attachment.Conditions;
|
||||
}
|
||||
|
||||
using (_logger.BeginScope(scope))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Scope attached: {Scope} to policy {EffectivePolicyId}",
|
||||
attachment.Scope,
|
||||
attachment.EffectivePolicyId);
|
||||
}
|
||||
}
|
||||
|
||||
public void RecordScopeDetached(string attachmentId, string? actorId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(attachmentId);
|
||||
|
||||
var scope = CreateBaseScope("scope_attachment.deleted", actorId);
|
||||
scope["attachment_id"] = attachmentId;
|
||||
|
||||
using (_logger.BeginScope(scope))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Scope detached: {AttachmentId}",
|
||||
attachmentId);
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<string, object?> CreateBaseScope(string eventType, string? actorId)
|
||||
{
|
||||
var scope = new Dictionary<string, object?>
|
||||
{
|
||||
["event"] = eventType,
|
||||
["timestamp"] = _timeProvider.GetUtcNow().ToString("O")
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(actorId))
|
||||
{
|
||||
scope["actor"] = actorId;
|
||||
}
|
||||
|
||||
return scope;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Simulation;
|
||||
|
||||
/// <summary>
|
||||
/// Detailed breakdown of a risk simulation result.
|
||||
/// Per POLICY-RISK-67-003.
|
||||
/// </summary>
|
||||
public sealed record RiskSimulationBreakdown(
|
||||
[property: JsonPropertyName("simulation_id")] string SimulationId,
|
||||
[property: JsonPropertyName("profile_ref")] ProfileReference ProfileRef,
|
||||
[property: JsonPropertyName("signal_analysis")] SignalAnalysis SignalAnalysis,
|
||||
[property: JsonPropertyName("override_analysis")] OverrideAnalysis OverrideAnalysis,
|
||||
[property: JsonPropertyName("score_distribution")] ScoreDistributionAnalysis ScoreDistribution,
|
||||
[property: JsonPropertyName("severity_breakdown")] SeverityBreakdownAnalysis SeverityBreakdown,
|
||||
[property: JsonPropertyName("action_breakdown")] ActionBreakdownAnalysis ActionBreakdown,
|
||||
[property: JsonPropertyName("component_breakdown")] ComponentBreakdownAnalysis? ComponentBreakdown,
|
||||
[property: JsonPropertyName("risk_trends")] RiskTrendAnalysis? RiskTrends,
|
||||
[property: JsonPropertyName("determinism_hash")] string DeterminismHash);
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the risk profile used in simulation.
|
||||
/// </summary>
|
||||
public sealed record ProfileReference(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("hash")] string Hash,
|
||||
[property: JsonPropertyName("description")] string? Description,
|
||||
[property: JsonPropertyName("extends")] string? Extends);
|
||||
|
||||
/// <summary>
|
||||
/// Analysis of signal contributions to risk scores.
|
||||
/// </summary>
|
||||
public sealed record SignalAnalysis(
|
||||
[property: JsonPropertyName("total_signals")] int TotalSignals,
|
||||
[property: JsonPropertyName("signals_used")] int SignalsUsed,
|
||||
[property: JsonPropertyName("signals_missing")] int SignalsMissing,
|
||||
[property: JsonPropertyName("signal_coverage")] double SignalCoverage,
|
||||
[property: JsonPropertyName("signal_stats")] ImmutableArray<SignalStatistics> SignalStats,
|
||||
[property: JsonPropertyName("top_contributors")] ImmutableArray<SignalContributor> TopContributors,
|
||||
[property: JsonPropertyName("missing_signal_impact")] MissingSignalImpact MissingSignalImpact);
|
||||
|
||||
/// <summary>
|
||||
/// Statistics for a single signal across all findings.
|
||||
/// </summary>
|
||||
public sealed record SignalStatistics(
|
||||
[property: JsonPropertyName("signal_name")] string SignalName,
|
||||
[property: JsonPropertyName("signal_type")] string SignalType,
|
||||
[property: JsonPropertyName("weight")] double Weight,
|
||||
[property: JsonPropertyName("findings_with_signal")] int FindingsWithSignal,
|
||||
[property: JsonPropertyName("findings_missing_signal")] int FindingsMissingSignal,
|
||||
[property: JsonPropertyName("coverage_percentage")] double CoveragePercentage,
|
||||
[property: JsonPropertyName("value_distribution")] ValueDistribution? ValueDistribution,
|
||||
[property: JsonPropertyName("total_contribution")] double TotalContribution,
|
||||
[property: JsonPropertyName("avg_contribution")] double AvgContribution);
|
||||
|
||||
/// <summary>
|
||||
/// Distribution of values for a signal.
|
||||
/// </summary>
|
||||
public sealed record ValueDistribution(
|
||||
[property: JsonPropertyName("min")] double? Min,
|
||||
[property: JsonPropertyName("max")] double? Max,
|
||||
[property: JsonPropertyName("mean")] double? Mean,
|
||||
[property: JsonPropertyName("median")] double? Median,
|
||||
[property: JsonPropertyName("std_dev")] double? StdDev,
|
||||
[property: JsonPropertyName("histogram")] ImmutableArray<HistogramBucket>? Histogram);
|
||||
|
||||
/// <summary>
|
||||
/// Histogram bucket for value distribution.
|
||||
/// </summary>
|
||||
public sealed record HistogramBucket(
|
||||
[property: JsonPropertyName("range_min")] double RangeMin,
|
||||
[property: JsonPropertyName("range_max")] double RangeMax,
|
||||
[property: JsonPropertyName("count")] int Count,
|
||||
[property: JsonPropertyName("percentage")] double Percentage);
|
||||
|
||||
/// <summary>
|
||||
/// A signal that significantly contributed to risk scores.
|
||||
/// </summary>
|
||||
public sealed record SignalContributor(
|
||||
[property: JsonPropertyName("signal_name")] string SignalName,
|
||||
[property: JsonPropertyName("total_contribution")] double TotalContribution,
|
||||
[property: JsonPropertyName("contribution_percentage")] double ContributionPercentage,
|
||||
[property: JsonPropertyName("avg_value")] double AvgValue,
|
||||
[property: JsonPropertyName("weight")] double Weight,
|
||||
[property: JsonPropertyName("impact_direction")] string ImpactDirection);
|
||||
|
||||
/// <summary>
|
||||
/// Impact of missing signals on scoring.
|
||||
/// </summary>
|
||||
public sealed record MissingSignalImpact(
|
||||
[property: JsonPropertyName("findings_with_missing_signals")] int FindingsWithMissingSignals,
|
||||
[property: JsonPropertyName("avg_missing_signals_per_finding")] double AvgMissingSignalsPerFinding,
|
||||
[property: JsonPropertyName("estimated_score_impact")] double EstimatedScoreImpact,
|
||||
[property: JsonPropertyName("most_impactful_missing")] ImmutableArray<string> MostImpactfulMissing);
|
||||
|
||||
/// <summary>
|
||||
/// Analysis of override applications.
|
||||
/// </summary>
|
||||
public sealed record OverrideAnalysis(
|
||||
[property: JsonPropertyName("total_overrides_evaluated")] int TotalOverridesEvaluated,
|
||||
[property: JsonPropertyName("severity_overrides_applied")] int SeverityOverridesApplied,
|
||||
[property: JsonPropertyName("decision_overrides_applied")] int DecisionOverridesApplied,
|
||||
[property: JsonPropertyName("override_application_rate")] double OverrideApplicationRate,
|
||||
[property: JsonPropertyName("severity_override_details")] ImmutableArray<SeverityOverrideDetail> SeverityOverrideDetails,
|
||||
[property: JsonPropertyName("decision_override_details")] ImmutableArray<DecisionOverrideDetail> DecisionOverrideDetails,
|
||||
[property: JsonPropertyName("override_conflicts")] ImmutableArray<OverrideConflict> OverrideConflicts);
|
||||
|
||||
/// <summary>
|
||||
/// Details of severity override applications.
|
||||
/// </summary>
|
||||
public sealed record SeverityOverrideDetail(
|
||||
[property: JsonPropertyName("predicate_hash")] string PredicateHash,
|
||||
[property: JsonPropertyName("predicate_summary")] string PredicateSummary,
|
||||
[property: JsonPropertyName("target_severity")] string TargetSeverity,
|
||||
[property: JsonPropertyName("applications_count")] int ApplicationsCount,
|
||||
[property: JsonPropertyName("original_severities")] ImmutableDictionary<string, int> OriginalSeverities);
|
||||
|
||||
/// <summary>
|
||||
/// Details of decision override applications.
|
||||
/// </summary>
|
||||
public sealed record DecisionOverrideDetail(
|
||||
[property: JsonPropertyName("predicate_hash")] string PredicateHash,
|
||||
[property: JsonPropertyName("predicate_summary")] string PredicateSummary,
|
||||
[property: JsonPropertyName("target_action")] string TargetAction,
|
||||
[property: JsonPropertyName("reason")] string? Reason,
|
||||
[property: JsonPropertyName("applications_count")] int ApplicationsCount,
|
||||
[property: JsonPropertyName("original_actions")] ImmutableDictionary<string, int> OriginalActions);
|
||||
|
||||
/// <summary>
|
||||
/// Override conflict detected during evaluation.
|
||||
/// </summary>
|
||||
public sealed record OverrideConflict(
|
||||
[property: JsonPropertyName("finding_id")] string FindingId,
|
||||
[property: JsonPropertyName("conflict_type")] string ConflictType,
|
||||
[property: JsonPropertyName("override_1")] string Override1,
|
||||
[property: JsonPropertyName("override_2")] string Override2,
|
||||
[property: JsonPropertyName("resolution")] string Resolution);
|
||||
|
||||
/// <summary>
|
||||
/// Analysis of score distribution.
|
||||
/// </summary>
|
||||
public sealed record ScoreDistributionAnalysis(
|
||||
[property: JsonPropertyName("raw_score_stats")] ScoreStatistics RawScoreStats,
|
||||
[property: JsonPropertyName("normalized_score_stats")] ScoreStatistics NormalizedScoreStats,
|
||||
[property: JsonPropertyName("score_buckets")] ImmutableArray<ScoreBucket> ScoreBuckets,
|
||||
[property: JsonPropertyName("percentiles")] ImmutableDictionary<string, double> Percentiles,
|
||||
[property: JsonPropertyName("outliers")] OutlierAnalysis Outliers);
|
||||
|
||||
/// <summary>
|
||||
/// Statistical summary of scores.
|
||||
/// </summary>
|
||||
public sealed record ScoreStatistics(
|
||||
[property: JsonPropertyName("count")] int Count,
|
||||
[property: JsonPropertyName("min")] double Min,
|
||||
[property: JsonPropertyName("max")] double Max,
|
||||
[property: JsonPropertyName("mean")] double Mean,
|
||||
[property: JsonPropertyName("median")] double Median,
|
||||
[property: JsonPropertyName("std_dev")] double StdDev,
|
||||
[property: JsonPropertyName("variance")] double Variance,
|
||||
[property: JsonPropertyName("skewness")] double Skewness,
|
||||
[property: JsonPropertyName("kurtosis")] double Kurtosis);
|
||||
|
||||
/// <summary>
|
||||
/// Score bucket for distribution.
|
||||
/// </summary>
|
||||
public sealed record ScoreBucket(
|
||||
[property: JsonPropertyName("range_min")] double RangeMin,
|
||||
[property: JsonPropertyName("range_max")] double RangeMax,
|
||||
[property: JsonPropertyName("label")] string Label,
|
||||
[property: JsonPropertyName("count")] int Count,
|
||||
[property: JsonPropertyName("percentage")] double Percentage);
|
||||
|
||||
/// <summary>
|
||||
/// Outlier analysis for scores.
|
||||
/// </summary>
|
||||
public sealed record OutlierAnalysis(
|
||||
[property: JsonPropertyName("outlier_count")] int OutlierCount,
|
||||
[property: JsonPropertyName("outlier_threshold")] double OutlierThreshold,
|
||||
[property: JsonPropertyName("outlier_finding_ids")] ImmutableArray<string> OutlierFindingIds);
|
||||
|
||||
/// <summary>
|
||||
/// Breakdown by severity level.
|
||||
/// </summary>
|
||||
public sealed record SeverityBreakdownAnalysis(
|
||||
[property: JsonPropertyName("by_severity")] ImmutableDictionary<string, SeverityBucket> BySeverity,
|
||||
[property: JsonPropertyName("severity_flow")] ImmutableArray<SeverityFlow> SeverityFlow,
|
||||
[property: JsonPropertyName("severity_concentration")] double SeverityConcentration);
|
||||
|
||||
/// <summary>
|
||||
/// Details for a severity bucket.
|
||||
/// </summary>
|
||||
public sealed record SeverityBucket(
|
||||
[property: JsonPropertyName("severity")] string Severity,
|
||||
[property: JsonPropertyName("count")] int Count,
|
||||
[property: JsonPropertyName("percentage")] double Percentage,
|
||||
[property: JsonPropertyName("avg_score")] double AvgScore,
|
||||
[property: JsonPropertyName("score_range")] ScoreRange ScoreRange,
|
||||
[property: JsonPropertyName("top_contributors")] ImmutableArray<string> TopContributors);
|
||||
|
||||
/// <summary>
|
||||
/// Score range for a bucket.
|
||||
/// </summary>
|
||||
public sealed record ScoreRange(
|
||||
[property: JsonPropertyName("min")] double Min,
|
||||
[property: JsonPropertyName("max")] double Max);
|
||||
|
||||
/// <summary>
|
||||
/// Flow from original to final severity after overrides.
|
||||
/// </summary>
|
||||
public sealed record SeverityFlow(
|
||||
[property: JsonPropertyName("from_severity")] string FromSeverity,
|
||||
[property: JsonPropertyName("to_severity")] string ToSeverity,
|
||||
[property: JsonPropertyName("count")] int Count,
|
||||
[property: JsonPropertyName("is_escalation")] bool IsEscalation);
|
||||
|
||||
/// <summary>
|
||||
/// Breakdown by recommended action.
|
||||
/// </summary>
|
||||
public sealed record ActionBreakdownAnalysis(
|
||||
[property: JsonPropertyName("by_action")] ImmutableDictionary<string, ActionBucket> ByAction,
|
||||
[property: JsonPropertyName("action_flow")] ImmutableArray<ActionFlow> ActionFlow,
|
||||
[property: JsonPropertyName("decision_stability")] double DecisionStability);
|
||||
|
||||
/// <summary>
|
||||
/// Details for an action bucket.
|
||||
/// </summary>
|
||||
public sealed record ActionBucket(
|
||||
[property: JsonPropertyName("action")] string Action,
|
||||
[property: JsonPropertyName("count")] int Count,
|
||||
[property: JsonPropertyName("percentage")] double Percentage,
|
||||
[property: JsonPropertyName("avg_score")] double AvgScore,
|
||||
[property: JsonPropertyName("severity_breakdown")] ImmutableDictionary<string, int> SeverityBreakdown);
|
||||
|
||||
/// <summary>
|
||||
/// Flow from original to final action after overrides.
|
||||
/// </summary>
|
||||
public sealed record ActionFlow(
|
||||
[property: JsonPropertyName("from_action")] string FromAction,
|
||||
[property: JsonPropertyName("to_action")] string ToAction,
|
||||
[property: JsonPropertyName("count")] int Count);
|
||||
|
||||
/// <summary>
|
||||
/// Breakdown by component/package.
|
||||
/// </summary>
|
||||
public sealed record ComponentBreakdownAnalysis(
|
||||
[property: JsonPropertyName("total_components")] int TotalComponents,
|
||||
[property: JsonPropertyName("components_with_findings")] int ComponentsWithFindings,
|
||||
[property: JsonPropertyName("top_risk_components")] ImmutableArray<ComponentRiskSummary> TopRiskComponents,
|
||||
[property: JsonPropertyName("ecosystem_breakdown")] ImmutableDictionary<string, EcosystemSummary> EcosystemBreakdown);
|
||||
|
||||
/// <summary>
|
||||
/// Risk summary for a component.
|
||||
/// </summary>
|
||||
public sealed record ComponentRiskSummary(
|
||||
[property: JsonPropertyName("component_purl")] string ComponentPurl,
|
||||
[property: JsonPropertyName("finding_count")] int FindingCount,
|
||||
[property: JsonPropertyName("max_score")] double MaxScore,
|
||||
[property: JsonPropertyName("avg_score")] double AvgScore,
|
||||
[property: JsonPropertyName("highest_severity")] string HighestSeverity,
|
||||
[property: JsonPropertyName("recommended_action")] string RecommendedAction);
|
||||
|
||||
/// <summary>
|
||||
/// Summary for a package ecosystem.
|
||||
/// </summary>
|
||||
public sealed record EcosystemSummary(
|
||||
[property: JsonPropertyName("ecosystem")] string Ecosystem,
|
||||
[property: JsonPropertyName("component_count")] int ComponentCount,
|
||||
[property: JsonPropertyName("finding_count")] int FindingCount,
|
||||
[property: JsonPropertyName("avg_score")] double AvgScore,
|
||||
[property: JsonPropertyName("critical_count")] int CriticalCount,
|
||||
[property: JsonPropertyName("high_count")] int HighCount);
|
||||
|
||||
/// <summary>
|
||||
/// Risk trend analysis (for comparison simulations).
|
||||
/// </summary>
|
||||
public sealed record RiskTrendAnalysis(
|
||||
[property: JsonPropertyName("comparison_type")] string ComparisonType,
|
||||
[property: JsonPropertyName("score_trend")] TrendMetric ScoreTrend,
|
||||
[property: JsonPropertyName("severity_trend")] TrendMetric SeverityTrend,
|
||||
[property: JsonPropertyName("action_trend")] TrendMetric ActionTrend,
|
||||
[property: JsonPropertyName("findings_improved")] int FindingsImproved,
|
||||
[property: JsonPropertyName("findings_worsened")] int FindingsWorsened,
|
||||
[property: JsonPropertyName("findings_unchanged")] int FindingsUnchanged);
|
||||
|
||||
/// <summary>
|
||||
/// Trend metric for comparison.
|
||||
/// </summary>
|
||||
public sealed record TrendMetric(
|
||||
[property: JsonPropertyName("direction")] string Direction,
|
||||
[property: JsonPropertyName("magnitude")] double Magnitude,
|
||||
[property: JsonPropertyName("percentage_change")] double PercentageChange,
|
||||
[property: JsonPropertyName("is_significant")] bool IsSignificant);
|
||||
@@ -0,0 +1,897 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Simulation;
|
||||
|
||||
/// <summary>
|
||||
/// Service for generating detailed breakdowns of risk simulation results.
|
||||
/// Per POLICY-RISK-67-003.
|
||||
/// </summary>
|
||||
public sealed class RiskSimulationBreakdownService
|
||||
{
|
||||
private readonly ILogger<RiskSimulationBreakdownService> _logger;
|
||||
|
||||
private static readonly ImmutableArray<string> SeverityOrder = ImmutableArray.Create(
|
||||
"informational", "low", "medium", "high", "critical");
|
||||
|
||||
private static readonly ImmutableArray<string> ActionOrder = ImmutableArray.Create(
|
||||
"allow", "review", "deny");
|
||||
|
||||
public RiskSimulationBreakdownService(ILogger<RiskSimulationBreakdownService> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a detailed breakdown of a risk simulation result.
|
||||
/// </summary>
|
||||
public RiskSimulationBreakdown GenerateBreakdown(
|
||||
RiskSimulationResult result,
|
||||
RiskProfileModel profile,
|
||||
IReadOnlyList<SimulationFinding> findings,
|
||||
RiskSimulationBreakdownOptions? options = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
ArgumentNullException.ThrowIfNull(profile);
|
||||
ArgumentNullException.ThrowIfNull(findings);
|
||||
|
||||
options ??= RiskSimulationBreakdownOptions.Default;
|
||||
|
||||
_logger.LogDebug(
|
||||
"Generating breakdown for simulation {SimulationId} with {FindingCount} findings",
|
||||
result.SimulationId, findings.Count);
|
||||
|
||||
var profileRef = new ProfileReference(
|
||||
profile.Id,
|
||||
profile.Version,
|
||||
result.ProfileHash,
|
||||
profile.Description,
|
||||
profile.Extends);
|
||||
|
||||
var signalAnalysis = ComputeSignalAnalysis(result, profile, findings, options);
|
||||
var overrideAnalysis = ComputeOverrideAnalysis(result, profile);
|
||||
var scoreDistribution = ComputeScoreDistributionAnalysis(result, options);
|
||||
var severityBreakdown = ComputeSeverityBreakdownAnalysis(result);
|
||||
var actionBreakdown = ComputeActionBreakdownAnalysis(result);
|
||||
var componentBreakdown = options.IncludeComponentBreakdown
|
||||
? ComputeComponentBreakdownAnalysis(result, findings, options)
|
||||
: null;
|
||||
|
||||
var determinismHash = ComputeDeterminismHash(result, profile);
|
||||
|
||||
return new RiskSimulationBreakdown(
|
||||
result.SimulationId,
|
||||
profileRef,
|
||||
signalAnalysis,
|
||||
overrideAnalysis,
|
||||
scoreDistribution,
|
||||
severityBreakdown,
|
||||
actionBreakdown,
|
||||
componentBreakdown,
|
||||
RiskTrends: null, // Set by comparison operations
|
||||
determinismHash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a breakdown with trend analysis comparing two simulations.
|
||||
/// </summary>
|
||||
public RiskSimulationBreakdown GenerateComparisonBreakdown(
|
||||
RiskSimulationResult baselineResult,
|
||||
RiskSimulationResult compareResult,
|
||||
RiskProfileModel baselineProfile,
|
||||
RiskProfileModel compareProfile,
|
||||
IReadOnlyList<SimulationFinding> findings,
|
||||
RiskSimulationBreakdownOptions? options = null)
|
||||
{
|
||||
var breakdown = GenerateBreakdown(compareResult, compareProfile, findings, options);
|
||||
var trends = ComputeRiskTrends(baselineResult, compareResult);
|
||||
|
||||
return breakdown with { RiskTrends = trends };
|
||||
}
|
||||
|
||||
private SignalAnalysis ComputeSignalAnalysis(
|
||||
RiskSimulationResult result,
|
||||
RiskProfileModel profile,
|
||||
IReadOnlyList<SimulationFinding> findings,
|
||||
RiskSimulationBreakdownOptions options)
|
||||
{
|
||||
var signalStats = new List<SignalStatistics>();
|
||||
var totalContribution = 0.0;
|
||||
var signalsUsed = 0;
|
||||
var findingsWithMissingSignals = 0;
|
||||
var missingSignalCounts = new Dictionary<string, int>();
|
||||
|
||||
foreach (var signal in profile.Signals)
|
||||
{
|
||||
var weight = profile.Weights.GetValueOrDefault(signal.Name, 0.0);
|
||||
var contributions = new List<double>();
|
||||
var values = new List<double>();
|
||||
var findingsWithSignal = 0;
|
||||
var findingsMissing = 0;
|
||||
|
||||
foreach (var findingScore in result.FindingScores)
|
||||
{
|
||||
var contribution = findingScore.Contributions?
|
||||
.FirstOrDefault(c => c.SignalName == signal.Name);
|
||||
|
||||
if (contribution != null)
|
||||
{
|
||||
findingsWithSignal++;
|
||||
contributions.Add(contribution.Contribution);
|
||||
if (contribution.SignalValue is double dv)
|
||||
values.Add(dv);
|
||||
else if (contribution.SignalValue is JsonElement je && je.TryGetDouble(out var jd))
|
||||
values.Add(jd);
|
||||
}
|
||||
else
|
||||
{
|
||||
findingsMissing++;
|
||||
missingSignalCounts.TryGetValue(signal.Name, out var count);
|
||||
missingSignalCounts[signal.Name] = count + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (findingsWithSignal > 0)
|
||||
{
|
||||
signalsUsed++;
|
||||
}
|
||||
|
||||
var signalTotalContribution = contributions.Sum();
|
||||
totalContribution += signalTotalContribution;
|
||||
|
||||
var valueDistribution = values.Count > 0 && options.IncludeHistograms
|
||||
? ComputeValueDistribution(values, options.HistogramBuckets)
|
||||
: null;
|
||||
|
||||
signalStats.Add(new SignalStatistics(
|
||||
signal.Name,
|
||||
signal.Type.ToString().ToLowerInvariant(),
|
||||
weight,
|
||||
findingsWithSignal,
|
||||
findingsMissing,
|
||||
result.FindingScores.Count > 0
|
||||
? (double)findingsWithSignal / result.FindingScores.Count * 100
|
||||
: 0,
|
||||
valueDistribution,
|
||||
signalTotalContribution,
|
||||
findingsWithSignal > 0 ? signalTotalContribution / findingsWithSignal : 0));
|
||||
}
|
||||
|
||||
// Compute top contributors
|
||||
var topContributors = signalStats
|
||||
.Where(s => s.TotalContribution > 0)
|
||||
.OrderByDescending(s => s.TotalContribution)
|
||||
.Take(options.TopContributorsCount)
|
||||
.Select(s => new SignalContributor(
|
||||
s.SignalName,
|
||||
s.TotalContribution,
|
||||
totalContribution > 0 ? s.TotalContribution / totalContribution * 100 : 0,
|
||||
s.ValueDistribution?.Mean ?? 0,
|
||||
s.Weight,
|
||||
s.Weight >= 0 ? "increase" : "decrease"))
|
||||
.ToImmutableArray();
|
||||
|
||||
// Missing signal impact analysis
|
||||
var avgMissingPerFinding = result.FindingScores.Count > 0
|
||||
? missingSignalCounts.Values.Sum() / (double)result.FindingScores.Count
|
||||
: 0;
|
||||
|
||||
var mostImpactfulMissing = missingSignalCounts
|
||||
.OrderByDescending(kvp => kvp.Value * profile.Weights.GetValueOrDefault(kvp.Key, 0))
|
||||
.Take(5)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToImmutableArray();
|
||||
|
||||
var missingImpact = new MissingSignalImpact(
|
||||
findingsWithMissingSignals,
|
||||
avgMissingPerFinding,
|
||||
EstimateMissingSignalImpact(missingSignalCounts, profile),
|
||||
mostImpactfulMissing);
|
||||
|
||||
return new SignalAnalysis(
|
||||
profile.Signals.Count,
|
||||
signalsUsed,
|
||||
profile.Signals.Count - signalsUsed,
|
||||
profile.Signals.Count > 0 ? (double)signalsUsed / profile.Signals.Count * 100 : 0,
|
||||
signalStats.ToImmutableArray(),
|
||||
topContributors,
|
||||
missingImpact);
|
||||
}
|
||||
|
||||
private OverrideAnalysis ComputeOverrideAnalysis(
|
||||
RiskSimulationResult result,
|
||||
RiskProfileModel profile)
|
||||
{
|
||||
var severityOverrideDetails = new Dictionary<string, SeverityOverrideTracker>();
|
||||
var decisionOverrideDetails = new Dictionary<string, DecisionOverrideTracker>();
|
||||
var severityOverrideCount = 0;
|
||||
var decisionOverrideCount = 0;
|
||||
var conflicts = new List<OverrideConflict>();
|
||||
|
||||
foreach (var score in result.FindingScores)
|
||||
{
|
||||
if (score.OverridesApplied == null)
|
||||
continue;
|
||||
|
||||
foreach (var applied in score.OverridesApplied)
|
||||
{
|
||||
var predicateHash = ComputePredicateHash(applied.Predicate);
|
||||
|
||||
if (applied.OverrideType == "severity")
|
||||
{
|
||||
severityOverrideCount++;
|
||||
if (!severityOverrideDetails.TryGetValue(predicateHash, out var tracker))
|
||||
{
|
||||
tracker = new SeverityOverrideTracker(
|
||||
predicateHash,
|
||||
SummarizePredicate(applied.Predicate),
|
||||
applied.AppliedValue?.ToString() ?? "unknown");
|
||||
severityOverrideDetails[predicateHash] = tracker;
|
||||
}
|
||||
tracker.Count++;
|
||||
var origSev = applied.OriginalValue?.ToString() ?? "unknown";
|
||||
tracker.OriginalSeverities.TryGetValue(origSev, out var count);
|
||||
tracker.OriginalSeverities[origSev] = count + 1;
|
||||
}
|
||||
else if (applied.OverrideType == "decision")
|
||||
{
|
||||
decisionOverrideCount++;
|
||||
if (!decisionOverrideDetails.TryGetValue(predicateHash, out var tracker))
|
||||
{
|
||||
tracker = new DecisionOverrideTracker(
|
||||
predicateHash,
|
||||
SummarizePredicate(applied.Predicate),
|
||||
applied.AppliedValue?.ToString() ?? "unknown",
|
||||
applied.Reason);
|
||||
decisionOverrideDetails[predicateHash] = tracker;
|
||||
}
|
||||
tracker.Count++;
|
||||
var origAction = applied.OriginalValue?.ToString() ?? "unknown";
|
||||
tracker.OriginalActions.TryGetValue(origAction, out var count);
|
||||
tracker.OriginalActions[origAction] = count + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for conflicts (multiple overrides of same type)
|
||||
var severityOverrides = score.OverridesApplied.Where(o => o.OverrideType == "severity").ToList();
|
||||
if (severityOverrides.Count > 1)
|
||||
{
|
||||
conflicts.Add(new OverrideConflict(
|
||||
score.FindingId,
|
||||
"severity_conflict",
|
||||
SummarizePredicate(severityOverrides[0].Predicate),
|
||||
SummarizePredicate(severityOverrides[1].Predicate),
|
||||
"first_match"));
|
||||
}
|
||||
}
|
||||
|
||||
var totalOverridesEvaluated = profile.Overrides.Severity.Count + profile.Overrides.Decisions.Count;
|
||||
var overrideApplicationRate = result.FindingScores.Count > 0
|
||||
? (double)(severityOverrideCount + decisionOverrideCount) / result.FindingScores.Count * 100
|
||||
: 0;
|
||||
|
||||
return new OverrideAnalysis(
|
||||
totalOverridesEvaluated * result.FindingScores.Count,
|
||||
severityOverrideCount,
|
||||
decisionOverrideCount,
|
||||
overrideApplicationRate,
|
||||
severityOverrideDetails.Values
|
||||
.Select(t => new SeverityOverrideDetail(
|
||||
t.Hash, t.Summary, t.TargetSeverity, t.Count,
|
||||
t.OriginalSeverities.ToImmutableDictionary()))
|
||||
.ToImmutableArray(),
|
||||
decisionOverrideDetails.Values
|
||||
.Select(t => new DecisionOverrideDetail(
|
||||
t.Hash, t.Summary, t.TargetAction, t.Reason, t.Count,
|
||||
t.OriginalActions.ToImmutableDictionary()))
|
||||
.ToImmutableArray(),
|
||||
conflicts.ToImmutableArray());
|
||||
}
|
||||
|
||||
private ScoreDistributionAnalysis ComputeScoreDistributionAnalysis(
|
||||
RiskSimulationResult result,
|
||||
RiskSimulationBreakdownOptions options)
|
||||
{
|
||||
var rawScores = result.FindingScores.Select(s => s.RawScore).ToList();
|
||||
var normalizedScores = result.FindingScores.Select(s => s.NormalizedScore).ToList();
|
||||
|
||||
var rawStats = ComputeScoreStatistics(rawScores);
|
||||
var normalizedStats = ComputeScoreStatistics(normalizedScores);
|
||||
|
||||
var buckets = ComputeScoreBuckets(normalizedScores, options.ScoreBucketCount);
|
||||
var percentiles = ComputePercentiles(normalizedScores);
|
||||
var outliers = ComputeOutliers(result.FindingScores, normalizedStats);
|
||||
|
||||
return new ScoreDistributionAnalysis(
|
||||
rawStats,
|
||||
normalizedStats,
|
||||
buckets,
|
||||
percentiles.ToImmutableDictionary(),
|
||||
outliers);
|
||||
}
|
||||
|
||||
private SeverityBreakdownAnalysis ComputeSeverityBreakdownAnalysis(RiskSimulationResult result)
|
||||
{
|
||||
var bySeverity = new Dictionary<string, SeverityBucketBuilder>();
|
||||
var severityFlows = new Dictionary<(string from, string to), int>();
|
||||
|
||||
foreach (var score in result.FindingScores)
|
||||
{
|
||||
var severity = score.Severity.ToString().ToLowerInvariant();
|
||||
|
||||
if (!bySeverity.TryGetValue(severity, out var bucket))
|
||||
{
|
||||
bucket = new SeverityBucketBuilder(severity);
|
||||
bySeverity[severity] = bucket;
|
||||
}
|
||||
|
||||
bucket.Count++;
|
||||
bucket.Scores.Add(score.NormalizedScore);
|
||||
|
||||
// Track top contributors
|
||||
var topContributor = score.Contributions?
|
||||
.OrderByDescending(c => c.ContributionPercentage)
|
||||
.FirstOrDefault();
|
||||
if (topContributor != null)
|
||||
{
|
||||
bucket.TopContributors.TryGetValue(topContributor.SignalName, out var count);
|
||||
bucket.TopContributors[topContributor.SignalName] = count + 1;
|
||||
}
|
||||
|
||||
// Track severity flows (from score-based to override-based)
|
||||
var originalSeverity = DetermineSeverityFromScore(score.NormalizedScore).ToString().ToLowerInvariant();
|
||||
if (originalSeverity != severity)
|
||||
{
|
||||
var flowKey = (originalSeverity, severity);
|
||||
severityFlows.TryGetValue(flowKey, out var flowCount);
|
||||
severityFlows[flowKey] = flowCount + 1;
|
||||
}
|
||||
}
|
||||
|
||||
var total = result.FindingScores.Count;
|
||||
var severityBuckets = bySeverity.Values
|
||||
.Select(b => new SeverityBucket(
|
||||
b.Severity,
|
||||
b.Count,
|
||||
total > 0 ? (double)b.Count / total * 100 : 0,
|
||||
b.Scores.Count > 0 ? b.Scores.Average() : 0,
|
||||
new ScoreRange(
|
||||
b.Scores.Count > 0 ? b.Scores.Min() : 0,
|
||||
b.Scores.Count > 0 ? b.Scores.Max() : 0),
|
||||
b.TopContributors
|
||||
.OrderByDescending(kvp => kvp.Value)
|
||||
.Take(3)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToImmutableArray()))
|
||||
.ToImmutableDictionary(b => b.Severity);
|
||||
|
||||
var flows = severityFlows
|
||||
.Select(kvp => new SeverityFlow(
|
||||
kvp.Key.from,
|
||||
kvp.Key.to,
|
||||
kvp.Value,
|
||||
SeverityOrder.IndexOf(kvp.Key.to) > SeverityOrder.IndexOf(kvp.Key.from)))
|
||||
.ToImmutableArray();
|
||||
|
||||
// Severity concentration (HHI - higher = more concentrated)
|
||||
var concentration = bySeverity.Values.Sum(b =>
|
||||
Math.Pow((double)b.Count / (total > 0 ? total : 1), 2));
|
||||
|
||||
return new SeverityBreakdownAnalysis(severityBuckets, flows, concentration);
|
||||
}
|
||||
|
||||
private ActionBreakdownAnalysis ComputeActionBreakdownAnalysis(RiskSimulationResult result)
|
||||
{
|
||||
var byAction = new Dictionary<string, ActionBucketBuilder>();
|
||||
var actionFlows = new Dictionary<(string from, string to), int>();
|
||||
|
||||
foreach (var score in result.FindingScores)
|
||||
{
|
||||
var action = score.RecommendedAction.ToString().ToLowerInvariant();
|
||||
var severity = score.Severity.ToString().ToLowerInvariant();
|
||||
|
||||
if (!byAction.TryGetValue(action, out var bucket))
|
||||
{
|
||||
bucket = new ActionBucketBuilder(action);
|
||||
byAction[action] = bucket;
|
||||
}
|
||||
|
||||
bucket.Count++;
|
||||
bucket.Scores.Add(score.NormalizedScore);
|
||||
bucket.SeverityCounts.TryGetValue(severity, out var sevCount);
|
||||
bucket.SeverityCounts[severity] = sevCount + 1;
|
||||
|
||||
// Track action flows
|
||||
var originalAction = DetermineActionFromSeverity(score.Severity).ToString().ToLowerInvariant();
|
||||
if (originalAction != action)
|
||||
{
|
||||
var flowKey = (originalAction, action);
|
||||
actionFlows.TryGetValue(flowKey, out var flowCount);
|
||||
actionFlows[flowKey] = flowCount + 1;
|
||||
}
|
||||
}
|
||||
|
||||
var total = result.FindingScores.Count;
|
||||
var actionBuckets = byAction.Values
|
||||
.Select(b => new ActionBucket(
|
||||
b.Action,
|
||||
b.Count,
|
||||
total > 0 ? (double)b.Count / total * 100 : 0,
|
||||
b.Scores.Count > 0 ? b.Scores.Average() : 0,
|
||||
b.SeverityCounts.ToImmutableDictionary()))
|
||||
.ToImmutableDictionary(b => b.Action);
|
||||
|
||||
var flows = actionFlows
|
||||
.Select(kvp => new ActionFlow(kvp.Key.from, kvp.Key.to, kvp.Value))
|
||||
.ToImmutableArray();
|
||||
|
||||
// Decision stability (1 - flow rate)
|
||||
var totalFlows = flows.Sum(f => f.Count);
|
||||
var stability = total > 0 ? 1.0 - (double)totalFlows / total : 1.0;
|
||||
|
||||
return new ActionBreakdownAnalysis(actionBuckets, flows, stability);
|
||||
}
|
||||
|
||||
private ComponentBreakdownAnalysis ComputeComponentBreakdownAnalysis(
|
||||
RiskSimulationResult result,
|
||||
IReadOnlyList<SimulationFinding> findings,
|
||||
RiskSimulationBreakdownOptions options)
|
||||
{
|
||||
var componentScores = new Dictionary<string, ComponentScoreTracker>();
|
||||
var ecosystemStats = new Dictionary<string, EcosystemTracker>();
|
||||
|
||||
foreach (var score in result.FindingScores)
|
||||
{
|
||||
var finding = findings.FirstOrDefault(f => f.FindingId == score.FindingId);
|
||||
var purl = finding?.ComponentPurl ?? "unknown";
|
||||
var ecosystem = ExtractEcosystem(purl);
|
||||
|
||||
// Component tracking
|
||||
if (!componentScores.TryGetValue(purl, out var tracker))
|
||||
{
|
||||
tracker = new ComponentScoreTracker(purl);
|
||||
componentScores[purl] = tracker;
|
||||
}
|
||||
tracker.Scores.Add(score.NormalizedScore);
|
||||
tracker.Severities.Add(score.Severity);
|
||||
tracker.Actions.Add(score.RecommendedAction);
|
||||
|
||||
// Ecosystem tracking
|
||||
if (!ecosystemStats.TryGetValue(ecosystem, out var ecoTracker))
|
||||
{
|
||||
ecoTracker = new EcosystemTracker(ecosystem);
|
||||
ecosystemStats[ecosystem] = ecoTracker;
|
||||
}
|
||||
ecoTracker.Components.Add(purl);
|
||||
ecoTracker.FindingCount++;
|
||||
ecoTracker.Scores.Add(score.NormalizedScore);
|
||||
if (score.Severity == RiskSeverity.Critical) ecoTracker.CriticalCount++;
|
||||
if (score.Severity == RiskSeverity.High) ecoTracker.HighCount++;
|
||||
}
|
||||
|
||||
var topComponents = componentScores.Values
|
||||
.OrderByDescending(c => c.Scores.Max())
|
||||
.ThenByDescending(c => c.Scores.Count)
|
||||
.Take(options.TopComponentsCount)
|
||||
.Select(c => new ComponentRiskSummary(
|
||||
c.Purl,
|
||||
c.Scores.Count,
|
||||
c.Scores.Max(),
|
||||
c.Scores.Average(),
|
||||
GetHighestSeverity(c.Severities),
|
||||
GetMostRestrictiveAction(c.Actions)))
|
||||
.ToImmutableArray();
|
||||
|
||||
var ecosystemBreakdown = ecosystemStats.Values
|
||||
.Select(e => new EcosystemSummary(
|
||||
e.Ecosystem,
|
||||
e.Components.Count,
|
||||
e.FindingCount,
|
||||
e.Scores.Count > 0 ? e.Scores.Average() : 0,
|
||||
e.CriticalCount,
|
||||
e.HighCount))
|
||||
.ToImmutableDictionary(e => e.Ecosystem);
|
||||
|
||||
return new ComponentBreakdownAnalysis(
|
||||
componentScores.Count,
|
||||
componentScores.Values.Count(c => c.Scores.Count > 0),
|
||||
topComponents,
|
||||
ecosystemBreakdown);
|
||||
}
|
||||
|
||||
private RiskTrendAnalysis ComputeRiskTrends(
|
||||
RiskSimulationResult baseline,
|
||||
RiskSimulationResult compare)
|
||||
{
|
||||
var baselineScores = baseline.FindingScores.ToDictionary(s => s.FindingId);
|
||||
var compareScores = compare.FindingScores.ToDictionary(s => s.FindingId);
|
||||
|
||||
var improved = 0;
|
||||
var worsened = 0;
|
||||
var unchanged = 0;
|
||||
var scoreDeltaSum = 0.0;
|
||||
var severityEscalations = 0;
|
||||
var severityDeescalations = 0;
|
||||
var actionChanges = 0;
|
||||
|
||||
foreach (var (findingId, baseScore) in baselineScores)
|
||||
{
|
||||
if (!compareScores.TryGetValue(findingId, out var compScore))
|
||||
continue;
|
||||
|
||||
var scoreDelta = compScore.NormalizedScore - baseScore.NormalizedScore;
|
||||
scoreDeltaSum += scoreDelta;
|
||||
|
||||
if (Math.Abs(scoreDelta) < 1.0)
|
||||
unchanged++;
|
||||
else if (scoreDelta < 0)
|
||||
improved++;
|
||||
else
|
||||
worsened++;
|
||||
|
||||
var baseSevIdx = SeverityOrder.IndexOf(baseScore.Severity.ToString().ToLowerInvariant());
|
||||
var compSevIdx = SeverityOrder.IndexOf(compScore.Severity.ToString().ToLowerInvariant());
|
||||
if (compSevIdx > baseSevIdx) severityEscalations++;
|
||||
else if (compSevIdx < baseSevIdx) severityDeescalations++;
|
||||
|
||||
if (baseScore.RecommendedAction != compScore.RecommendedAction)
|
||||
actionChanges++;
|
||||
}
|
||||
|
||||
var baselineAvg = baseline.AggregateMetrics.MeanScore;
|
||||
var compareAvg = compare.AggregateMetrics.MeanScore;
|
||||
var scorePercentChange = baselineAvg > 0
|
||||
? (compareAvg - baselineAvg) / baselineAvg * 100
|
||||
: 0;
|
||||
|
||||
var scoreTrend = new TrendMetric(
|
||||
scorePercentChange < -1 ? "improving" : scorePercentChange > 1 ? "worsening" : "stable",
|
||||
Math.Abs(compareAvg - baselineAvg),
|
||||
scorePercentChange,
|
||||
Math.Abs(scorePercentChange) > 5);
|
||||
|
||||
var severityTrend = new TrendMetric(
|
||||
severityDeescalations > severityEscalations ? "improving" :
|
||||
severityEscalations > severityDeescalations ? "worsening" : "stable",
|
||||
Math.Abs(severityEscalations - severityDeescalations),
|
||||
baselineScores.Count > 0
|
||||
? (double)(severityEscalations - severityDeescalations) / baselineScores.Count * 100
|
||||
: 0,
|
||||
Math.Abs(severityEscalations - severityDeescalations) > baselineScores.Count * 0.05);
|
||||
|
||||
var actionTrend = new TrendMetric(
|
||||
"changed",
|
||||
actionChanges,
|
||||
baselineScores.Count > 0 ? (double)actionChanges / baselineScores.Count * 100 : 0,
|
||||
actionChanges > baselineScores.Count * 0.1);
|
||||
|
||||
return new RiskTrendAnalysis(
|
||||
"profile_comparison",
|
||||
scoreTrend,
|
||||
severityTrend,
|
||||
actionTrend,
|
||||
improved,
|
||||
worsened,
|
||||
unchanged);
|
||||
}
|
||||
|
||||
private static ValueDistribution ComputeValueDistribution(List<double> values, int bucketCount)
|
||||
{
|
||||
if (values.Count == 0)
|
||||
return new ValueDistribution(null, null, null, null, null, null);
|
||||
|
||||
var sorted = values.OrderBy(v => v).ToList();
|
||||
var min = sorted.First();
|
||||
var max = sorted.Last();
|
||||
var mean = values.Average();
|
||||
var median = sorted.Count % 2 == 0
|
||||
? (sorted[sorted.Count / 2 - 1] + sorted[sorted.Count / 2]) / 2
|
||||
: sorted[sorted.Count / 2];
|
||||
var variance = values.Average(v => Math.Pow(v - mean, 2));
|
||||
var stdDev = Math.Sqrt(variance);
|
||||
|
||||
var histogram = new List<HistogramBucket>();
|
||||
if (max > min)
|
||||
{
|
||||
var bucketSize = (max - min) / bucketCount;
|
||||
for (var i = 0; i < bucketCount; i++)
|
||||
{
|
||||
var rangeMin = min + i * bucketSize;
|
||||
var rangeMax = min + (i + 1) * bucketSize;
|
||||
var count = values.Count(v => v >= rangeMin && (i == bucketCount - 1 ? v <= rangeMax : v < rangeMax));
|
||||
histogram.Add(new HistogramBucket(rangeMin, rangeMax, count, (double)count / values.Count * 100));
|
||||
}
|
||||
}
|
||||
|
||||
return new ValueDistribution(min, max, mean, median, stdDev, histogram.ToImmutableArray());
|
||||
}
|
||||
|
||||
private static ScoreStatistics ComputeScoreStatistics(List<double> scores)
|
||||
{
|
||||
if (scores.Count == 0)
|
||||
return new ScoreStatistics(0, 0, 0, 0, 0, 0, 0, 0, 0);
|
||||
|
||||
var sorted = scores.OrderBy(s => s).ToList();
|
||||
var mean = scores.Average();
|
||||
var median = sorted.Count % 2 == 0
|
||||
? (sorted[sorted.Count / 2 - 1] + sorted[sorted.Count / 2]) / 2
|
||||
: sorted[sorted.Count / 2];
|
||||
var variance = scores.Average(s => Math.Pow(s - mean, 2));
|
||||
var stdDev = Math.Sqrt(variance);
|
||||
|
||||
// Skewness and kurtosis
|
||||
var skewness = stdDev > 0
|
||||
? scores.Average(s => Math.Pow((s - mean) / stdDev, 3))
|
||||
: 0;
|
||||
var kurtosis = stdDev > 0
|
||||
? scores.Average(s => Math.Pow((s - mean) / stdDev, 4)) - 3
|
||||
: 0;
|
||||
|
||||
return new ScoreStatistics(
|
||||
scores.Count,
|
||||
sorted.First(),
|
||||
sorted.Last(),
|
||||
Math.Round(mean, 2),
|
||||
Math.Round(median, 2),
|
||||
Math.Round(stdDev, 2),
|
||||
Math.Round(variance, 2),
|
||||
Math.Round(skewness, 3),
|
||||
Math.Round(kurtosis, 3));
|
||||
}
|
||||
|
||||
private static ImmutableArray<ScoreBucket> ComputeScoreBuckets(List<double> scores, int bucketCount)
|
||||
{
|
||||
var buckets = new List<ScoreBucket>();
|
||||
var bucketSize = 100.0 / bucketCount;
|
||||
|
||||
for (var i = 0; i < bucketCount; i++)
|
||||
{
|
||||
var rangeMin = i * bucketSize;
|
||||
var rangeMax = (i + 1) * bucketSize;
|
||||
var count = scores.Count(s => s >= rangeMin && s < rangeMax);
|
||||
var label = i switch
|
||||
{
|
||||
0 => "Very Low",
|
||||
1 => "Low",
|
||||
2 => "Low-Medium",
|
||||
3 => "Medium",
|
||||
4 => "Medium",
|
||||
5 => "Medium-High",
|
||||
6 => "High",
|
||||
7 => "High",
|
||||
8 => "Very High",
|
||||
9 => "Critical",
|
||||
_ => $"Bucket {i + 1}"
|
||||
};
|
||||
|
||||
buckets.Add(new ScoreBucket(
|
||||
rangeMin, rangeMax, label, count,
|
||||
scores.Count > 0 ? (double)count / scores.Count * 100 : 0));
|
||||
}
|
||||
|
||||
return buckets.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static Dictionary<string, double> ComputePercentiles(List<double> scores)
|
||||
{
|
||||
var percentiles = new Dictionary<string, double>();
|
||||
if (scores.Count == 0)
|
||||
return percentiles;
|
||||
|
||||
var sorted = scores.OrderBy(s => s).ToList();
|
||||
var levels = new[] { 0.25, 0.50, 0.75, 0.90, 0.95, 0.99 };
|
||||
|
||||
foreach (var level in levels)
|
||||
{
|
||||
var index = (int)(level * (sorted.Count - 1));
|
||||
percentiles[$"p{(int)(level * 100)}"] = sorted[index];
|
||||
}
|
||||
|
||||
return percentiles;
|
||||
}
|
||||
|
||||
private static OutlierAnalysis ComputeOutliers(
|
||||
IReadOnlyList<FindingScore> scores,
|
||||
ScoreStatistics stats)
|
||||
{
|
||||
if (scores.Count == 0)
|
||||
return new OutlierAnalysis(0, 0, ImmutableArray<string>.Empty);
|
||||
|
||||
// Use IQR method for outlier detection
|
||||
var sorted = scores.OrderBy(s => s.NormalizedScore).ToList();
|
||||
var q1Idx = sorted.Count / 4;
|
||||
var q3Idx = sorted.Count * 3 / 4;
|
||||
var q1 = sorted[q1Idx].NormalizedScore;
|
||||
var q3 = sorted[q3Idx].NormalizedScore;
|
||||
var iqr = q3 - q1;
|
||||
var threshold = q3 + 1.5 * iqr;
|
||||
|
||||
var outliers = scores
|
||||
.Where(s => s.NormalizedScore > threshold)
|
||||
.Select(s => s.FindingId)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new OutlierAnalysis(outliers.Length, threshold, outliers);
|
||||
}
|
||||
|
||||
private static double EstimateMissingSignalImpact(
|
||||
Dictionary<string, int> missingCounts,
|
||||
RiskProfileModel profile)
|
||||
{
|
||||
var impact = 0.0;
|
||||
foreach (var (signal, count) in missingCounts)
|
||||
{
|
||||
var weight = profile.Weights.GetValueOrDefault(signal, 0.0);
|
||||
// Estimate impact as weight * average value (0.5) * missing count
|
||||
impact += Math.Abs(weight) * 0.5 * count;
|
||||
}
|
||||
return impact;
|
||||
}
|
||||
|
||||
private static RiskSeverity DetermineSeverityFromScore(double score)
|
||||
{
|
||||
return score switch
|
||||
{
|
||||
>= 90 => RiskSeverity.Critical,
|
||||
>= 70 => RiskSeverity.High,
|
||||
>= 40 => RiskSeverity.Medium,
|
||||
>= 10 => RiskSeverity.Low,
|
||||
_ => RiskSeverity.Informational
|
||||
};
|
||||
}
|
||||
|
||||
private static RiskAction DetermineActionFromSeverity(RiskSeverity severity)
|
||||
{
|
||||
return severity switch
|
||||
{
|
||||
RiskSeverity.Critical => RiskAction.Deny,
|
||||
RiskSeverity.High => RiskAction.Deny,
|
||||
RiskSeverity.Medium => RiskAction.Review,
|
||||
_ => RiskAction.Allow
|
||||
};
|
||||
}
|
||||
|
||||
private static string ExtractEcosystem(string purl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(purl) || !purl.StartsWith("pkg:"))
|
||||
return "unknown";
|
||||
|
||||
var colonIdx = purl.IndexOf(':', 4);
|
||||
if (colonIdx < 0)
|
||||
colonIdx = purl.IndexOf('/');
|
||||
if (colonIdx < 0)
|
||||
return "unknown";
|
||||
|
||||
return purl[4..colonIdx];
|
||||
}
|
||||
|
||||
private static string GetHighestSeverity(List<RiskSeverity> severities)
|
||||
{
|
||||
if (severities.Count == 0) return "unknown";
|
||||
return severities.Max().ToString().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string GetMostRestrictiveAction(List<RiskAction> actions)
|
||||
{
|
||||
if (actions.Count == 0) return "unknown";
|
||||
return actions.Max().ToString().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ComputePredicateHash(Dictionary<string, object> predicate)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(predicate, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
return Convert.ToHexString(bytes)[..8].ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string SummarizePredicate(Dictionary<string, object> predicate)
|
||||
{
|
||||
var parts = predicate.Select(kvp => $"{kvp.Key}={kvp.Value}");
|
||||
return string.Join(", ", parts);
|
||||
}
|
||||
|
||||
private static string ComputeDeterminismHash(RiskSimulationResult result, RiskProfileModel profile)
|
||||
{
|
||||
var input = $"{result.SimulationId}:{result.ProfileHash}:{result.FindingScores.Count}";
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return $"sha256:{Convert.ToHexString(bytes)[..16].ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
// Helper classes for tracking state during computation
|
||||
private sealed class SeverityOverrideTracker(string hash, string summary, string targetSeverity)
|
||||
{
|
||||
public string Hash { get; } = hash;
|
||||
public string Summary { get; } = summary;
|
||||
public string TargetSeverity { get; } = targetSeverity;
|
||||
public int Count { get; set; }
|
||||
public Dictionary<string, int> OriginalSeverities { get; } = new();
|
||||
}
|
||||
|
||||
private sealed class DecisionOverrideTracker(string hash, string summary, string targetAction, string? reason)
|
||||
{
|
||||
public string Hash { get; } = hash;
|
||||
public string Summary { get; } = summary;
|
||||
public string TargetAction { get; } = targetAction;
|
||||
public string? Reason { get; } = reason;
|
||||
public int Count { get; set; }
|
||||
public Dictionary<string, int> OriginalActions { get; } = new();
|
||||
}
|
||||
|
||||
private sealed class SeverityBucketBuilder(string severity)
|
||||
{
|
||||
public string Severity { get; } = severity;
|
||||
public int Count { get; set; }
|
||||
public List<double> Scores { get; } = new();
|
||||
public Dictionary<string, int> TopContributors { get; } = new();
|
||||
}
|
||||
|
||||
private sealed class ActionBucketBuilder(string action)
|
||||
{
|
||||
public string Action { get; } = action;
|
||||
public int Count { get; set; }
|
||||
public List<double> Scores { get; } = new();
|
||||
public Dictionary<string, int> SeverityCounts { get; } = new();
|
||||
}
|
||||
|
||||
private sealed class ComponentScoreTracker(string purl)
|
||||
{
|
||||
public string Purl { get; } = purl;
|
||||
public List<double> Scores { get; } = new();
|
||||
public List<RiskSeverity> Severities { get; } = new();
|
||||
public List<RiskAction> Actions { get; } = new();
|
||||
}
|
||||
|
||||
private sealed class EcosystemTracker(string ecosystem)
|
||||
{
|
||||
public string Ecosystem { get; } = ecosystem;
|
||||
public HashSet<string> Components { get; } = new();
|
||||
public int FindingCount { get; set; }
|
||||
public List<double> Scores { get; } = new();
|
||||
public int CriticalCount { get; set; }
|
||||
public int HighCount { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for risk simulation breakdown generation.
|
||||
/// </summary>
|
||||
public sealed record RiskSimulationBreakdownOptions
|
||||
{
|
||||
/// <summary>Whether to include component breakdown analysis.</summary>
|
||||
public bool IncludeComponentBreakdown { get; init; } = true;
|
||||
|
||||
/// <summary>Whether to include value histograms for signals.</summary>
|
||||
public bool IncludeHistograms { get; init; } = true;
|
||||
|
||||
/// <summary>Number of histogram buckets.</summary>
|
||||
public int HistogramBuckets { get; init; } = 10;
|
||||
|
||||
/// <summary>Number of score buckets for distribution.</summary>
|
||||
public int ScoreBucketCount { get; init; } = 10;
|
||||
|
||||
/// <summary>Number of top signal contributors to include.</summary>
|
||||
public int TopContributorsCount { get; init; } = 10;
|
||||
|
||||
/// <summary>Number of top components to include.</summary>
|
||||
public int TopComponentsCount { get; init; } = 20;
|
||||
|
||||
/// <summary>Default options.</summary>
|
||||
public static RiskSimulationBreakdownOptions Default { get; } = new();
|
||||
|
||||
/// <summary>Minimal options for quick analysis.</summary>
|
||||
public static RiskSimulationBreakdownOptions Quick { get; } = new()
|
||||
{
|
||||
IncludeComponentBreakdown = false,
|
||||
IncludeHistograms = false,
|
||||
TopContributorsCount = 5,
|
||||
TopComponentsCount = 10
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,7 @@ namespace StellaOps.Policy.Engine.Simulation;
|
||||
|
||||
/// <summary>
|
||||
/// Service for running risk simulations with score distributions and contribution breakdowns.
|
||||
/// Enhanced with detailed breakdown analytics per POLICY-RISK-67-003.
|
||||
/// </summary>
|
||||
public sealed class RiskSimulationService
|
||||
{
|
||||
@@ -20,6 +21,7 @@ public sealed class RiskSimulationService
|
||||
private readonly RiskProfileConfigurationService _profileService;
|
||||
private readonly RiskProfileHasher _hasher;
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
private readonly RiskSimulationBreakdownService? _breakdownService;
|
||||
|
||||
private static readonly double[] PercentileLevels = { 0.25, 0.50, 0.75, 0.90, 0.95, 0.99 };
|
||||
private const int TopMoverCount = 10;
|
||||
@@ -29,13 +31,15 @@ public sealed class RiskSimulationService
|
||||
ILogger<RiskSimulationService> logger,
|
||||
TimeProvider timeProvider,
|
||||
RiskProfileConfigurationService profileService,
|
||||
ICryptoHash cryptoHash)
|
||||
ICryptoHash cryptoHash,
|
||||
RiskSimulationBreakdownService? breakdownService = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_profileService = profileService ?? throw new ArgumentNullException(nameof(profileService));
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
_hasher = new RiskProfileHasher(cryptoHash);
|
||||
_breakdownService = breakdownService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -461,4 +465,183 @@ public sealed class RiskSimulationService
|
||||
var hash = _cryptoHash.ComputeHashHexForPurpose(Encoding.UTF8.GetBytes(seed), HashPurpose.Content);
|
||||
return $"rsim-{hash[..16]}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a risk simulation with detailed breakdown analytics.
|
||||
/// Per POLICY-RISK-67-003.
|
||||
/// </summary>
|
||||
public RiskSimulationWithBreakdown SimulateWithBreakdown(
|
||||
RiskSimulationRequest request,
|
||||
RiskSimulationBreakdownOptions? breakdownOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (_breakdownService == null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Breakdown service not available. Register RiskSimulationBreakdownService in DI.");
|
||||
}
|
||||
|
||||
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity("risk_simulation.run_with_breakdown");
|
||||
activity?.SetTag("profile.id", request.ProfileId);
|
||||
activity?.SetTag("finding.count", request.Findings.Count);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
// Run simulation with contributions enabled for breakdown
|
||||
var simulationRequest = request with { IncludeContributions = true };
|
||||
var result = Simulate(simulationRequest);
|
||||
|
||||
var profile = _profileService.GetProfile(request.ProfileId);
|
||||
if (profile == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Risk profile '{request.ProfileId}' not found.");
|
||||
}
|
||||
|
||||
// Generate breakdown
|
||||
var breakdown = _breakdownService.GenerateBreakdown(
|
||||
result,
|
||||
profile,
|
||||
request.Findings,
|
||||
breakdownOptions);
|
||||
|
||||
sw.Stop();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Risk simulation with breakdown {SimulationId} completed in {ElapsedMs}ms",
|
||||
result.SimulationId, sw.Elapsed.TotalMilliseconds);
|
||||
|
||||
PolicyEngineTelemetry.RiskSimulationsRun.Add(1);
|
||||
|
||||
return new RiskSimulationWithBreakdown(result, breakdown, sw.Elapsed.TotalMilliseconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a comparison simulation between two profiles with trend analysis.
|
||||
/// Per POLICY-RISK-67-003.
|
||||
/// </summary>
|
||||
public RiskProfileComparisonResult CompareProfilesWithBreakdown(
|
||||
string baseProfileId,
|
||||
string compareProfileId,
|
||||
IReadOnlyList<SimulationFinding> findings,
|
||||
RiskSimulationBreakdownOptions? breakdownOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNullOrWhiteSpace(baseProfileId);
|
||||
ArgumentNullException.ThrowIfNullOrWhiteSpace(compareProfileId);
|
||||
ArgumentNullException.ThrowIfNull(findings);
|
||||
|
||||
if (_breakdownService == null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Breakdown service not available. Register RiskSimulationBreakdownService in DI.");
|
||||
}
|
||||
|
||||
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity("risk_simulation.compare_profiles");
|
||||
activity?.SetTag("profile.base", baseProfileId);
|
||||
activity?.SetTag("profile.compare", compareProfileId);
|
||||
activity?.SetTag("finding.count", findings.Count);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
// Run baseline simulation
|
||||
var baselineRequest = new RiskSimulationRequest(
|
||||
ProfileId: baseProfileId,
|
||||
ProfileVersion: null,
|
||||
Findings: findings,
|
||||
IncludeContributions: true,
|
||||
IncludeDistribution: true,
|
||||
Mode: SimulationMode.Full);
|
||||
var baselineResult = Simulate(baselineRequest);
|
||||
|
||||
// Run comparison simulation
|
||||
var compareRequest = new RiskSimulationRequest(
|
||||
ProfileId: compareProfileId,
|
||||
ProfileVersion: null,
|
||||
Findings: findings,
|
||||
IncludeContributions: true,
|
||||
IncludeDistribution: true,
|
||||
Mode: SimulationMode.Full);
|
||||
var compareResult = Simulate(compareRequest);
|
||||
|
||||
// Get profiles
|
||||
var baseProfile = _profileService.GetProfile(baseProfileId)
|
||||
?? throw new InvalidOperationException($"Profile '{baseProfileId}' not found.");
|
||||
var compareProfile = _profileService.GetProfile(compareProfileId)
|
||||
?? throw new InvalidOperationException($"Profile '{compareProfileId}' not found.");
|
||||
|
||||
// Generate breakdown with trends
|
||||
var breakdown = _breakdownService.GenerateComparisonBreakdown(
|
||||
baselineResult,
|
||||
compareResult,
|
||||
baseProfile,
|
||||
compareProfile,
|
||||
findings,
|
||||
breakdownOptions);
|
||||
|
||||
sw.Stop();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Profile comparison completed between {BaseProfile} and {CompareProfile} in {ElapsedMs}ms",
|
||||
baseProfileId, compareProfileId, sw.Elapsed.TotalMilliseconds);
|
||||
|
||||
return new RiskProfileComparisonResult(
|
||||
BaselineResult: baselineResult,
|
||||
CompareResult: compareResult,
|
||||
Breakdown: breakdown,
|
||||
ExecutionTimeMs: sw.Elapsed.TotalMilliseconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a standalone breakdown for an existing simulation result.
|
||||
/// </summary>
|
||||
public RiskSimulationBreakdown GenerateBreakdown(
|
||||
RiskSimulationResult result,
|
||||
IReadOnlyList<SimulationFinding> findings,
|
||||
RiskSimulationBreakdownOptions? options = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
ArgumentNullException.ThrowIfNull(findings);
|
||||
|
||||
if (_breakdownService == null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Breakdown service not available. Register RiskSimulationBreakdownService in DI.");
|
||||
}
|
||||
|
||||
var profile = _profileService.GetProfile(result.ProfileId)
|
||||
?? throw new InvalidOperationException($"Profile '{result.ProfileId}' not found.");
|
||||
|
||||
return _breakdownService.GenerateBreakdown(result, profile, findings, options);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Risk simulation result with detailed breakdown.
|
||||
/// Per POLICY-RISK-67-003.
|
||||
/// </summary>
|
||||
public sealed record RiskSimulationWithBreakdown(
|
||||
/// <summary>The simulation result.</summary>
|
||||
RiskSimulationResult Result,
|
||||
|
||||
/// <summary>Detailed breakdown analytics.</summary>
|
||||
RiskSimulationBreakdown Breakdown,
|
||||
|
||||
/// <summary>Total execution time including breakdown generation.</summary>
|
||||
double TotalExecutionTimeMs);
|
||||
|
||||
/// <summary>
|
||||
/// Result of comparing two risk profiles.
|
||||
/// Per POLICY-RISK-67-003.
|
||||
/// </summary>
|
||||
public sealed record RiskProfileComparisonResult(
|
||||
/// <summary>Baseline simulation result.</summary>
|
||||
RiskSimulationResult BaselineResult,
|
||||
|
||||
/// <summary>Comparison simulation result.</summary>
|
||||
RiskSimulationResult CompareResult,
|
||||
|
||||
/// <summary>Breakdown with trend analysis.</summary>
|
||||
RiskSimulationBreakdown Breakdown,
|
||||
|
||||
/// <summary>Total execution time.</summary>
|
||||
double ExecutionTimeMs);
|
||||
|
||||
@@ -585,6 +585,72 @@ public static class PolicyEngineTelemetry
|
||||
|
||||
#endregion
|
||||
|
||||
#region AirGap/Staleness Metrics
|
||||
|
||||
// Counter: policy_airgap_staleness_events_total{tenant,event_type}
|
||||
private static readonly Counter<long> StalenessEventsCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_airgap_staleness_events_total",
|
||||
unit: "events",
|
||||
description: "Total staleness events by type (warning, breach, recovered, anchor_missing).");
|
||||
|
||||
// Gauge: policy_airgap_sealed
|
||||
private static readonly ObservableGauge<int> AirGapSealedGauge =
|
||||
Meter.CreateObservableGauge<int>(
|
||||
"policy_airgap_sealed",
|
||||
observeValues: () => AirGapSealedObservations ?? Enumerable.Empty<Measurement<int>>(),
|
||||
unit: "boolean",
|
||||
description: "1 if sealed, 0 if unsealed.");
|
||||
|
||||
// Gauge: policy_airgap_anchor_age_seconds
|
||||
private static readonly ObservableGauge<int> AnchorAgeGauge =
|
||||
Meter.CreateObservableGauge<int>(
|
||||
"policy_airgap_anchor_age_seconds",
|
||||
observeValues: () => AnchorAgeObservations ?? Enumerable.Empty<Measurement<int>>(),
|
||||
unit: "s",
|
||||
description: "Current age of the time anchor in seconds.");
|
||||
|
||||
private static IEnumerable<Measurement<int>> AirGapSealedObservations = Enumerable.Empty<Measurement<int>>();
|
||||
private static IEnumerable<Measurement<int>> AnchorAgeObservations = Enumerable.Empty<Measurement<int>>();
|
||||
|
||||
/// <summary>
|
||||
/// Records a staleness event.
|
||||
/// </summary>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="eventType">Event type (warning, breach, recovered, anchor_missing).</param>
|
||||
public static void RecordStalenessEvent(string tenant, string eventType)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "tenant", NormalizeTenant(tenant) },
|
||||
{ "event_type", NormalizeTag(eventType) },
|
||||
};
|
||||
|
||||
StalenessEventsCounter.Add(1, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a callback to observe air-gap sealed state.
|
||||
/// </summary>
|
||||
/// <param name="observeFunc">Function that returns current sealed state measurements.</param>
|
||||
public static void RegisterAirGapSealedObservation(Func<IEnumerable<Measurement<int>>> observeFunc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(observeFunc);
|
||||
AirGapSealedObservations = observeFunc();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a callback to observe time anchor age.
|
||||
/// </summary>
|
||||
/// <param name="observeFunc">Function that returns current anchor age measurements.</param>
|
||||
public static void RegisterAnchorAgeObservation(Func<IEnumerable<Measurement<int>>> observeFunc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(observeFunc);
|
||||
AnchorAgeObservations = observeFunc();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
// Storage for observable gauge observations
|
||||
private static IEnumerable<Measurement<int>> QueueDepthObservations = Enumerable.Empty<Measurement<int>>();
|
||||
private static IEnumerable<Measurement<int>> ConcurrentEvaluationsObservations = Enumerable.Empty<Measurement<int>>();
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Policy.RiskProfile.Scope;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing effective policies with subject pattern matching and priority resolution.
|
||||
/// Implements CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008.
|
||||
/// </summary>
|
||||
public sealed class EffectivePolicyService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ConcurrentDictionary<string, EffectivePolicy> _policies;
|
||||
private readonly ConcurrentDictionary<string, AuthorityScopeAttachment> _scopeAttachments;
|
||||
private readonly ConcurrentDictionary<string, List<string>> _policyAttachmentIndex;
|
||||
|
||||
public EffectivePolicyService(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_policies = new ConcurrentDictionary<string, EffectivePolicy>(StringComparer.OrdinalIgnoreCase);
|
||||
_scopeAttachments = new ConcurrentDictionary<string, AuthorityScopeAttachment>(StringComparer.OrdinalIgnoreCase);
|
||||
_policyAttachmentIndex = new ConcurrentDictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new effective policy.
|
||||
/// </summary>
|
||||
public EffectivePolicy Create(CreateEffectivePolicyRequest request, string? createdBy = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.TenantId))
|
||||
{
|
||||
throw new ArgumentException("TenantId is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.PolicyId))
|
||||
{
|
||||
throw new ArgumentException("PolicyId is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.SubjectPattern))
|
||||
{
|
||||
throw new ArgumentException("SubjectPattern is required.");
|
||||
}
|
||||
|
||||
if (!IsValidSubjectPattern(request.SubjectPattern))
|
||||
{
|
||||
throw new ArgumentException($"Invalid subject pattern: {request.SubjectPattern}");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var id = GeneratePolicyId(request.TenantId, request.PolicyId, request.SubjectPattern, now);
|
||||
|
||||
var policy = new EffectivePolicy(
|
||||
EffectivePolicyId: id,
|
||||
TenantId: request.TenantId,
|
||||
PolicyId: request.PolicyId,
|
||||
PolicyVersion: request.PolicyVersion,
|
||||
SubjectPattern: request.SubjectPattern,
|
||||
Priority: request.Priority,
|
||||
Enabled: request.Enabled,
|
||||
ExpiresAt: request.ExpiresAt,
|
||||
Scopes: request.Scopes?.ToList().AsReadOnly(),
|
||||
CreatedAt: now,
|
||||
CreatedBy: createdBy,
|
||||
UpdatedAt: now);
|
||||
|
||||
_policies[id] = policy;
|
||||
return policy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an effective policy by ID.
|
||||
/// </summary>
|
||||
public EffectivePolicy? Get(string effectivePolicyId)
|
||||
{
|
||||
return _policies.TryGetValue(effectivePolicyId, out var policy) ? policy : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an effective policy.
|
||||
/// </summary>
|
||||
public EffectivePolicy? Update(string effectivePolicyId, UpdateEffectivePolicyRequest request, string? updatedBy = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (!_policies.TryGetValue(effectivePolicyId, out var existing))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var updated = existing with
|
||||
{
|
||||
Priority = request.Priority ?? existing.Priority,
|
||||
Enabled = request.Enabled ?? existing.Enabled,
|
||||
ExpiresAt = request.ExpiresAt ?? existing.ExpiresAt,
|
||||
Scopes = request.Scopes?.ToList().AsReadOnly() ?? existing.Scopes,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
_policies[effectivePolicyId] = updated;
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an effective policy.
|
||||
/// </summary>
|
||||
public bool Delete(string effectivePolicyId)
|
||||
{
|
||||
if (_policies.TryRemove(effectivePolicyId, out _))
|
||||
{
|
||||
// Remove associated scope attachments
|
||||
if (_policyAttachmentIndex.TryRemove(effectivePolicyId, out var attachmentIds))
|
||||
{
|
||||
foreach (var attachmentId in attachmentIds)
|
||||
{
|
||||
_scopeAttachments.TryRemove(attachmentId, out _);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists effective policies matching query criteria.
|
||||
/// </summary>
|
||||
public IReadOnlyList<EffectivePolicy> Query(EffectivePolicyQuery query)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
IEnumerable<EffectivePolicy> results = _policies.Values;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.TenantId))
|
||||
{
|
||||
results = results.Where(p => p.TenantId.Equals(query.TenantId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.PolicyId))
|
||||
{
|
||||
results = results.Where(p => p.PolicyId.Equals(query.PolicyId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (query.EnabledOnly)
|
||||
{
|
||||
results = results.Where(p => p.Enabled);
|
||||
}
|
||||
|
||||
if (!query.IncludeExpired)
|
||||
{
|
||||
results = results.Where(p => !p.ExpiresAt.HasValue || p.ExpiresAt.Value > now);
|
||||
}
|
||||
|
||||
return results
|
||||
.OrderByDescending(p => p.Priority)
|
||||
.ThenByDescending(p => p.UpdatedAt)
|
||||
.Take(query.Limit)
|
||||
.ToList()
|
||||
.AsReadOnly();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches an authority scope to an effective policy.
|
||||
/// </summary>
|
||||
public AuthorityScopeAttachment AttachScope(AttachAuthorityScopeRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.EffectivePolicyId))
|
||||
{
|
||||
throw new ArgumentException("EffectivePolicyId is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Scope))
|
||||
{
|
||||
throw new ArgumentException("Scope is required.");
|
||||
}
|
||||
|
||||
if (!_policies.ContainsKey(request.EffectivePolicyId))
|
||||
{
|
||||
throw new ArgumentException($"Effective policy '{request.EffectivePolicyId}' not found.");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var id = GenerateAttachmentId(request.EffectivePolicyId, request.Scope, now);
|
||||
|
||||
var attachment = new AuthorityScopeAttachment(
|
||||
AttachmentId: id,
|
||||
EffectivePolicyId: request.EffectivePolicyId,
|
||||
Scope: request.Scope,
|
||||
Conditions: request.Conditions,
|
||||
CreatedAt: now);
|
||||
|
||||
_scopeAttachments[id] = attachment;
|
||||
IndexAttachment(attachment);
|
||||
|
||||
return attachment;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detaches an authority scope.
|
||||
/// </summary>
|
||||
public bool DetachScope(string attachmentId)
|
||||
{
|
||||
if (_scopeAttachments.TryRemove(attachmentId, out var attachment))
|
||||
{
|
||||
RemoveFromIndex(attachment);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all scope attachments for an effective policy.
|
||||
/// </summary>
|
||||
public IReadOnlyList<AuthorityScopeAttachment> GetScopeAttachments(string effectivePolicyId)
|
||||
{
|
||||
if (_policyAttachmentIndex.TryGetValue(effectivePolicyId, out var attachmentIds))
|
||||
{
|
||||
lock (attachmentIds)
|
||||
{
|
||||
return attachmentIds
|
||||
.Select(id => _scopeAttachments.TryGetValue(id, out var a) ? a : null)
|
||||
.Where(a => a != null)
|
||||
.Cast<AuthorityScopeAttachment>()
|
||||
.ToList()
|
||||
.AsReadOnly();
|
||||
}
|
||||
}
|
||||
return Array.Empty<AuthorityScopeAttachment>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the effective policy for a subject using priority and specificity rules.
|
||||
/// Priority resolution order:
|
||||
/// 1. Higher priority value wins
|
||||
/// 2. If equal priority, more specific pattern wins
|
||||
/// 3. If equal specificity, most recently updated wins
|
||||
/// </summary>
|
||||
public EffectivePolicyResolutionResult Resolve(string subject, string? tenantId = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subject);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Find all matching policies
|
||||
var matchingPolicies = _policies.Values
|
||||
.Where(p => p.Enabled)
|
||||
.Where(p => string.IsNullOrWhiteSpace(tenantId) || p.TenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(p => !p.ExpiresAt.HasValue || p.ExpiresAt.Value > now)
|
||||
.Where(p => MatchesPattern(subject, p.SubjectPattern))
|
||||
.ToList();
|
||||
|
||||
if (matchingPolicies.Count == 0)
|
||||
{
|
||||
sw.Stop();
|
||||
return new EffectivePolicyResolutionResult(
|
||||
Subject: subject,
|
||||
EffectivePolicy: null,
|
||||
GrantedScopes: Array.Empty<string>(),
|
||||
MatchedPattern: null,
|
||||
ResolutionTimeMs: sw.Elapsed.TotalMilliseconds);
|
||||
}
|
||||
|
||||
// Apply priority resolution rules
|
||||
var winner = matchingPolicies
|
||||
.OrderByDescending(p => p.Priority)
|
||||
.ThenByDescending(p => GetPatternSpecificity(p.SubjectPattern))
|
||||
.ThenByDescending(p => p.UpdatedAt)
|
||||
.First();
|
||||
|
||||
// Collect granted scopes from the winning policy and its attachments
|
||||
var grantedScopes = new List<string>();
|
||||
if (winner.Scopes != null)
|
||||
{
|
||||
grantedScopes.AddRange(winner.Scopes);
|
||||
}
|
||||
|
||||
// Add scopes from attachments
|
||||
var attachments = GetScopeAttachments(winner.EffectivePolicyId);
|
||||
foreach (var attachment in attachments)
|
||||
{
|
||||
if (!grantedScopes.Contains(attachment.Scope, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
grantedScopes.Add(attachment.Scope);
|
||||
}
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
|
||||
return new EffectivePolicyResolutionResult(
|
||||
Subject: subject,
|
||||
EffectivePolicy: winner,
|
||||
GrantedScopes: grantedScopes.AsReadOnly(),
|
||||
MatchedPattern: winner.SubjectPattern,
|
||||
ResolutionTimeMs: sw.Elapsed.TotalMilliseconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a subject pattern.
|
||||
/// Valid patterns: *, pkg:*, pkg:npm/*, pkg:npm/@org/*, oci://registry/*
|
||||
/// </summary>
|
||||
public static bool IsValidSubjectPattern(string pattern)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pattern))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Universal wildcard
|
||||
if (pattern == "*")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Must be a valid PURL or OCI pattern with optional wildcards
|
||||
if (pattern.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase) ||
|
||||
pattern.StartsWith("oci://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Pattern should not have consecutive wildcards or invalid chars
|
||||
if (pattern.Contains("**", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a subject matches a glob-style pattern.
|
||||
/// </summary>
|
||||
public static bool MatchesPattern(string subject, string pattern)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(subject) || string.IsNullOrWhiteSpace(pattern))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Universal wildcard matches everything
|
||||
if (pattern == "*")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Convert glob pattern to regex
|
||||
var regexPattern = GlobToRegex(pattern);
|
||||
return Regex.IsMatch(subject, regexPattern, RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the specificity score of a pattern (higher = more specific).
|
||||
/// Scoring: length of non-wildcard characters * 10, bonus for segment depth
|
||||
/// </summary>
|
||||
public static int GetPatternSpecificity(string pattern)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pattern))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Universal wildcard is least specific
|
||||
if (pattern == "*")
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Count literal (non-wildcard) characters
|
||||
var literalChars = pattern.Count(c => c != '*');
|
||||
|
||||
// Count path segments (depth bonus)
|
||||
var segmentCount = pattern.Count(c => c == '/') + 1;
|
||||
|
||||
// Base score: literal characters weighted heavily
|
||||
// Segment bonus: more segments = more specific
|
||||
return (literalChars * 10) + (segmentCount * 5);
|
||||
}
|
||||
|
||||
private static string GlobToRegex(string pattern)
|
||||
{
|
||||
// Escape regex special characters except *
|
||||
var escaped = Regex.Escape(pattern);
|
||||
|
||||
// Replace escaped wildcards with regex equivalents
|
||||
// For trailing wildcards, match everything (including /)
|
||||
// For middle wildcards, match single path segment only
|
||||
if (escaped.EndsWith(@"\*", StringComparison.Ordinal))
|
||||
{
|
||||
// Trailing wildcard: match everything remaining
|
||||
escaped = escaped[..^2] + ".*";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Non-trailing wildcards: match single path segment
|
||||
escaped = escaped.Replace(@"\*", @"[^/]*");
|
||||
}
|
||||
|
||||
return $"^{escaped}$";
|
||||
}
|
||||
|
||||
private void IndexAttachment(AuthorityScopeAttachment attachment)
|
||||
{
|
||||
var list = _policyAttachmentIndex.GetOrAdd(attachment.EffectivePolicyId, _ => new List<string>());
|
||||
lock (list)
|
||||
{
|
||||
if (!list.Contains(attachment.AttachmentId))
|
||||
{
|
||||
list.Add(attachment.AttachmentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveFromIndex(AuthorityScopeAttachment attachment)
|
||||
{
|
||||
if (_policyAttachmentIndex.TryGetValue(attachment.EffectivePolicyId, out var list))
|
||||
{
|
||||
lock (list)
|
||||
{
|
||||
list.Remove(attachment.AttachmentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string GeneratePolicyId(string tenantId, string policyId, string pattern, DateTimeOffset timestamp)
|
||||
{
|
||||
var seed = $"{tenantId}|{policyId}|{pattern}|{timestamp:O}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
|
||||
return $"eff-{Convert.ToHexStringLower(hash)[..16]}";
|
||||
}
|
||||
|
||||
private static string GenerateAttachmentId(string policyId, string scope, DateTimeOffset timestamp)
|
||||
{
|
||||
var seed = $"{policyId}|{scope}|{timestamp:O}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
|
||||
return $"att-{Convert.ToHexStringLower(hash)[..16]}";
|
||||
}
|
||||
}
|
||||
@@ -107,3 +107,81 @@ public sealed record ScopeResolutionResult(
|
||||
[property: JsonPropertyName("resolved_profile")] ResolvedScopeProfile? ResolvedProfile,
|
||||
[property: JsonPropertyName("applicable_attachments")] IReadOnlyList<ScopeAttachment> ApplicableAttachments,
|
||||
[property: JsonPropertyName("resolution_time_ms")] double ResolutionTimeMs);
|
||||
|
||||
/// <summary>
|
||||
/// Effective policy attachment with subject pattern matching and priority rules.
|
||||
/// Per CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008.
|
||||
/// </summary>
|
||||
public sealed record EffectivePolicy(
|
||||
[property: JsonPropertyName("effective_policy_id")] string EffectivePolicyId,
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("policy_id")] string PolicyId,
|
||||
[property: JsonPropertyName("policy_version")] string? PolicyVersion,
|
||||
[property: JsonPropertyName("subject_pattern")] string SubjectPattern,
|
||||
[property: JsonPropertyName("priority")] int Priority,
|
||||
[property: JsonPropertyName("enabled")] bool Enabled,
|
||||
[property: JsonPropertyName("expires_at")] DateTimeOffset? ExpiresAt,
|
||||
[property: JsonPropertyName("scopes")] IReadOnlyList<string>? Scopes,
|
||||
[property: JsonPropertyName("created_at")] DateTimeOffset CreatedAt,
|
||||
[property: JsonPropertyName("created_by")] string? CreatedBy,
|
||||
[property: JsonPropertyName("updated_at")] DateTimeOffset UpdatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Request to create an effective policy.
|
||||
/// </summary>
|
||||
public sealed record CreateEffectivePolicyRequest(
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("policy_id")] string PolicyId,
|
||||
[property: JsonPropertyName("policy_version")] string? PolicyVersion,
|
||||
[property: JsonPropertyName("subject_pattern")] string SubjectPattern,
|
||||
[property: JsonPropertyName("priority")] int Priority,
|
||||
[property: JsonPropertyName("enabled")] bool Enabled = true,
|
||||
[property: JsonPropertyName("expires_at")] DateTimeOffset? ExpiresAt = null,
|
||||
[property: JsonPropertyName("scopes")] IReadOnlyList<string>? Scopes = null);
|
||||
|
||||
/// <summary>
|
||||
/// Request to update an effective policy.
|
||||
/// </summary>
|
||||
public sealed record UpdateEffectivePolicyRequest(
|
||||
[property: JsonPropertyName("priority")] int? Priority = null,
|
||||
[property: JsonPropertyName("enabled")] bool? Enabled = null,
|
||||
[property: JsonPropertyName("expires_at")] DateTimeOffset? ExpiresAt = null,
|
||||
[property: JsonPropertyName("scopes")] IReadOnlyList<string>? Scopes = null);
|
||||
|
||||
/// <summary>
|
||||
/// Authority scope attachment with conditions.
|
||||
/// </summary>
|
||||
public sealed record AuthorityScopeAttachment(
|
||||
[property: JsonPropertyName("attachment_id")] string AttachmentId,
|
||||
[property: JsonPropertyName("effective_policy_id")] string EffectivePolicyId,
|
||||
[property: JsonPropertyName("scope")] string Scope,
|
||||
[property: JsonPropertyName("conditions")] Dictionary<string, string>? Conditions,
|
||||
[property: JsonPropertyName("created_at")] DateTimeOffset CreatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Request to attach an authority scope.
|
||||
/// </summary>
|
||||
public sealed record AttachAuthorityScopeRequest(
|
||||
[property: JsonPropertyName("effective_policy_id")] string EffectivePolicyId,
|
||||
[property: JsonPropertyName("scope")] string Scope,
|
||||
[property: JsonPropertyName("conditions")] Dictionary<string, string>? Conditions = null);
|
||||
|
||||
/// <summary>
|
||||
/// Result of resolving the effective policy for a subject.
|
||||
/// </summary>
|
||||
public sealed record EffectivePolicyResolutionResult(
|
||||
[property: JsonPropertyName("subject")] string Subject,
|
||||
[property: JsonPropertyName("effective_policy")] EffectivePolicy? EffectivePolicy,
|
||||
[property: JsonPropertyName("granted_scopes")] IReadOnlyList<string> GrantedScopes,
|
||||
[property: JsonPropertyName("matched_pattern")] string? MatchedPattern,
|
||||
[property: JsonPropertyName("resolution_time_ms")] double ResolutionTimeMs);
|
||||
|
||||
/// <summary>
|
||||
/// Query for listing effective policies.
|
||||
/// </summary>
|
||||
public sealed record EffectivePolicyQuery(
|
||||
[property: JsonPropertyName("tenant_id")] string? TenantId = null,
|
||||
[property: JsonPropertyName("policy_id")] string? PolicyId = null,
|
||||
[property: JsonPropertyName("enabled_only")] bool EnabledOnly = true,
|
||||
[property: JsonPropertyName("include_expired")] bool IncludeExpired = false,
|
||||
[property: JsonPropertyName("limit")] int Limit = 100);
|
||||
|
||||
@@ -0,0 +1,660 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Policy.Engine.AirGap;
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.AirGap;
|
||||
|
||||
public sealed class RiskProfileAirGapExportServiceTests
|
||||
{
|
||||
private readonly FakeCryptoHash _cryptoHash = new();
|
||||
private readonly FakeTimeProvider _timeProvider = new();
|
||||
private readonly NullLogger<RiskProfileAirGapExportService> _logger = new();
|
||||
|
||||
private RiskProfileAirGapExportService CreateService(ISealedModeService? sealedMode = null)
|
||||
{
|
||||
return new RiskProfileAirGapExportService(
|
||||
_cryptoHash,
|
||||
_timeProvider,
|
||||
_logger,
|
||||
sealedMode);
|
||||
}
|
||||
|
||||
private static RiskProfileModel CreateTestProfile(string id = "test-profile", string version = "1.0.0")
|
||||
{
|
||||
return new RiskProfileModel
|
||||
{
|
||||
Id = id,
|
||||
Version = version,
|
||||
Description = $"Test profile {id} for air-gap tests",
|
||||
Signals = new List<RiskSignal>
|
||||
{
|
||||
new() { Name = "cvss", Source = "nvd", Type = RiskSignalType.Numeric },
|
||||
new() { Name = "kev", Source = "cisa", Type = RiskSignalType.Boolean }
|
||||
},
|
||||
Weights = new Dictionary<string, double>
|
||||
{
|
||||
["cvss"] = 0.7,
|
||||
["kev"] = 0.3
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#region Export Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_SingleProfile_CreatesValidBundle()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var profile = CreateTestProfile();
|
||||
var profiles = new List<RiskProfileModel> { profile };
|
||||
var request = new AirGapExportRequest(SignBundle: true);
|
||||
|
||||
// Act
|
||||
var bundle = await service.ExportAsync(profiles, request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(bundle);
|
||||
Assert.Equal(1, bundle.SchemaVersion);
|
||||
Assert.Equal("risk-profiles", bundle.DomainId);
|
||||
Assert.Single(bundle.Exports);
|
||||
Assert.NotNull(bundle.MerkleRoot);
|
||||
Assert.NotNull(bundle.Signature);
|
||||
Assert.NotNull(bundle.Profiles);
|
||||
Assert.Single(bundle.Profiles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_MultipleProfiles_CreatesExportForEach()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var profiles = new List<RiskProfileModel>
|
||||
{
|
||||
CreateTestProfile("profile-1", "1.0.0"),
|
||||
CreateTestProfile("profile-2", "2.0.0"),
|
||||
CreateTestProfile("profile-3", "1.5.0")
|
||||
};
|
||||
var request = new AirGapExportRequest(SignBundle: true);
|
||||
|
||||
// Act
|
||||
var bundle = await service.ExportAsync(profiles, request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, bundle.Exports.Count);
|
||||
Assert.Equal(3, bundle.Profiles?.Count);
|
||||
foreach (var export in bundle.Exports)
|
||||
{
|
||||
Assert.NotEmpty(export.ContentHash);
|
||||
Assert.NotEmpty(export.ArtifactDigest);
|
||||
Assert.Contains("sha256:", export.ArtifactDigest);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_WithoutSigning_OmitsSignature()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var profile = CreateTestProfile();
|
||||
var profiles = new List<RiskProfileModel> { profile };
|
||||
var request = new AirGapExportRequest(SignBundle: false);
|
||||
|
||||
// Act
|
||||
var bundle = await service.ExportAsync(profiles, request);
|
||||
|
||||
// Assert
|
||||
Assert.Null(bundle.Signature);
|
||||
Assert.NotNull(bundle.MerkleRoot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_WithTenant_IncludesTenantId()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var profile = CreateTestProfile();
|
||||
var profiles = new List<RiskProfileModel> { profile };
|
||||
var request = new AirGapExportRequest();
|
||||
|
||||
// Act
|
||||
var bundle = await service.ExportAsync(profiles, request, "tenant-123");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("tenant-123", bundle.TenantId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_WithDisplayName_UsesProvidedName()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var profile = CreateTestProfile();
|
||||
var profiles = new List<RiskProfileModel> { profile };
|
||||
var request = new AirGapExportRequest(DisplayName: "Custom Bundle Name");
|
||||
|
||||
// Act
|
||||
var bundle = await service.ExportAsync(profiles, request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Custom Bundle Name", bundle.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_EmptyProfiles_CreatesEmptyBundle()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var profiles = new List<RiskProfileModel>();
|
||||
var request = new AirGapExportRequest();
|
||||
|
||||
// Act
|
||||
var bundle = await service.ExportAsync(profiles, request);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(bundle.Exports);
|
||||
Assert.Empty(bundle.MerkleRoot ?? "");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_ComputesCorrectMerkleRoot()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var profiles = new List<RiskProfileModel>
|
||||
{
|
||||
CreateTestProfile("profile-a"),
|
||||
CreateTestProfile("profile-b")
|
||||
};
|
||||
var request = new AirGapExportRequest();
|
||||
|
||||
// Act
|
||||
var bundle1 = await service.ExportAsync(profiles, request);
|
||||
var bundle2 = await service.ExportAsync(profiles, request);
|
||||
|
||||
// Assert - same profiles should produce same merkle root
|
||||
Assert.Equal(bundle1.MerkleRoot, bundle2.MerkleRoot);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Import Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_ValidBundle_ImportsSuccessfully()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var profile = CreateTestProfile();
|
||||
var profiles = new List<RiskProfileModel> { profile };
|
||||
var exportRequest = new AirGapExportRequest(SignBundle: true);
|
||||
var bundle = await service.ExportAsync(profiles, exportRequest);
|
||||
|
||||
var importRequest = new AirGapImportRequest(
|
||||
VerifySignature: true,
|
||||
VerifyMerkle: true,
|
||||
EnforceSealedMode: false);
|
||||
|
||||
// Act
|
||||
var result = await service.ImportAsync(bundle, importRequest, "tenant-123");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(1, result.TotalCount);
|
||||
Assert.Equal(1, result.ImportedCount);
|
||||
Assert.Equal(0, result.ErrorCount);
|
||||
Assert.True(result.SignatureVerified);
|
||||
Assert.True(result.MerkleVerified);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_TamperedBundle_FailsMerkleVerification()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var profile = CreateTestProfile();
|
||||
var profiles = new List<RiskProfileModel> { profile };
|
||||
var exportRequest = new AirGapExportRequest(SignBundle: true);
|
||||
var bundle = await service.ExportAsync(profiles, exportRequest);
|
||||
|
||||
// Tamper with merkle root
|
||||
var tamperedBundle = bundle with { MerkleRoot = "sha256:tampered" };
|
||||
|
||||
var importRequest = new AirGapImportRequest(
|
||||
VerifySignature: false,
|
||||
VerifyMerkle: true,
|
||||
RejectOnMerkleFailure: true,
|
||||
EnforceSealedMode: false);
|
||||
|
||||
// Act
|
||||
var result = await service.ImportAsync(tamperedBundle, importRequest, "tenant-123");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.False(result.MerkleVerified);
|
||||
Assert.Contains(result.Errors, e => e.Contains("Merkle root verification failed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_TamperedProfile_FailsContentHashVerification()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var profile = CreateTestProfile();
|
||||
var profiles = new List<RiskProfileModel> { profile };
|
||||
var exportRequest = new AirGapExportRequest(SignBundle: false);
|
||||
var bundle = await service.ExportAsync(profiles, exportRequest);
|
||||
|
||||
// Tamper with profile by modifying it after export
|
||||
// Need to create a completely different profile that won't match the hash
|
||||
var tamperedProfile = new RiskProfileModel
|
||||
{
|
||||
Id = profile.Id,
|
||||
Version = profile.Version,
|
||||
Description = "COMPLETELY DIFFERENT DESCRIPTION TO BREAK HASH",
|
||||
Signals = new List<RiskSignal>
|
||||
{
|
||||
new() { Name = "tampered", Source = "fake", Type = RiskSignalType.Boolean }
|
||||
},
|
||||
Weights = new Dictionary<string, double> { ["tampered"] = 1.0 }
|
||||
};
|
||||
var tamperedBundle = bundle with { Profiles = new[] { tamperedProfile } };
|
||||
|
||||
var importRequest = new AirGapImportRequest(
|
||||
VerifySignature: false,
|
||||
VerifyMerkle: false,
|
||||
EnforceSealedMode: false);
|
||||
|
||||
// Act
|
||||
var result = await service.ImportAsync(tamperedBundle, importRequest, "tenant-123");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(1, result.ErrorCount);
|
||||
Assert.Contains(result.Details, d => d.Message?.Contains("hash mismatch") == true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_MissingProfile_ReportsError()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var profile = CreateTestProfile();
|
||||
var profiles = new List<RiskProfileModel> { profile };
|
||||
var exportRequest = new AirGapExportRequest(SignBundle: false);
|
||||
var bundle = await service.ExportAsync(profiles, exportRequest);
|
||||
|
||||
// Remove profiles
|
||||
var bundleWithoutProfiles = bundle with { Profiles = Array.Empty<RiskProfileModel>() };
|
||||
|
||||
var importRequest = new AirGapImportRequest(
|
||||
VerifySignature: false,
|
||||
VerifyMerkle: false,
|
||||
EnforceSealedMode: false);
|
||||
|
||||
// Act
|
||||
var result = await service.ImportAsync(bundleWithoutProfiles, importRequest, "tenant-123");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(1, result.ErrorCount);
|
||||
Assert.Contains(result.Details, d => d.Message == "Profile data missing from bundle");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_InvalidSignature_FailsWhenEnforced()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var profile = CreateTestProfile();
|
||||
var profiles = new List<RiskProfileModel> { profile };
|
||||
var exportRequest = new AirGapExportRequest(SignBundle: true);
|
||||
var bundle = await service.ExportAsync(profiles, exportRequest);
|
||||
|
||||
// Tamper with signature
|
||||
var tamperedSignature = bundle.Signature! with { Path = "invalid-signature" };
|
||||
var tamperedBundle = bundle with { Signature = tamperedSignature };
|
||||
|
||||
var importRequest = new AirGapImportRequest(
|
||||
VerifySignature: true,
|
||||
RejectOnSignatureFailure: true,
|
||||
VerifyMerkle: false,
|
||||
EnforceSealedMode: false);
|
||||
|
||||
// Act
|
||||
var result = await service.ImportAsync(tamperedBundle, importRequest, "tenant-123");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.False(result.SignatureVerified);
|
||||
Assert.Contains(result.Errors, e => e.Contains("signature verification failed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_SealedModeBlocked_ReturnsBlockedResult()
|
||||
{
|
||||
// Arrange
|
||||
var sealedModeService = new FakeSealedModeService(allowed: false, reason: "Environment is locked");
|
||||
var service = CreateService(sealedModeService);
|
||||
|
||||
var profile = CreateTestProfile();
|
||||
var profiles = new List<RiskProfileModel> { profile };
|
||||
var exportRequest = new AirGapExportRequest(SignBundle: false);
|
||||
var bundle = await service.ExportAsync(profiles, exportRequest);
|
||||
|
||||
var importRequest = new AirGapImportRequest(EnforceSealedMode: true);
|
||||
|
||||
// Act
|
||||
var result = await service.ImportAsync(bundle, importRequest, "tenant-123");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(0, result.ImportedCount);
|
||||
Assert.Contains(result.Errors, e => e.Contains("Sealed-mode blocked"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_SealedModeAllowed_ProceedsWithImport()
|
||||
{
|
||||
// Arrange
|
||||
var sealedModeService = new FakeSealedModeService(allowed: true);
|
||||
var service = CreateService(sealedModeService);
|
||||
|
||||
var profile = CreateTestProfile();
|
||||
var profiles = new List<RiskProfileModel> { profile };
|
||||
var exportRequest = new AirGapExportRequest(SignBundle: false);
|
||||
var bundle = await service.ExportAsync(profiles, exportRequest);
|
||||
|
||||
var importRequest = new AirGapImportRequest(
|
||||
VerifySignature: false,
|
||||
VerifyMerkle: false,
|
||||
EnforceSealedMode: true);
|
||||
|
||||
// Act
|
||||
var result = await service.ImportAsync(bundle, importRequest, "tenant-123");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(1, result.ImportedCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportAsync_RequiresTenantId()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var profile = CreateTestProfile();
|
||||
var profiles = new List<RiskProfileModel> { profile };
|
||||
var exportRequest = new AirGapExportRequest();
|
||||
var bundle = await service.ExportAsync(profiles, exportRequest);
|
||||
|
||||
var importRequest = new AirGapImportRequest();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
service.ImportAsync(bundle, importRequest, ""));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Verify Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Verify_ValidBundle_ReturnsAllValid()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var profile = CreateTestProfile();
|
||||
var profiles = new List<RiskProfileModel> { profile };
|
||||
var exportRequest = new AirGapExportRequest(SignBundle: true);
|
||||
var bundle = await service.ExportAsync(profiles, exportRequest);
|
||||
|
||||
// Act
|
||||
var verification = service.Verify(bundle);
|
||||
|
||||
// Assert
|
||||
Assert.True(verification.SignatureValid);
|
||||
Assert.True(verification.MerkleValid);
|
||||
Assert.True(verification.AllValid);
|
||||
Assert.All(verification.ExportDigests, d => Assert.True(d.Valid));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Verify_TamperedMerkle_ReturnsMerkleInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var profile = CreateTestProfile();
|
||||
var profiles = new List<RiskProfileModel> { profile };
|
||||
var exportRequest = new AirGapExportRequest(SignBundle: true);
|
||||
var bundle = await service.ExportAsync(profiles, exportRequest);
|
||||
|
||||
var tamperedBundle = bundle with { MerkleRoot = "sha256:invalid" };
|
||||
|
||||
// Act
|
||||
var verification = service.Verify(tamperedBundle);
|
||||
|
||||
// Assert
|
||||
Assert.False(verification.MerkleValid);
|
||||
Assert.False(verification.AllValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Verify_TamperedSignature_ReturnsSignatureInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var profile = CreateTestProfile();
|
||||
var profiles = new List<RiskProfileModel> { profile };
|
||||
var exportRequest = new AirGapExportRequest(SignBundle: true);
|
||||
var bundle = await service.ExportAsync(profiles, exportRequest);
|
||||
|
||||
var tamperedSignature = bundle.Signature! with { Path = "invalid" };
|
||||
var tamperedBundle = bundle with { Signature = tamperedSignature };
|
||||
|
||||
// Act
|
||||
var verification = service.Verify(tamperedBundle);
|
||||
|
||||
// Assert
|
||||
Assert.False(verification.SignatureValid);
|
||||
Assert.False(verification.AllValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Verify_TamperedProfile_ReturnsExportDigestInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var profile = CreateTestProfile();
|
||||
var profiles = new List<RiskProfileModel> { profile };
|
||||
var exportRequest = new AirGapExportRequest(SignBundle: false);
|
||||
var bundle = await service.ExportAsync(profiles, exportRequest);
|
||||
|
||||
// Tamper with profile by creating a completely different one to break hash
|
||||
var tamperedProfile = new RiskProfileModel
|
||||
{
|
||||
Id = profile.Id,
|
||||
Version = profile.Version,
|
||||
Description = "COMPLETELY DIFFERENT FOR HASH BREAK",
|
||||
Signals = new List<RiskSignal>
|
||||
{
|
||||
new() { Name = "tampered_verify", Source = "fake", Type = RiskSignalType.Categorical }
|
||||
},
|
||||
Weights = new Dictionary<string, double> { ["tampered_verify"] = 0.5 }
|
||||
};
|
||||
var tamperedBundle = bundle with { Profiles = new[] { tamperedProfile } };
|
||||
|
||||
// Act
|
||||
var verification = service.Verify(tamperedBundle);
|
||||
|
||||
// Assert
|
||||
Assert.Contains(verification.ExportDigests, d => !d.Valid);
|
||||
Assert.False(verification.AllValid);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Fakes
|
||||
|
||||
internal sealed class FakeCryptoHash : ICryptoHash
|
||||
{
|
||||
public byte[] ComputeHash(ReadOnlySpan<byte> data, string? algorithmId = null)
|
||||
{
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
return sha256.ComputeHash(data.ToArray());
|
||||
}
|
||||
|
||||
public string ComputeHashHex(ReadOnlySpan<byte> data, string? algorithmId = null)
|
||||
{
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
var hash = sha256.ComputeHash(data.ToArray());
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
public string ComputeHashBase64(ReadOnlySpan<byte> data, string? algorithmId = null)
|
||||
{
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
var hash = sha256.ComputeHash(data.ToArray());
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
|
||||
public ValueTask<byte[]> ComputeHashAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
return new ValueTask<byte[]>(sha256.ComputeHash(stream));
|
||||
}
|
||||
|
||||
public ValueTask<string> ComputeHashHexAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
var hash = sha256.ComputeHash(stream);
|
||||
return new ValueTask<string>(Convert.ToHexStringLower(hash));
|
||||
}
|
||||
|
||||
public byte[] ComputeHashForPurpose(ReadOnlySpan<byte> data, string purpose)
|
||||
{
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
return sha256.ComputeHash(data.ToArray());
|
||||
}
|
||||
|
||||
public string ComputeHashHexForPurpose(ReadOnlySpan<byte> data, string purpose)
|
||||
{
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
var hash = sha256.ComputeHash(data.ToArray());
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
public string ComputeHashBase64ForPurpose(ReadOnlySpan<byte> data, string purpose)
|
||||
{
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
var hash = sha256.ComputeHash(data.ToArray());
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
|
||||
public ValueTask<byte[]> ComputeHashForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
return new ValueTask<byte[]>(sha256.ComputeHash(stream));
|
||||
}
|
||||
|
||||
public ValueTask<string> ComputeHashHexForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
var hash = sha256.ComputeHash(stream);
|
||||
return new ValueTask<string>(Convert.ToHexStringLower(hash));
|
||||
}
|
||||
|
||||
public string GetAlgorithmForPurpose(string purpose) => "SHA256";
|
||||
|
||||
public string GetHashPrefix(string purpose) => "sha256:";
|
||||
|
||||
public string ComputePrefixedHashForPurpose(ReadOnlySpan<byte> data, string purpose)
|
||||
{
|
||||
return $"sha256:{ComputeHashHexForPurpose(data, purpose)}";
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = new(2025, 12, 6, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public void Advance(TimeSpan duration) => _now = _now.Add(duration);
|
||||
}
|
||||
|
||||
internal sealed class FakeSealedModeService : ISealedModeService
|
||||
{
|
||||
private readonly bool _allowed;
|
||||
private readonly string? _reason;
|
||||
|
||||
public FakeSealedModeService(bool allowed, string? reason = null)
|
||||
{
|
||||
_allowed = allowed;
|
||||
_reason = reason;
|
||||
}
|
||||
|
||||
public bool IsSealed => !_allowed;
|
||||
|
||||
public Task<PolicyPackSealedState> GetStateAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new PolicyPackSealedState(
|
||||
TenantId: tenantId,
|
||||
IsSealed: !_allowed,
|
||||
PolicyHash: null,
|
||||
TimeAnchor: null,
|
||||
StalenessBudget: StalenessBudget.Default,
|
||||
LastTransitionAt: DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
public Task<SealedStatusResponse> GetStatusAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new SealedStatusResponse(
|
||||
Sealed: !_allowed,
|
||||
TenantId: tenantId,
|
||||
Staleness: null,
|
||||
TimeAnchor: null,
|
||||
PolicyHash: null));
|
||||
}
|
||||
|
||||
public Task<SealResponse> SealAsync(string tenantId, SealRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new SealResponse(Sealed: true, LastTransitionAt: DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
public Task<SealResponse> UnsealAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new SealResponse(Sealed: false, LastTransitionAt: DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
public Task<StalenessEvaluation?> EvaluateStalenessAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<StalenessEvaluation?>(null);
|
||||
}
|
||||
|
||||
public Task<SealedModeEnforcementResult> EnforceBundleImportAsync(
|
||||
string tenantId, string bundlePath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new SealedModeEnforcementResult(
|
||||
Allowed: _allowed,
|
||||
Reason: _allowed ? null : _reason,
|
||||
Remediation: _allowed ? null : "Contact administrator"));
|
||||
}
|
||||
|
||||
public Task<BundleVerifyResponse> VerifyBundleAsync(
|
||||
BundleVerifyRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new BundleVerifyResponse(
|
||||
Valid: true,
|
||||
VerificationResult: new BundleVerificationResult(
|
||||
DsseValid: true,
|
||||
TufValid: true,
|
||||
MerkleValid: true,
|
||||
Error: null)));
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,493 @@
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
using StellaOps.Policy.RiskProfile.Overrides;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Overrides;
|
||||
|
||||
public sealed class OverrideServiceTests
|
||||
{
|
||||
private readonly OverrideService _service;
|
||||
|
||||
public OverrideServiceTests()
|
||||
{
|
||||
_service = new OverrideService();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_ValidRequest_ReturnsAuditedOverride()
|
||||
{
|
||||
var request = CreateValidRequest();
|
||||
|
||||
var result = _service.Create(request, "admin@example.com");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.StartsWith("ovr-", result.OverrideId);
|
||||
Assert.Equal("test-profile", result.ProfileId);
|
||||
Assert.Equal(OverrideType.Severity, result.OverrideType);
|
||||
Assert.Equal(OverrideStatus.Active, result.Status);
|
||||
Assert.Equal("admin@example.com", result.Audit.CreatedBy);
|
||||
Assert.Equal("KEV findings should be critical", result.Audit.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_ReviewRequired_CreatesDisabledOverride()
|
||||
{
|
||||
var request = new CreateOverrideRequest(
|
||||
ProfileId: "test-profile",
|
||||
OverrideType: OverrideType.Severity,
|
||||
Predicate: CreateKevPredicate(),
|
||||
Action: new OverrideAction(OverrideActionType.SetSeverity, Severity: RiskSeverity.Critical),
|
||||
Priority: 100,
|
||||
Reason: "Needs approval",
|
||||
Justification: null,
|
||||
TicketRef: null,
|
||||
Expiration: null,
|
||||
Tags: null,
|
||||
ReviewRequired: true);
|
||||
|
||||
var result = _service.Create(request);
|
||||
|
||||
Assert.Equal(OverrideStatus.Disabled, result.Status);
|
||||
Assert.True(result.Audit.ReviewRequired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_MissingReason_ThrowsException()
|
||||
{
|
||||
var request = new CreateOverrideRequest(
|
||||
ProfileId: "test-profile",
|
||||
OverrideType: OverrideType.Severity,
|
||||
Predicate: CreateKevPredicate(),
|
||||
Action: new OverrideAction(OverrideActionType.SetSeverity, Severity: RiskSeverity.Critical),
|
||||
Priority: 100,
|
||||
Reason: "",
|
||||
Justification: null,
|
||||
TicketRef: null,
|
||||
Expiration: null,
|
||||
Tags: null);
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() => _service.Create(request));
|
||||
Assert.Contains("Reason", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_ExistingOverride_ReturnsOverride()
|
||||
{
|
||||
var created = _service.Create(CreateValidRequest());
|
||||
var fetched = _service.Get(created.OverrideId);
|
||||
|
||||
Assert.NotNull(fetched);
|
||||
Assert.Equal(created.OverrideId, fetched.OverrideId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_NonExistingOverride_ReturnsNull()
|
||||
{
|
||||
var fetched = _service.Get("non-existent");
|
||||
Assert.Null(fetched);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListByProfile_ReturnsOverridesOrderedByPriority()
|
||||
{
|
||||
var request1 = CreateValidRequest() with { Priority = 50 };
|
||||
var request2 = CreateValidRequest() with { Priority = 200 };
|
||||
var request3 = CreateValidRequest() with { Priority = 100 };
|
||||
|
||||
_service.Create(request1);
|
||||
_service.Create(request2);
|
||||
_service.Create(request3);
|
||||
|
||||
var results = _service.ListByProfile("test-profile");
|
||||
|
||||
Assert.Equal(3, results.Count);
|
||||
Assert.Equal(200, results[0].Priority);
|
||||
Assert.Equal(100, results[1].Priority);
|
||||
Assert.Equal(50, results[2].Priority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListByProfile_ExcludesDisabledByDefault()
|
||||
{
|
||||
var active = _service.Create(CreateValidRequest());
|
||||
var disabled = _service.Create(CreateValidRequest() with { ReviewRequired = true });
|
||||
|
||||
var activeResults = _service.ListByProfile("test-profile", includeInactive: false);
|
||||
var allResults = _service.ListByProfile("test-profile", includeInactive: true);
|
||||
|
||||
Assert.Single(activeResults);
|
||||
Assert.Equal(active.OverrideId, activeResults[0].OverrideId);
|
||||
Assert.Equal(2, allResults.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Approve_ReviewRequiredOverride_ActivatesAndRecordsApproval()
|
||||
{
|
||||
var created = _service.Create(CreateValidRequest() with { ReviewRequired = true });
|
||||
Assert.Equal(OverrideStatus.Disabled, created.Status);
|
||||
|
||||
var approved = _service.Approve(created.OverrideId, "manager@example.com");
|
||||
|
||||
Assert.NotNull(approved);
|
||||
Assert.Equal(OverrideStatus.Active, approved.Status);
|
||||
Assert.Equal("manager@example.com", approved.Audit.ApprovedBy);
|
||||
Assert.NotNull(approved.Audit.ApprovedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Approve_AlreadyActiveOverride_ThrowsException()
|
||||
{
|
||||
var created = _service.Create(CreateValidRequest());
|
||||
Assert.Equal(OverrideStatus.Active, created.Status);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||
_service.Approve(created.OverrideId, "manager@example.com"));
|
||||
|
||||
Assert.Contains("does not require approval", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disable_ActiveOverride_DisablesAndRecordsModification()
|
||||
{
|
||||
var created = _service.Create(CreateValidRequest());
|
||||
|
||||
var disabled = _service.Disable(created.OverrideId, "admin@example.com", "No longer needed");
|
||||
|
||||
Assert.NotNull(disabled);
|
||||
Assert.Equal(OverrideStatus.Disabled, disabled.Status);
|
||||
Assert.Equal("admin@example.com", disabled.Audit.LastModifiedBy);
|
||||
Assert.NotNull(disabled.Audit.LastModifiedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Delete_ExistingOverride_RemovesFromStorage()
|
||||
{
|
||||
var created = _service.Create(CreateValidRequest());
|
||||
|
||||
var deleted = _service.Delete(created.OverrideId);
|
||||
|
||||
Assert.True(deleted);
|
||||
Assert.Null(_service.Get(created.OverrideId));
|
||||
Assert.Empty(_service.ListByProfile("test-profile"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateConflicts_SamePredicate_DetectsConflict()
|
||||
{
|
||||
var original = _service.Create(CreateValidRequest());
|
||||
|
||||
var newRequest = CreateValidRequest();
|
||||
var validation = _service.ValidateConflicts(newRequest);
|
||||
|
||||
Assert.True(validation.HasConflicts);
|
||||
Assert.Contains(validation.Conflicts, c => c.ConflictType == ConflictType.SamePredicate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateConflicts_OverlappingPredicateWithContradictoryAction_DetectsConflict()
|
||||
{
|
||||
// Create an override that sets severity to Critical for high cvss
|
||||
var originalPredicate = new OverridePredicate(
|
||||
Conditions: new[] { new OverrideCondition("cvss", ConditionOperator.GreaterThan, 8.0) },
|
||||
MatchMode: PredicateMatchMode.All);
|
||||
var originalRequest = new CreateOverrideRequest(
|
||||
ProfileId: "test-profile",
|
||||
OverrideType: OverrideType.Severity,
|
||||
Predicate: originalPredicate,
|
||||
Action: new OverrideAction(OverrideActionType.SetSeverity, Severity: RiskSeverity.Critical),
|
||||
Priority: 100,
|
||||
Reason: "High CVSS should be critical",
|
||||
Justification: null,
|
||||
TicketRef: null,
|
||||
Expiration: null,
|
||||
Tags: null);
|
||||
_service.Create(originalRequest);
|
||||
|
||||
// Try to create overlapping override (also uses cvss) with contradictory action (Low severity)
|
||||
var newPredicate = new OverridePredicate(
|
||||
Conditions: new[]
|
||||
{
|
||||
new OverrideCondition("cvss", ConditionOperator.GreaterThan, 7.0),
|
||||
new OverrideCondition("reachability", ConditionOperator.LessThan, 0.5)
|
||||
},
|
||||
MatchMode: PredicateMatchMode.All);
|
||||
var newRequest = new CreateOverrideRequest(
|
||||
ProfileId: "test-profile",
|
||||
OverrideType: OverrideType.Severity,
|
||||
Predicate: newPredicate,
|
||||
Action: new OverrideAction(OverrideActionType.SetSeverity, Severity: RiskSeverity.Low),
|
||||
Priority: 50,
|
||||
Reason: "Low reachability should reduce severity",
|
||||
Justification: null,
|
||||
TicketRef: null,
|
||||
Expiration: null,
|
||||
Tags: null);
|
||||
|
||||
var validation = _service.ValidateConflicts(newRequest);
|
||||
|
||||
Assert.True(validation.HasConflicts);
|
||||
Assert.Contains(validation.Conflicts, c => c.ConflictType == ConflictType.ContradictoryAction);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateConflicts_PriorityCollision_DetectsConflict()
|
||||
{
|
||||
var original = _service.Create(CreateValidRequest() with { Priority = 100 });
|
||||
|
||||
var newRequest = CreateValidRequest() with { Priority = 100 };
|
||||
var validation = _service.ValidateConflicts(newRequest);
|
||||
|
||||
Assert.True(validation.HasConflicts);
|
||||
Assert.Contains(validation.Conflicts, c => c.ConflictType == ConflictType.PriorityCollision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateConflicts_NoConflicts_ReturnsClean()
|
||||
{
|
||||
var differentProfileRequest = new CreateOverrideRequest(
|
||||
ProfileId: "other-profile",
|
||||
OverrideType: OverrideType.Severity,
|
||||
Predicate: CreateKevPredicate(),
|
||||
Action: new OverrideAction(OverrideActionType.SetSeverity, Severity: RiskSeverity.Critical),
|
||||
Priority: 100,
|
||||
Reason: "Different profile",
|
||||
Justification: null,
|
||||
TicketRef: null,
|
||||
Expiration: null,
|
||||
Tags: null);
|
||||
|
||||
var validation = _service.ValidateConflicts(differentProfileRequest);
|
||||
|
||||
Assert.False(validation.HasConflicts);
|
||||
Assert.Empty(validation.Conflicts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordApplication_StoresHistory()
|
||||
{
|
||||
var created = _service.Create(CreateValidRequest());
|
||||
|
||||
_service.RecordApplication(
|
||||
overrideId: created.OverrideId,
|
||||
findingId: "finding-001",
|
||||
originalValue: RiskSeverity.High,
|
||||
appliedValue: RiskSeverity.Critical,
|
||||
context: new Dictionary<string, object?> { ["component"] = "pkg:npm/lodash" });
|
||||
|
||||
var history = _service.GetApplicationHistory(created.OverrideId);
|
||||
|
||||
Assert.Single(history);
|
||||
Assert.Equal(created.OverrideId, history[0].OverrideId);
|
||||
Assert.Equal("finding-001", history[0].FindingId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetApplicationHistory_LimitsResults()
|
||||
{
|
||||
var created = _service.Create(CreateValidRequest());
|
||||
|
||||
// Record 10 applications
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
_service.RecordApplication(
|
||||
overrideId: created.OverrideId,
|
||||
findingId: $"finding-{i:D3}",
|
||||
originalValue: RiskSeverity.High,
|
||||
appliedValue: RiskSeverity.Critical);
|
||||
}
|
||||
|
||||
var limitedHistory = _service.GetApplicationHistory(created.OverrideId, limit: 5);
|
||||
|
||||
Assert.Equal(5, limitedHistory.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluatePredicate_AllConditionsMustMatch_WhenModeIsAll()
|
||||
{
|
||||
var predicate = new OverridePredicate(
|
||||
Conditions: new[]
|
||||
{
|
||||
new OverrideCondition("kev", ConditionOperator.Equals, true),
|
||||
new OverrideCondition("cvss", ConditionOperator.GreaterThan, 7.0)
|
||||
},
|
||||
MatchMode: PredicateMatchMode.All);
|
||||
|
||||
var matchingSignals = new Dictionary<string, object?>
|
||||
{
|
||||
["kev"] = true,
|
||||
["cvss"] = 8.5
|
||||
};
|
||||
|
||||
var partialMatch = new Dictionary<string, object?>
|
||||
{
|
||||
["kev"] = true,
|
||||
["cvss"] = 5.0
|
||||
};
|
||||
|
||||
Assert.True(_service.EvaluatePredicate(predicate, matchingSignals));
|
||||
Assert.False(_service.EvaluatePredicate(predicate, partialMatch));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluatePredicate_AnyConditionCanMatch_WhenModeIsAny()
|
||||
{
|
||||
var predicate = new OverridePredicate(
|
||||
Conditions: new[]
|
||||
{
|
||||
new OverrideCondition("kev", ConditionOperator.Equals, true),
|
||||
new OverrideCondition("cvss", ConditionOperator.GreaterThan, 9.0)
|
||||
},
|
||||
MatchMode: PredicateMatchMode.Any);
|
||||
|
||||
var kevOnly = new Dictionary<string, object?>
|
||||
{
|
||||
["kev"] = true,
|
||||
["cvss"] = 5.0
|
||||
};
|
||||
|
||||
var cvssOnly = new Dictionary<string, object?>
|
||||
{
|
||||
["kev"] = false,
|
||||
["cvss"] = 9.5
|
||||
};
|
||||
|
||||
var neither = new Dictionary<string, object?>
|
||||
{
|
||||
["kev"] = false,
|
||||
["cvss"] = 5.0
|
||||
};
|
||||
|
||||
Assert.True(_service.EvaluatePredicate(predicate, kevOnly));
|
||||
Assert.True(_service.EvaluatePredicate(predicate, cvssOnly));
|
||||
Assert.False(_service.EvaluatePredicate(predicate, neither));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ConditionOperator.Equals, "high", "high", true)]
|
||||
[InlineData(ConditionOperator.Equals, "high", "low", false)]
|
||||
[InlineData(ConditionOperator.NotEquals, "high", "low", true)]
|
||||
[InlineData(ConditionOperator.NotEquals, "high", "high", false)]
|
||||
public void EvaluatePredicate_StringComparisons(ConditionOperator op, object expected, object actual, bool shouldMatch)
|
||||
{
|
||||
var predicate = new OverridePredicate(
|
||||
Conditions: new[] { new OverrideCondition("severity", op, expected) },
|
||||
MatchMode: PredicateMatchMode.All);
|
||||
|
||||
var signals = new Dictionary<string, object?> { ["severity"] = actual };
|
||||
|
||||
Assert.Equal(shouldMatch, _service.EvaluatePredicate(predicate, signals));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ConditionOperator.GreaterThan, 5.0, 7.5, true)]
|
||||
[InlineData(ConditionOperator.GreaterThan, 5.0, 5.0, false)]
|
||||
[InlineData(ConditionOperator.GreaterThanOrEqual, 5.0, 5.0, true)]
|
||||
[InlineData(ConditionOperator.LessThan, 5.0, 3.0, true)]
|
||||
[InlineData(ConditionOperator.LessThanOrEqual, 5.0, 5.0, true)]
|
||||
public void EvaluatePredicate_NumericComparisons(ConditionOperator op, object threshold, object actual, bool shouldMatch)
|
||||
{
|
||||
var predicate = new OverridePredicate(
|
||||
Conditions: new[] { new OverrideCondition("cvss", op, threshold) },
|
||||
MatchMode: PredicateMatchMode.All);
|
||||
|
||||
var signals = new Dictionary<string, object?> { ["cvss"] = actual };
|
||||
|
||||
Assert.Equal(shouldMatch, _service.EvaluatePredicate(predicate, signals));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluatePredicate_InOperator_MatchesCollection()
|
||||
{
|
||||
var predicate = new OverridePredicate(
|
||||
Conditions: new[] { new OverrideCondition("ecosystem", ConditionOperator.In, "npm,maven,pypi") },
|
||||
MatchMode: PredicateMatchMode.All);
|
||||
|
||||
var matchingSignals = new Dictionary<string, object?> { ["ecosystem"] = "npm" };
|
||||
var nonMatchingSignals = new Dictionary<string, object?> { ["ecosystem"] = "go" };
|
||||
|
||||
Assert.True(_service.EvaluatePredicate(predicate, matchingSignals));
|
||||
Assert.False(_service.EvaluatePredicate(predicate, nonMatchingSignals));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluatePredicate_ContainsOperator_MatchesSubstring()
|
||||
{
|
||||
var predicate = new OverridePredicate(
|
||||
Conditions: new[] { new OverrideCondition("purl", ConditionOperator.Contains, "@angular") },
|
||||
MatchMode: PredicateMatchMode.All);
|
||||
|
||||
var matchingSignals = new Dictionary<string, object?> { ["purl"] = "pkg:npm/@angular/core@15.0.0" };
|
||||
var nonMatchingSignals = new Dictionary<string, object?> { ["purl"] = "pkg:npm/lodash@4.17.21" };
|
||||
|
||||
Assert.True(_service.EvaluatePredicate(predicate, matchingSignals));
|
||||
Assert.False(_service.EvaluatePredicate(predicate, nonMatchingSignals));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluatePredicate_RegexOperator_MatchesPattern()
|
||||
{
|
||||
var predicate = new OverridePredicate(
|
||||
Conditions: new[] { new OverrideCondition("advisory_id", ConditionOperator.Regex, "^CVE-2024-.*") },
|
||||
MatchMode: PredicateMatchMode.All);
|
||||
|
||||
var matchingSignals = new Dictionary<string, object?> { ["advisory_id"] = "CVE-2024-1234" };
|
||||
var nonMatchingSignals = new Dictionary<string, object?> { ["advisory_id"] = "CVE-2023-5678" };
|
||||
|
||||
Assert.True(_service.EvaluatePredicate(predicate, matchingSignals));
|
||||
Assert.False(_service.EvaluatePredicate(predicate, nonMatchingSignals));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_WithExpirationAndTags_StoresMetadata()
|
||||
{
|
||||
var expiration = DateTimeOffset.UtcNow.AddDays(30);
|
||||
var tags = new[] { "emergency", "security-team" };
|
||||
|
||||
var request = CreateValidRequest() with
|
||||
{
|
||||
Expiration = expiration,
|
||||
Tags = tags
|
||||
};
|
||||
|
||||
var result = _service.Create(request);
|
||||
|
||||
Assert.Equal(expiration, result.Expiration);
|
||||
Assert.NotNull(result.Tags);
|
||||
Assert.Equal(2, result.Tags.Count);
|
||||
Assert.Contains("emergency", result.Tags);
|
||||
Assert.Contains("security-team", result.Tags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListByProfile_ExcludesExpiredOverrides()
|
||||
{
|
||||
// Create override that expired in the past
|
||||
var pastExpiration = DateTimeOffset.UtcNow.AddDays(-1);
|
||||
var request = CreateValidRequest() with { Expiration = pastExpiration };
|
||||
var expired = _service.Create(request);
|
||||
|
||||
// Create override with no expiration
|
||||
var active = _service.Create(CreateValidRequest() with { Priority = 200 });
|
||||
|
||||
var results = _service.ListByProfile("test-profile", includeInactive: false);
|
||||
|
||||
Assert.Single(results);
|
||||
Assert.Equal(active.OverrideId, results[0].OverrideId);
|
||||
}
|
||||
|
||||
private static CreateOverrideRequest CreateValidRequest() => new(
|
||||
ProfileId: "test-profile",
|
||||
OverrideType: OverrideType.Severity,
|
||||
Predicate: CreateKevPredicate(),
|
||||
Action: new OverrideAction(OverrideActionType.SetSeverity, Severity: RiskSeverity.Critical),
|
||||
Priority: 100,
|
||||
Reason: "KEV findings should be critical",
|
||||
Justification: "Security policy requires KEV to be critical",
|
||||
TicketRef: "SEC-1234",
|
||||
Expiration: null,
|
||||
Tags: null);
|
||||
|
||||
private static OverridePredicate CreateKevPredicate() => new(
|
||||
Conditions: new[] { new OverrideCondition("kev", ConditionOperator.Equals, true) },
|
||||
MatchMode: PredicateMatchMode.All);
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
using StellaOps.Policy.RiskProfile.Scope;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Scope;
|
||||
|
||||
public sealed class EffectivePolicyServiceTests
|
||||
{
|
||||
private readonly EffectivePolicyService _service;
|
||||
|
||||
public EffectivePolicyServiceTests()
|
||||
{
|
||||
_service = new EffectivePolicyService();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_ValidRequest_ReturnsPolicy()
|
||||
{
|
||||
var request = new CreateEffectivePolicyRequest(
|
||||
TenantId: "default",
|
||||
PolicyId: "security-policy-v1",
|
||||
PolicyVersion: "1.0.0",
|
||||
SubjectPattern: "pkg:npm/*",
|
||||
Priority: 100);
|
||||
|
||||
var policy = _service.Create(request, "admin@example.com");
|
||||
|
||||
Assert.NotNull(policy);
|
||||
Assert.StartsWith("eff-", policy.EffectivePolicyId);
|
||||
Assert.Equal("default", policy.TenantId);
|
||||
Assert.Equal("security-policy-v1", policy.PolicyId);
|
||||
Assert.Equal("1.0.0", policy.PolicyVersion);
|
||||
Assert.Equal("pkg:npm/*", policy.SubjectPattern);
|
||||
Assert.Equal(100, policy.Priority);
|
||||
Assert.True(policy.Enabled);
|
||||
Assert.Equal("admin@example.com", policy.CreatedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_InvalidPattern_ThrowsException()
|
||||
{
|
||||
var request = new CreateEffectivePolicyRequest(
|
||||
TenantId: "default",
|
||||
PolicyId: "policy-1",
|
||||
PolicyVersion: null,
|
||||
SubjectPattern: "invalid-pattern",
|
||||
Priority: 100);
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() => _service.Create(request));
|
||||
Assert.Contains("Invalid subject pattern", ex.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("*")]
|
||||
[InlineData("pkg:npm/*")]
|
||||
[InlineData("pkg:npm/@org/*")]
|
||||
[InlineData("pkg:maven/com.example/*")]
|
||||
[InlineData("oci://registry.example.com/*")]
|
||||
public void IsValidSubjectPattern_ValidPatterns_ReturnsTrue(string pattern)
|
||||
{
|
||||
Assert.True(EffectivePolicyService.IsValidSubjectPattern(pattern));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("invalid")]
|
||||
[InlineData("pkg:**")]
|
||||
public void IsValidSubjectPattern_InvalidPatterns_ReturnsFalse(string pattern)
|
||||
{
|
||||
Assert.False(EffectivePolicyService.IsValidSubjectPattern(pattern));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("pkg:npm/lodash@4.17.20", "*", true)]
|
||||
[InlineData("pkg:npm/lodash@4.17.20", "pkg:npm/*", true)]
|
||||
[InlineData("pkg:npm/@org/utils@1.0.0", "pkg:npm/@org/*", true)]
|
||||
[InlineData("pkg:maven/com.example/lib@1.0", "pkg:maven/*", true)]
|
||||
[InlineData("pkg:npm/lodash@4.17.20", "pkg:maven/*", false)]
|
||||
[InlineData("oci://registry.io/image:tag", "oci://registry.io/*", true)]
|
||||
[InlineData("oci://other.io/image:tag", "oci://registry.io/*", false)]
|
||||
public void MatchesPattern_ReturnsExpectedResult(string subject, string pattern, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, EffectivePolicyService.MatchesPattern(subject, pattern));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPatternSpecificity_UniversalWildcard_ReturnsZero()
|
||||
{
|
||||
Assert.Equal(0, EffectivePolicyService.GetPatternSpecificity("*"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPatternSpecificity_MoreSpecificPatterns_ReturnHigherScores()
|
||||
{
|
||||
var universal = EffectivePolicyService.GetPatternSpecificity("*");
|
||||
var pkgWildcard = EffectivePolicyService.GetPatternSpecificity("pkg:*");
|
||||
var npmWildcard = EffectivePolicyService.GetPatternSpecificity("pkg:npm/*");
|
||||
var orgWildcard = EffectivePolicyService.GetPatternSpecificity("pkg:npm/@org/*");
|
||||
|
||||
Assert.True(pkgWildcard > universal);
|
||||
Assert.True(npmWildcard > pkgWildcard);
|
||||
Assert.True(orgWildcard > npmWildcard);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_ExistingPolicy_ReturnsPolicy()
|
||||
{
|
||||
var request = new CreateEffectivePolicyRequest(
|
||||
TenantId: "default",
|
||||
PolicyId: "policy-1",
|
||||
PolicyVersion: null,
|
||||
SubjectPattern: "pkg:npm/*",
|
||||
Priority: 100);
|
||||
|
||||
var created = _service.Create(request);
|
||||
var fetched = _service.Get(created.EffectivePolicyId);
|
||||
|
||||
Assert.NotNull(fetched);
|
||||
Assert.Equal(created.EffectivePolicyId, fetched.EffectivePolicyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_NonExistingPolicy_ReturnsNull()
|
||||
{
|
||||
var fetched = _service.Get("non-existent-id");
|
||||
Assert.Null(fetched);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_ExistingPolicy_UpdatesFields()
|
||||
{
|
||||
var request = new CreateEffectivePolicyRequest(
|
||||
TenantId: "default",
|
||||
PolicyId: "policy-1",
|
||||
PolicyVersion: null,
|
||||
SubjectPattern: "pkg:npm/*",
|
||||
Priority: 100);
|
||||
|
||||
var created = _service.Create(request);
|
||||
|
||||
var updateRequest = new UpdateEffectivePolicyRequest(
|
||||
Priority: 150,
|
||||
Enabled: false);
|
||||
|
||||
var updated = _service.Update(created.EffectivePolicyId, updateRequest);
|
||||
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal(150, updated.Priority);
|
||||
Assert.False(updated.Enabled);
|
||||
Assert.True(updated.UpdatedAt > created.UpdatedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Delete_ExistingPolicy_ReturnsTrue()
|
||||
{
|
||||
var request = new CreateEffectivePolicyRequest(
|
||||
TenantId: "default",
|
||||
PolicyId: "policy-1",
|
||||
PolicyVersion: null,
|
||||
SubjectPattern: "pkg:npm/*",
|
||||
Priority: 100);
|
||||
|
||||
var created = _service.Create(request);
|
||||
var deleted = _service.Delete(created.EffectivePolicyId);
|
||||
|
||||
Assert.True(deleted);
|
||||
Assert.Null(_service.Get(created.EffectivePolicyId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Query_ByTenant_ReturnsMatchingPolicies()
|
||||
{
|
||||
_service.Create(new CreateEffectivePolicyRequest("tenant-a", "policy-1", null, "pkg:npm/*", 100));
|
||||
_service.Create(new CreateEffectivePolicyRequest("tenant-a", "policy-2", null, "pkg:maven/*", 100));
|
||||
_service.Create(new CreateEffectivePolicyRequest("tenant-b", "policy-3", null, "pkg:*", 100));
|
||||
|
||||
var query = new EffectivePolicyQuery(TenantId: "tenant-a");
|
||||
var results = _service.Query(query);
|
||||
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.All(results, p => Assert.Equal("tenant-a", p.TenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Query_EnabledOnly_ExcludesDisabled()
|
||||
{
|
||||
_service.Create(new CreateEffectivePolicyRequest("default", "policy-1", null, "pkg:npm/*", 100, Enabled: true));
|
||||
var disabled = _service.Create(new CreateEffectivePolicyRequest("default", "policy-2", null, "pkg:maven/*", 100, Enabled: false));
|
||||
|
||||
var query = new EffectivePolicyQuery(TenantId: "default", EnabledOnly: true);
|
||||
var results = _service.Query(query);
|
||||
|
||||
Assert.Single(results);
|
||||
Assert.DoesNotContain(results, p => p.EffectivePolicyId == disabled.EffectivePolicyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttachScope_ValidRequest_ReturnsAttachment()
|
||||
{
|
||||
var policy = _service.Create(new CreateEffectivePolicyRequest(
|
||||
"default", "policy-1", null, "pkg:npm/*", 100));
|
||||
|
||||
var attachment = _service.AttachScope(new AttachAuthorityScopeRequest(
|
||||
EffectivePolicyId: policy.EffectivePolicyId,
|
||||
Scope: "scan:write",
|
||||
Conditions: new Dictionary<string, string> { ["environment"] = "production" }));
|
||||
|
||||
Assert.NotNull(attachment);
|
||||
Assert.StartsWith("att-", attachment.AttachmentId);
|
||||
Assert.Equal(policy.EffectivePolicyId, attachment.EffectivePolicyId);
|
||||
Assert.Equal("scan:write", attachment.Scope);
|
||||
Assert.NotNull(attachment.Conditions);
|
||||
Assert.Equal("production", attachment.Conditions["environment"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttachScope_NonExistingPolicy_ThrowsException()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
_service.AttachScope(new AttachAuthorityScopeRequest(
|
||||
EffectivePolicyId: "non-existent",
|
||||
Scope: "scan:write")));
|
||||
|
||||
Assert.Contains("not found", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetachScope_ExistingAttachment_ReturnsTrue()
|
||||
{
|
||||
var policy = _service.Create(new CreateEffectivePolicyRequest(
|
||||
"default", "policy-1", null, "pkg:npm/*", 100));
|
||||
|
||||
var attachment = _service.AttachScope(new AttachAuthorityScopeRequest(
|
||||
EffectivePolicyId: policy.EffectivePolicyId,
|
||||
Scope: "scan:write"));
|
||||
|
||||
var detached = _service.DetachScope(attachment.AttachmentId);
|
||||
|
||||
Assert.True(detached);
|
||||
Assert.Empty(_service.GetScopeAttachments(policy.EffectivePolicyId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetScopeAttachments_MultipleAttachments_ReturnsAll()
|
||||
{
|
||||
var policy = _service.Create(new CreateEffectivePolicyRequest(
|
||||
"default", "policy-1", null, "pkg:npm/*", 100));
|
||||
|
||||
_service.AttachScope(new AttachAuthorityScopeRequest(policy.EffectivePolicyId, "scan:read"));
|
||||
_service.AttachScope(new AttachAuthorityScopeRequest(policy.EffectivePolicyId, "scan:write"));
|
||||
_service.AttachScope(new AttachAuthorityScopeRequest(policy.EffectivePolicyId, "promotion:approve"));
|
||||
|
||||
var attachments = _service.GetScopeAttachments(policy.EffectivePolicyId);
|
||||
|
||||
Assert.Equal(3, attachments.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_MatchingPolicy_ReturnsCorrectResult()
|
||||
{
|
||||
_service.Create(new CreateEffectivePolicyRequest(
|
||||
"default", "npm-policy", null, "pkg:npm/*", 100,
|
||||
Scopes: new[] { "scan:read", "scan:write" }));
|
||||
|
||||
var result = _service.Resolve("pkg:npm/lodash@4.17.20");
|
||||
|
||||
Assert.NotNull(result.EffectivePolicy);
|
||||
Assert.Equal("npm-policy", result.EffectivePolicy.PolicyId);
|
||||
Assert.Equal("pkg:npm/*", result.MatchedPattern);
|
||||
Assert.Contains("scan:read", result.GrantedScopes);
|
||||
Assert.Contains("scan:write", result.GrantedScopes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_NoMatchingPolicy_ReturnsNullPolicy()
|
||||
{
|
||||
_service.Create(new CreateEffectivePolicyRequest(
|
||||
"default", "npm-policy", null, "pkg:npm/*", 100));
|
||||
|
||||
var result = _service.Resolve("pkg:maven/com.example/lib@1.0");
|
||||
|
||||
Assert.Null(result.EffectivePolicy);
|
||||
Assert.Null(result.MatchedPattern);
|
||||
Assert.Empty(result.GrantedScopes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_PriorityResolution_HigherPriorityWins()
|
||||
{
|
||||
_service.Create(new CreateEffectivePolicyRequest(
|
||||
"default", "low-priority", null, "pkg:npm/*", 50));
|
||||
_service.Create(new CreateEffectivePolicyRequest(
|
||||
"default", "high-priority", null, "pkg:npm/*", 200));
|
||||
|
||||
var result = _service.Resolve("pkg:npm/lodash@4.17.20");
|
||||
|
||||
Assert.NotNull(result.EffectivePolicy);
|
||||
Assert.Equal("high-priority", result.EffectivePolicy.PolicyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_EqualPriority_MoreSpecificPatternWins()
|
||||
{
|
||||
_service.Create(new CreateEffectivePolicyRequest(
|
||||
"default", "broad-policy", null, "pkg:npm/*", 100));
|
||||
_service.Create(new CreateEffectivePolicyRequest(
|
||||
"default", "specific-policy", null, "pkg:npm/@org/*", 100));
|
||||
|
||||
var result = _service.Resolve("pkg:npm/@org/utils@1.0.0");
|
||||
|
||||
Assert.NotNull(result.EffectivePolicy);
|
||||
Assert.Equal("specific-policy", result.EffectivePolicy.PolicyId);
|
||||
Assert.Equal("pkg:npm/@org/*", result.MatchedPattern);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_IncludesAttachedScopes()
|
||||
{
|
||||
var policy = _service.Create(new CreateEffectivePolicyRequest(
|
||||
"default", "policy-1", null, "pkg:npm/*", 100,
|
||||
Scopes: new[] { "scan:read" }));
|
||||
|
||||
_service.AttachScope(new AttachAuthorityScopeRequest(
|
||||
EffectivePolicyId: policy.EffectivePolicyId,
|
||||
Scope: "scan:write"));
|
||||
|
||||
var result = _service.Resolve("pkg:npm/lodash@4.17.20");
|
||||
|
||||
Assert.Contains("scan:read", result.GrantedScopes);
|
||||
Assert.Contains("scan:write", result.GrantedScopes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_DisabledPolicies_AreExcluded()
|
||||
{
|
||||
_service.Create(new CreateEffectivePolicyRequest(
|
||||
"default", "enabled-policy", null, "pkg:npm/*", 100, Enabled: true));
|
||||
_service.Create(new CreateEffectivePolicyRequest(
|
||||
"default", "disabled-policy", null, "pkg:npm/*", 200, Enabled: false));
|
||||
|
||||
var result = _service.Resolve("pkg:npm/lodash@4.17.20");
|
||||
|
||||
Assert.NotNull(result.EffectivePolicy);
|
||||
Assert.Equal("enabled-policy", result.EffectivePolicy.PolicyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Delete_RemovesAssociatedScopeAttachments()
|
||||
{
|
||||
var policy = _service.Create(new CreateEffectivePolicyRequest(
|
||||
"default", "policy-1", null, "pkg:npm/*", 100));
|
||||
|
||||
_service.AttachScope(new AttachAuthorityScopeRequest(policy.EffectivePolicyId, "scan:read"));
|
||||
_service.AttachScope(new AttachAuthorityScopeRequest(policy.EffectivePolicyId, "scan:write"));
|
||||
|
||||
_service.Delete(policy.EffectivePolicyId);
|
||||
|
||||
Assert.Empty(_service.GetScopeAttachments(policy.EffectivePolicyId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Policy.Engine.Simulation;
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Simulation;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for RiskSimulationBreakdownService.
|
||||
/// Per POLICY-RISK-67-003.
|
||||
/// </summary>
|
||||
public sealed class RiskSimulationBreakdownServiceTests
|
||||
{
|
||||
private readonly RiskSimulationBreakdownService _service;
|
||||
|
||||
public RiskSimulationBreakdownServiceTests()
|
||||
{
|
||||
_service = new RiskSimulationBreakdownService(
|
||||
NullLogger<RiskSimulationBreakdownService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateBreakdown_WithValidInput_ReturnsBreakdown()
|
||||
{
|
||||
// Arrange
|
||||
var profile = CreateTestProfile();
|
||||
var findings = CreateTestFindings(5);
|
||||
var result = CreateTestResult(findings, profile);
|
||||
|
||||
// Act
|
||||
var breakdown = _service.GenerateBreakdown(result, profile, findings);
|
||||
|
||||
// Assert
|
||||
breakdown.Should().NotBeNull();
|
||||
breakdown.SimulationId.Should().Be(result.SimulationId);
|
||||
breakdown.ProfileRef.Should().NotBeNull();
|
||||
breakdown.ProfileRef.Id.Should().Be(profile.Id);
|
||||
breakdown.SignalAnalysis.Should().NotBeNull();
|
||||
breakdown.OverrideAnalysis.Should().NotBeNull();
|
||||
breakdown.ScoreDistribution.Should().NotBeNull();
|
||||
breakdown.SeverityBreakdown.Should().NotBeNull();
|
||||
breakdown.ActionBreakdown.Should().NotBeNull();
|
||||
breakdown.DeterminismHash.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateBreakdown_SignalAnalysis_ComputesCorrectCoverage()
|
||||
{
|
||||
// Arrange
|
||||
var profile = CreateTestProfile();
|
||||
var findings = CreateTestFindings(10);
|
||||
var result = CreateTestResult(findings, profile);
|
||||
|
||||
// Act
|
||||
var breakdown = _service.GenerateBreakdown(result, profile, findings);
|
||||
|
||||
// Assert
|
||||
breakdown.SignalAnalysis.TotalSignals.Should().Be(profile.Signals.Count);
|
||||
breakdown.SignalAnalysis.SignalsUsed.Should().BeGreaterThan(0);
|
||||
breakdown.SignalAnalysis.SignalCoverage.Should().BeGreaterThan(0);
|
||||
breakdown.SignalAnalysis.SignalStats.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateBreakdown_SignalAnalysis_IdentifiesTopContributors()
|
||||
{
|
||||
// Arrange
|
||||
var profile = CreateTestProfile();
|
||||
var findings = CreateTestFindings(20);
|
||||
var result = CreateTestResult(findings, profile);
|
||||
|
||||
// Act
|
||||
var breakdown = _service.GenerateBreakdown(result, profile, findings);
|
||||
|
||||
// Assert
|
||||
breakdown.SignalAnalysis.TopContributors.Should().NotBeEmpty();
|
||||
breakdown.SignalAnalysis.TopContributors.Length.Should().BeLessOrEqualTo(10);
|
||||
|
||||
// Top contributors should be ordered by contribution
|
||||
for (var i = 1; i < breakdown.SignalAnalysis.TopContributors.Length; i++)
|
||||
{
|
||||
breakdown.SignalAnalysis.TopContributors[i - 1].TotalContribution
|
||||
.Should().BeGreaterOrEqualTo(breakdown.SignalAnalysis.TopContributors[i].TotalContribution);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateBreakdown_OverrideAnalysis_TracksApplications()
|
||||
{
|
||||
// Arrange
|
||||
var profile = CreateTestProfileWithOverrides();
|
||||
var findings = CreateTestFindingsWithKev(5);
|
||||
var result = CreateTestResultWithOverrides(findings, profile);
|
||||
|
||||
// Act
|
||||
var breakdown = _service.GenerateBreakdown(result, profile, findings);
|
||||
|
||||
// Assert
|
||||
breakdown.OverrideAnalysis.Should().NotBeNull();
|
||||
breakdown.OverrideAnalysis.TotalOverridesEvaluated.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateBreakdown_ScoreDistribution_ComputesStatistics()
|
||||
{
|
||||
// Arrange
|
||||
var profile = CreateTestProfile();
|
||||
var findings = CreateTestFindings(50);
|
||||
var result = CreateTestResult(findings, profile);
|
||||
|
||||
// Act
|
||||
var breakdown = _service.GenerateBreakdown(result, profile, findings);
|
||||
|
||||
// Assert
|
||||
breakdown.ScoreDistribution.Should().NotBeNull();
|
||||
breakdown.ScoreDistribution.RawScoreStats.Should().NotBeNull();
|
||||
breakdown.ScoreDistribution.NormalizedScoreStats.Should().NotBeNull();
|
||||
breakdown.ScoreDistribution.ScoreBuckets.Should().HaveCount(10);
|
||||
breakdown.ScoreDistribution.Percentiles.Should().ContainKey("p50");
|
||||
breakdown.ScoreDistribution.Percentiles.Should().ContainKey("p90");
|
||||
breakdown.ScoreDistribution.Percentiles.Should().ContainKey("p99");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateBreakdown_ScoreDistribution_ComputesSkewnessAndKurtosis()
|
||||
{
|
||||
// Arrange
|
||||
var profile = CreateTestProfile();
|
||||
var findings = CreateTestFindings(100);
|
||||
var result = CreateTestResult(findings, profile);
|
||||
|
||||
// Act
|
||||
var breakdown = _service.GenerateBreakdown(result, profile, findings);
|
||||
|
||||
// Assert
|
||||
var stats = breakdown.ScoreDistribution.NormalizedScoreStats;
|
||||
stats.Skewness.Should().NotBe(0); // With random data, unlikely to be exactly 0
|
||||
// Kurtosis can be any value, just verify it's computed
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateBreakdown_ScoreDistribution_IdentifiesOutliers()
|
||||
{
|
||||
// Arrange
|
||||
var profile = CreateTestProfile();
|
||||
var findings = CreateTestFindings(50);
|
||||
var result = CreateTestResult(findings, profile);
|
||||
|
||||
// Act
|
||||
var breakdown = _service.GenerateBreakdown(result, profile, findings);
|
||||
|
||||
// Assert
|
||||
breakdown.ScoreDistribution.Outliers.Should().NotBeNull();
|
||||
breakdown.ScoreDistribution.Outliers.OutlierThreshold.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateBreakdown_SeverityBreakdown_GroupsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var profile = CreateTestProfile();
|
||||
var findings = CreateTestFindings(30);
|
||||
var result = CreateTestResult(findings, profile);
|
||||
|
||||
// Act
|
||||
var breakdown = _service.GenerateBreakdown(result, profile, findings);
|
||||
|
||||
// Assert
|
||||
breakdown.SeverityBreakdown.Should().NotBeNull();
|
||||
breakdown.SeverityBreakdown.BySeverity.Should().NotBeEmpty();
|
||||
|
||||
// Total count should match findings
|
||||
var totalCount = breakdown.SeverityBreakdown.BySeverity.Values.Sum(b => b.Count);
|
||||
totalCount.Should().Be(findings.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateBreakdown_SeverityBreakdown_ComputesConcentration()
|
||||
{
|
||||
// Arrange
|
||||
var profile = CreateTestProfile();
|
||||
var findings = CreateTestFindings(20);
|
||||
var result = CreateTestResult(findings, profile);
|
||||
|
||||
// Act
|
||||
var breakdown = _service.GenerateBreakdown(result, profile, findings);
|
||||
|
||||
// Assert
|
||||
// HHI ranges from 1/n to 1
|
||||
breakdown.SeverityBreakdown.SeverityConcentration.Should().BeGreaterOrEqualTo(0);
|
||||
breakdown.SeverityBreakdown.SeverityConcentration.Should().BeLessOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateBreakdown_ActionBreakdown_GroupsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var profile = CreateTestProfile();
|
||||
var findings = CreateTestFindings(25);
|
||||
var result = CreateTestResult(findings, profile);
|
||||
|
||||
// Act
|
||||
var breakdown = _service.GenerateBreakdown(result, profile, findings);
|
||||
|
||||
// Assert
|
||||
breakdown.ActionBreakdown.Should().NotBeNull();
|
||||
breakdown.ActionBreakdown.ByAction.Should().NotBeEmpty();
|
||||
|
||||
// Total count should match findings
|
||||
var totalCount = breakdown.ActionBreakdown.ByAction.Values.Sum(b => b.Count);
|
||||
totalCount.Should().Be(findings.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateBreakdown_ActionBreakdown_ComputesStability()
|
||||
{
|
||||
// Arrange
|
||||
var profile = CreateTestProfile();
|
||||
var findings = CreateTestFindings(20);
|
||||
var result = CreateTestResult(findings, profile);
|
||||
|
||||
// Act
|
||||
var breakdown = _service.GenerateBreakdown(result, profile, findings);
|
||||
|
||||
// Assert
|
||||
// Stability ranges from 0 to 1
|
||||
breakdown.ActionBreakdown.DecisionStability.Should().BeGreaterOrEqualTo(0);
|
||||
breakdown.ActionBreakdown.DecisionStability.Should().BeLessOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateBreakdown_ComponentBreakdown_IncludedByDefault()
|
||||
{
|
||||
// Arrange
|
||||
var profile = CreateTestProfile();
|
||||
var findings = CreateTestFindings(15);
|
||||
var result = CreateTestResult(findings, profile);
|
||||
|
||||
// Act
|
||||
var breakdown = _service.GenerateBreakdown(result, profile, findings);
|
||||
|
||||
// Assert
|
||||
breakdown.ComponentBreakdown.Should().NotBeNull();
|
||||
breakdown.ComponentBreakdown!.TotalComponents.Should().BeGreaterThan(0);
|
||||
breakdown.ComponentBreakdown.TopRiskComponents.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateBreakdown_ComponentBreakdown_ExtractsEcosystems()
|
||||
{
|
||||
// Arrange
|
||||
var profile = CreateTestProfile();
|
||||
var findings = CreateMixedEcosystemFindings();
|
||||
var result = CreateTestResult(findings, profile);
|
||||
|
||||
// Act
|
||||
var breakdown = _service.GenerateBreakdown(result, profile, findings);
|
||||
|
||||
// Assert
|
||||
breakdown.ComponentBreakdown.Should().NotBeNull();
|
||||
breakdown.ComponentBreakdown!.EcosystemBreakdown.Should().NotBeEmpty();
|
||||
breakdown.ComponentBreakdown.EcosystemBreakdown.Should().ContainKey("npm");
|
||||
breakdown.ComponentBreakdown.EcosystemBreakdown.Should().ContainKey("maven");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateBreakdown_WithQuickOptions_ExcludesComponentBreakdown()
|
||||
{
|
||||
// Arrange
|
||||
var profile = CreateTestProfile();
|
||||
var findings = CreateTestFindings(10);
|
||||
var result = CreateTestResult(findings, profile);
|
||||
var options = RiskSimulationBreakdownOptions.Quick;
|
||||
|
||||
// Act
|
||||
var breakdown = _service.GenerateBreakdown(result, profile, findings, options);
|
||||
|
||||
// Assert
|
||||
breakdown.ComponentBreakdown.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateBreakdown_DeterminismHash_IsConsistent()
|
||||
{
|
||||
// Arrange
|
||||
var profile = CreateTestProfile();
|
||||
var findings = CreateTestFindings(10);
|
||||
var result = CreateTestResult(findings, profile);
|
||||
|
||||
// Act
|
||||
var breakdown1 = _service.GenerateBreakdown(result, profile, findings);
|
||||
var breakdown2 = _service.GenerateBreakdown(result, profile, findings);
|
||||
|
||||
// Assert
|
||||
breakdown1.DeterminismHash.Should().Be(breakdown2.DeterminismHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateComparisonBreakdown_IncludesRiskTrends()
|
||||
{
|
||||
// Arrange
|
||||
var baseProfile = CreateTestProfile();
|
||||
var compareProfile = CreateTestProfileVariant();
|
||||
var findings = CreateTestFindings(20);
|
||||
var baseResult = CreateTestResult(findings, baseProfile);
|
||||
var compareResult = CreateTestResult(findings, compareProfile);
|
||||
|
||||
// Act
|
||||
var breakdown = _service.GenerateComparisonBreakdown(
|
||||
baseResult, compareResult,
|
||||
baseProfile, compareProfile,
|
||||
findings);
|
||||
|
||||
// Assert
|
||||
breakdown.RiskTrends.Should().NotBeNull();
|
||||
breakdown.RiskTrends!.ComparisonType.Should().Be("profile_comparison");
|
||||
breakdown.RiskTrends.ScoreTrend.Should().NotBeNull();
|
||||
breakdown.RiskTrends.SeverityTrend.Should().NotBeNull();
|
||||
breakdown.RiskTrends.ActionTrend.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateComparisonBreakdown_TracksImprovementsAndRegressions()
|
||||
{
|
||||
// Arrange
|
||||
var baseProfile = CreateTestProfile();
|
||||
var compareProfile = CreateTestProfile(); // Same profile = no changes
|
||||
var findings = CreateTestFindings(15);
|
||||
var baseResult = CreateTestResult(findings, baseProfile);
|
||||
var compareResult = CreateTestResult(findings, compareProfile);
|
||||
|
||||
// Act
|
||||
var breakdown = _service.GenerateComparisonBreakdown(
|
||||
baseResult, compareResult,
|
||||
baseProfile, compareProfile,
|
||||
findings);
|
||||
|
||||
// Assert
|
||||
var trends = breakdown.RiskTrends!;
|
||||
var total = trends.FindingsImproved + trends.FindingsWorsened + trends.FindingsUnchanged;
|
||||
total.Should().Be(findings.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateBreakdown_EmptyFindings_ReturnsValidBreakdown()
|
||||
{
|
||||
// Arrange
|
||||
var profile = CreateTestProfile();
|
||||
var findings = Array.Empty<SimulationFinding>();
|
||||
var result = CreateEmptyResult(profile);
|
||||
|
||||
// Act
|
||||
var breakdown = _service.GenerateBreakdown(result, profile, findings);
|
||||
|
||||
// Assert
|
||||
breakdown.Should().NotBeNull();
|
||||
breakdown.ScoreDistribution.RawScoreStats.Count.Should().Be(0);
|
||||
breakdown.SeverityBreakdown.BySeverity.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateBreakdown_MissingSignals_ReportsImpact()
|
||||
{
|
||||
// Arrange
|
||||
var profile = CreateTestProfile();
|
||||
var findings = CreateFindingsWithMissingSignals();
|
||||
var result = CreateTestResult(findings, profile);
|
||||
|
||||
// Act
|
||||
var breakdown = _service.GenerateBreakdown(result, profile, findings);
|
||||
|
||||
// Assert
|
||||
breakdown.SignalAnalysis.MissingSignalImpact.Should().NotBeNull();
|
||||
// Some findings have missing signals
|
||||
breakdown.SignalAnalysis.SignalsMissing.Should().BeGreaterOrEqualTo(0);
|
||||
}
|
||||
|
||||
#region Test Helpers
|
||||
|
||||
private static RiskProfileModel CreateTestProfile()
|
||||
{
|
||||
return new RiskProfileModel
|
||||
{
|
||||
Id = "test-profile",
|
||||
Version = "1.0.0",
|
||||
Description = "Test profile for unit tests",
|
||||
Signals = new List<RiskSignal>
|
||||
{
|
||||
new() { Name = "cvss", Source = "nvd", Type = RiskSignalType.Numeric },
|
||||
new() { Name = "kev", Source = "cisa", Type = RiskSignalType.Boolean },
|
||||
new() { Name = "reachability", Source = "scanner", Type = RiskSignalType.Numeric },
|
||||
new() { Name = "exploit_maturity", Source = "epss", Type = RiskSignalType.Categorical }
|
||||
},
|
||||
Weights = new Dictionary<string, double>
|
||||
{
|
||||
["cvss"] = 0.4,
|
||||
["kev"] = 0.3,
|
||||
["reachability"] = 0.2,
|
||||
["exploit_maturity"] = 0.1
|
||||
},
|
||||
Overrides = new RiskOverrides()
|
||||
};
|
||||
}
|
||||
|
||||
private static RiskProfileModel CreateTestProfileWithOverrides()
|
||||
{
|
||||
var profile = CreateTestProfile();
|
||||
profile.Overrides = new RiskOverrides
|
||||
{
|
||||
Severity = new List<SeverityOverride>
|
||||
{
|
||||
new()
|
||||
{
|
||||
When = new Dictionary<string, object> { ["kev"] = true },
|
||||
Set = RiskSeverity.Critical
|
||||
}
|
||||
},
|
||||
Decisions = new List<DecisionOverride>
|
||||
{
|
||||
new()
|
||||
{
|
||||
When = new Dictionary<string, object> { ["kev"] = true },
|
||||
Action = RiskAction.Deny,
|
||||
Reason = "KEV findings must be denied"
|
||||
}
|
||||
}
|
||||
};
|
||||
return profile;
|
||||
}
|
||||
|
||||
private static RiskProfileModel CreateTestProfileVariant()
|
||||
{
|
||||
var profile = CreateTestProfile();
|
||||
profile.Id = "test-profile-variant";
|
||||
profile.Weights = new Dictionary<string, double>
|
||||
{
|
||||
["cvss"] = 0.5, // Higher weight for CVSS
|
||||
["kev"] = 0.2,
|
||||
["reachability"] = 0.2,
|
||||
["exploit_maturity"] = 0.1
|
||||
};
|
||||
return profile;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SimulationFinding> CreateTestFindings(int count)
|
||||
{
|
||||
var random = new Random(42); // Deterministic seed
|
||||
return Enumerable.Range(1, count)
|
||||
.Select(i => new SimulationFinding(
|
||||
$"finding-{i}",
|
||||
$"pkg:npm/package-{i}@{i}.0.0",
|
||||
$"CVE-2024-{i:D4}",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["cvss"] = Math.Round(random.NextDouble() * 10, 1),
|
||||
["kev"] = random.Next(10) < 2, // 20% chance of KEV
|
||||
["reachability"] = Math.Round(random.NextDouble(), 2),
|
||||
["exploit_maturity"] = random.Next(4) switch
|
||||
{
|
||||
0 => "none",
|
||||
1 => "low",
|
||||
2 => "medium",
|
||||
_ => "high"
|
||||
}
|
||||
}))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SimulationFinding> CreateTestFindingsWithKev(int count)
|
||||
{
|
||||
return Enumerable.Range(1, count)
|
||||
.Select(i => new SimulationFinding(
|
||||
$"finding-{i}",
|
||||
$"pkg:npm/package-{i}@{i}.0.0",
|
||||
$"CVE-2024-{i:D4}",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["cvss"] = 8.0 + (i % 3),
|
||||
["kev"] = true, // All have KEV
|
||||
["reachability"] = 0.9,
|
||||
["exploit_maturity"] = "high"
|
||||
}))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SimulationFinding> CreateMixedEcosystemFindings()
|
||||
{
|
||||
return new List<SimulationFinding>
|
||||
{
|
||||
new("f1", "pkg:npm/lodash@4.17.0", "CVE-2024-0001", CreateSignals(7.5)),
|
||||
new("f2", "pkg:npm/express@4.0.0", "CVE-2024-0002", CreateSignals(6.0)),
|
||||
new("f3", "pkg:maven/org.apache.log4j/log4j-core@2.0.0", "CVE-2024-0003", CreateSignals(9.8)),
|
||||
new("f4", "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.9.0", "CVE-2024-0004", CreateSignals(7.2)),
|
||||
new("f5", "pkg:pypi/requests@2.25.0", "CVE-2024-0005", CreateSignals(5.5)),
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SimulationFinding> CreateFindingsWithMissingSignals()
|
||||
{
|
||||
return new List<SimulationFinding>
|
||||
{
|
||||
new("f1", "pkg:npm/a@1.0.0", "CVE-2024-0001",
|
||||
new Dictionary<string, object?> { ["cvss"] = 7.0 }), // Missing kev, reachability
|
||||
new("f2", "pkg:npm/b@1.0.0", "CVE-2024-0002",
|
||||
new Dictionary<string, object?> { ["cvss"] = 6.0, ["kev"] = false }), // Missing reachability
|
||||
new("f3", "pkg:npm/c@1.0.0", "CVE-2024-0003",
|
||||
new Dictionary<string, object?> { ["cvss"] = 8.0, ["kev"] = true, ["reachability"] = 0.5 }), // All present
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> CreateSignals(double cvss)
|
||||
{
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["cvss"] = cvss,
|
||||
["kev"] = cvss >= 9.0,
|
||||
["reachability"] = 0.7,
|
||||
["exploit_maturity"] = cvss >= 8.0 ? "high" : "medium"
|
||||
};
|
||||
}
|
||||
|
||||
private static RiskSimulationResult CreateTestResult(
|
||||
IReadOnlyList<SimulationFinding> findings,
|
||||
RiskProfileModel profile)
|
||||
{
|
||||
var findingScores = findings.Select(f =>
|
||||
{
|
||||
var cvss = f.Signals.GetValueOrDefault("cvss") switch
|
||||
{
|
||||
double d => d,
|
||||
_ => 5.0
|
||||
};
|
||||
var kev = f.Signals.GetValueOrDefault("kev") switch
|
||||
{
|
||||
bool b => b,
|
||||
_ => false
|
||||
};
|
||||
var reachability = f.Signals.GetValueOrDefault("reachability") switch
|
||||
{
|
||||
double d => d,
|
||||
_ => 0.5
|
||||
};
|
||||
|
||||
var rawScore = cvss * 0.4 + (kev ? 1.0 : 0.0) * 0.3 + reachability * 0.2;
|
||||
var normalizedScore = Math.Clamp(rawScore * 10, 0, 100);
|
||||
var severity = normalizedScore switch
|
||||
{
|
||||
>= 90 => RiskSeverity.Critical,
|
||||
>= 70 => RiskSeverity.High,
|
||||
>= 40 => RiskSeverity.Medium,
|
||||
>= 10 => RiskSeverity.Low,
|
||||
_ => RiskSeverity.Informational
|
||||
};
|
||||
var action = severity switch
|
||||
{
|
||||
RiskSeverity.Critical or RiskSeverity.High => RiskAction.Deny,
|
||||
RiskSeverity.Medium => RiskAction.Review,
|
||||
_ => RiskAction.Allow
|
||||
};
|
||||
|
||||
var contributions = new List<SignalContribution>
|
||||
{
|
||||
new("cvss", cvss, 0.4, cvss * 0.4, rawScore > 0 ? cvss * 0.4 / rawScore * 100 : 0),
|
||||
new("kev", kev, 0.3, (kev ? 1.0 : 0.0) * 0.3, rawScore > 0 ? (kev ? 0.3 : 0.0) / rawScore * 100 : 0),
|
||||
new("reachability", reachability, 0.2, reachability * 0.2, rawScore > 0 ? reachability * 0.2 / rawScore * 100 : 0)
|
||||
};
|
||||
|
||||
return new FindingScore(
|
||||
f.FindingId,
|
||||
rawScore,
|
||||
normalizedScore,
|
||||
severity,
|
||||
action,
|
||||
contributions,
|
||||
null);
|
||||
}).ToList();
|
||||
|
||||
var aggregateMetrics = new AggregateRiskMetrics(
|
||||
findings.Count,
|
||||
findingScores.Count > 0 ? findingScores.Average(s => s.NormalizedScore) : 0,
|
||||
findingScores.Count > 0 ? findingScores.OrderBy(s => s.NormalizedScore).ElementAt(findingScores.Count / 2).NormalizedScore : 0,
|
||||
0, // std dev
|
||||
findingScores.Count > 0 ? findingScores.Max(s => s.NormalizedScore) : 0,
|
||||
findingScores.Count > 0 ? findingScores.Min(s => s.NormalizedScore) : 0,
|
||||
findingScores.Count(s => s.Severity == RiskSeverity.Critical),
|
||||
findingScores.Count(s => s.Severity == RiskSeverity.High),
|
||||
findingScores.Count(s => s.Severity == RiskSeverity.Medium),
|
||||
findingScores.Count(s => s.Severity == RiskSeverity.Low),
|
||||
findingScores.Count(s => s.Severity == RiskSeverity.Informational));
|
||||
|
||||
return new RiskSimulationResult(
|
||||
SimulationId: $"rsim-test-{Guid.NewGuid():N}",
|
||||
ProfileId: profile.Id,
|
||||
ProfileVersion: profile.Version,
|
||||
ProfileHash: $"sha256:test{profile.Id.GetHashCode():x8}",
|
||||
Timestamp: DateTimeOffset.UtcNow,
|
||||
FindingScores: findingScores,
|
||||
Distribution: null,
|
||||
TopMovers: null,
|
||||
AggregateMetrics: aggregateMetrics,
|
||||
ExecutionTimeMs: 10.5);
|
||||
}
|
||||
|
||||
private static RiskSimulationResult CreateTestResultWithOverrides(
|
||||
IReadOnlyList<SimulationFinding> findings,
|
||||
RiskProfileModel profile)
|
||||
{
|
||||
var result = CreateTestResult(findings, profile);
|
||||
|
||||
// Add overrides to findings with KEV
|
||||
var findingScoresWithOverrides = result.FindingScores.Select(fs =>
|
||||
{
|
||||
var finding = findings.FirstOrDefault(f => f.FindingId == fs.FindingId);
|
||||
var kev = finding?.Signals.GetValueOrDefault("kev") switch { bool b => b, _ => false };
|
||||
|
||||
if (kev)
|
||||
{
|
||||
return fs with
|
||||
{
|
||||
Severity = RiskSeverity.Critical,
|
||||
RecommendedAction = RiskAction.Deny,
|
||||
OverridesApplied = new List<AppliedOverride>
|
||||
{
|
||||
new("severity",
|
||||
new Dictionary<string, object> { ["kev"] = true },
|
||||
fs.Severity.ToString(),
|
||||
RiskSeverity.Critical.ToString(),
|
||||
null),
|
||||
new("decision",
|
||||
new Dictionary<string, object> { ["kev"] = true },
|
||||
fs.RecommendedAction.ToString(),
|
||||
RiskAction.Deny.ToString(),
|
||||
"KEV findings must be denied")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return fs;
|
||||
}).ToList();
|
||||
|
||||
return result with { FindingScores = findingScoresWithOverrides };
|
||||
}
|
||||
|
||||
private static RiskSimulationResult CreateEmptyResult(RiskProfileModel profile)
|
||||
{
|
||||
return new RiskSimulationResult(
|
||||
SimulationId: "rsim-empty",
|
||||
ProfileId: profile.Id,
|
||||
ProfileVersion: profile.Version,
|
||||
ProfileHash: "sha256:empty",
|
||||
Timestamp: DateTimeOffset.UtcNow,
|
||||
FindingScores: Array.Empty<FindingScore>(),
|
||||
Distribution: null,
|
||||
TopMovers: null,
|
||||
AggregateMetrics: new AggregateRiskMetrics(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||
ExecutionTimeMs: 1.0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -12,4 +12,24 @@ internal sealed class BunLockEntry
|
||||
public bool IsDev { get; init; }
|
||||
public bool IsOptional { get; init; }
|
||||
public bool IsPeer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source type: npm, git, tarball, file, link, workspace.
|
||||
/// </summary>
|
||||
public string SourceType { get; init; } = "npm";
|
||||
|
||||
/// <summary>
|
||||
/// Git commit hash if this is a git dependency.
|
||||
/// </summary>
|
||||
public string? GitCommit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original specifier (e.g., "github:user/repo#tag").
|
||||
/// </summary>
|
||||
public string? Specifier { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Dependencies of this package (for transitive analysis).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Dependencies { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
@@ -136,17 +136,35 @@ internal static class BunLockParser
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Array && element.GetArrayLength() >= 1)
|
||||
{
|
||||
// bun.lock v1 format: [resolved, hash, deps, isDev?]
|
||||
// bun.lock v1 format: [resolved, integrity, dependencies?, optionalPeers?]
|
||||
// The resolved URL indicates the source type
|
||||
var resolved = element[0].GetString();
|
||||
var integrity = element.GetArrayLength() > 1 ? element[1].GetString() : null;
|
||||
|
||||
// Parse dependencies from element[2] if present
|
||||
var dependencies = new List<string>();
|
||||
if (element.GetArrayLength() > 2 && element[2].ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var dep in element[2].EnumerateObject())
|
||||
{
|
||||
dependencies.Add(dep.Name);
|
||||
}
|
||||
}
|
||||
|
||||
// Detect source type and extract additional metadata
|
||||
var (sourceType, gitCommit, specifier) = ClassifyResolvedUrl(resolved);
|
||||
|
||||
return new BunLockEntry
|
||||
{
|
||||
Name = name,
|
||||
Version = version,
|
||||
Resolved = resolved,
|
||||
Integrity = integrity,
|
||||
IsDev = false // Will be determined by dependency graph analysis if needed
|
||||
IsDev = false, // Bun lockfile doesn't mark dev in the array; determined by graph
|
||||
SourceType = sourceType,
|
||||
GitCommit = gitCommit,
|
||||
Specifier = specifier,
|
||||
Dependencies = dependencies
|
||||
};
|
||||
}
|
||||
|
||||
@@ -156,6 +174,10 @@ internal static class BunLockParser
|
||||
var resolved = element.TryGetProperty("resolved", out var r) ? r.GetString() : null;
|
||||
var integrity = element.TryGetProperty("integrity", out var i) ? i.GetString() : null;
|
||||
var isDev = element.TryGetProperty("dev", out var d) && d.GetBoolean();
|
||||
var isOptional = element.TryGetProperty("optional", out var o) && o.GetBoolean();
|
||||
var isPeer = element.TryGetProperty("peer", out var p) && p.GetBoolean();
|
||||
|
||||
var (sourceType, gitCommit, specifier) = ClassifyResolvedUrl(resolved);
|
||||
|
||||
return new BunLockEntry
|
||||
{
|
||||
@@ -163,23 +185,108 @@ internal static class BunLockParser
|
||||
Version = version,
|
||||
Resolved = resolved,
|
||||
Integrity = integrity,
|
||||
IsDev = isDev
|
||||
IsDev = isDev,
|
||||
IsOptional = isOptional,
|
||||
IsPeer = isPeer,
|
||||
SourceType = sourceType,
|
||||
GitCommit = gitCommit,
|
||||
Specifier = specifier
|
||||
};
|
||||
}
|
||||
|
||||
// Simple string value (just the resolved URL)
|
||||
if (element.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var resolved = element.GetString();
|
||||
var (sourceType, gitCommit, specifier) = ClassifyResolvedUrl(resolved);
|
||||
|
||||
return new BunLockEntry
|
||||
{
|
||||
Name = name,
|
||||
Version = version,
|
||||
Resolved = element.GetString(),
|
||||
Resolved = resolved,
|
||||
Integrity = null,
|
||||
IsDev = false
|
||||
IsDev = false,
|
||||
SourceType = sourceType,
|
||||
GitCommit = gitCommit,
|
||||
Specifier = specifier
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classifies the resolved URL to detect git, tarball, file, or npm sources.
|
||||
/// </summary>
|
||||
private static (string SourceType, string? GitCommit, string? Specifier) ClassifyResolvedUrl(string? resolved)
|
||||
{
|
||||
if (string.IsNullOrEmpty(resolved))
|
||||
{
|
||||
return ("npm", null, null);
|
||||
}
|
||||
|
||||
// Git dependencies: git+https://, git+ssh://, github:, gitlab:, bitbucket:
|
||||
if (resolved.StartsWith("git+", StringComparison.OrdinalIgnoreCase) ||
|
||||
resolved.StartsWith("git://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var commit = ExtractGitCommit(resolved);
|
||||
return ("git", commit, resolved);
|
||||
}
|
||||
|
||||
if (resolved.StartsWith("github:", StringComparison.OrdinalIgnoreCase) ||
|
||||
resolved.StartsWith("gitlab:", StringComparison.OrdinalIgnoreCase) ||
|
||||
resolved.StartsWith("bitbucket:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var commit = ExtractGitCommit(resolved);
|
||||
return ("git", commit, resolved);
|
||||
}
|
||||
|
||||
// Tarball URLs (not from npm registry)
|
||||
if ((resolved.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
|
||||
resolved.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) &&
|
||||
!resolved.Contains("registry.npmjs.org", StringComparison.OrdinalIgnoreCase) &&
|
||||
!resolved.Contains("registry.npm.", StringComparison.OrdinalIgnoreCase) &&
|
||||
(resolved.EndsWith(".tgz", StringComparison.OrdinalIgnoreCase) ||
|
||||
resolved.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return ("tarball", null, resolved);
|
||||
}
|
||||
|
||||
// File dependencies: file:, link:
|
||||
if (resolved.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ("file", null, resolved);
|
||||
}
|
||||
|
||||
if (resolved.StartsWith("link:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ("link", null, resolved);
|
||||
}
|
||||
|
||||
// Workspace dependencies
|
||||
if (resolved.StartsWith("workspace:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ("workspace", null, resolved);
|
||||
}
|
||||
|
||||
// Default to npm for standard registry URLs
|
||||
return ("npm", null, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts git commit hash from a git URL (after # or @).
|
||||
/// </summary>
|
||||
private static string? ExtractGitCommit(string url)
|
||||
{
|
||||
// Format: git+https://github.com/user/repo#commit
|
||||
// or: github:user/repo#tag
|
||||
var hashIndex = url.LastIndexOf('#');
|
||||
if (hashIndex > 0 && hashIndex < url.Length - 1)
|
||||
{
|
||||
return url[(hashIndex + 1)..];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"type": "npm",
|
||||
"usedByEntrypoint": false,
|
||||
"metadata": {
|
||||
"integrity": "sha512-Wu1VZAVuL1snqOnHLxJ0l2p3pjlzLnMcJ8gJhaTZVfP7VFKN7fSJ8X/gR0qFCLwfFJ0Rqd3IxfS+TY/Lc1Q7Pw==",
|
||||
"integrity": "sha512-Wu1VZAVuL1snqOnHLxJ0l2p3pjlzLnMcJ8gJhaTZVfP7VFKN7fSJ8X/gR0qFCLwfFJ0Rqd3IxfS\u002BTY/Lc1Q7Pw==",
|
||||
"packageManager": "bun",
|
||||
"path": "node_modules/.bun/is-number@6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-6.0.0.tgz",
|
||||
@@ -22,15 +22,15 @@
|
||||
},
|
||||
{
|
||||
"kind": "metadata",
|
||||
"source": "resolved",
|
||||
"source": "integrity",
|
||||
"locator": "bun.lock",
|
||||
"value": "https://registry.npmjs.org/is-number/-/is-number-6.0.0.tgz"
|
||||
"value": "sha512-Wu1VZAVuL1snqOnHLxJ0l2p3pjlzLnMcJ8gJhaTZVfP7VFKN7fSJ8X/gR0qFCLwfFJ0Rqd3IxfS\u002BTY/Lc1Q7Pw=="
|
||||
},
|
||||
{
|
||||
"kind": "metadata",
|
||||
"source": "integrity",
|
||||
"source": "resolved",
|
||||
"locator": "bun.lock",
|
||||
"value": "sha512-Wu1VZAVuL1snqOnHLxJ0l2p3pjlzLnMcJ8gJhaTZVfP7VFKN7fSJ8X/gR0qFCLwfFJ0Rqd3IxfS+TY/Lc1Q7Pw=="
|
||||
"value": "https://registry.npmjs.org/is-number/-/is-number-6.0.0.tgz"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -43,7 +43,7 @@
|
||||
"type": "npm",
|
||||
"usedByEntrypoint": false,
|
||||
"metadata": {
|
||||
"integrity": "sha512-CQpnWPrDwmP1+SMHXvTXAoSEu2mCPgMU0VKt1WcA7D8VXCo4HfVNlUbD1k8Tg0BVDX/LhyRaZqKqiS4vI6tTHg==",
|
||||
"integrity": "sha512-CQpnWPrDwmP1\u002BSMHXvTXAoSEu2mCPgMU0VKt1WcA7D8VXCo4HfVNlUbD1k8Tg0BVDX/LhyRaZqKqiS4vI6tTHg==",
|
||||
"packageManager": "bun",
|
||||
"path": "node_modules/.bun/is-odd@3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-odd/-/is-odd-3.0.1.tgz",
|
||||
@@ -57,15 +57,15 @@
|
||||
},
|
||||
{
|
||||
"kind": "metadata",
|
||||
"source": "resolved",
|
||||
"source": "integrity",
|
||||
"locator": "bun.lock",
|
||||
"value": "https://registry.npmjs.org/is-odd/-/is-odd-3.0.1.tgz"
|
||||
"value": "sha512-CQpnWPrDwmP1\u002BSMHXvTXAoSEu2mCPgMU0VKt1WcA7D8VXCo4HfVNlUbD1k8Tg0BVDX/LhyRaZqKqiS4vI6tTHg=="
|
||||
},
|
||||
{
|
||||
"kind": "metadata",
|
||||
"source": "integrity",
|
||||
"source": "resolved",
|
||||
"locator": "bun.lock",
|
||||
"value": "sha512-CQpnWPrDwmP1+SMHXvTXAoSEu2mCPgMU0VKt1WcA7D8VXCo4HfVNlUbD1k8Tg0BVDX/LhyRaZqKqiS4vI6tTHg=="
|
||||
"value": "https://registry.npmjs.org/is-odd/-/is-odd-3.0.1.tgz"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -14,17 +14,17 @@
|
||||
"source": "bun.lock"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "metadata",
|
||||
"source": "resolved",
|
||||
"locator": "bun.lock",
|
||||
"value": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz"
|
||||
},
|
||||
{
|
||||
"kind": "metadata",
|
||||
"source": "integrity",
|
||||
"locator": "bun.lock",
|
||||
"value": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
{
|
||||
"kind": "metadata",
|
||||
"source": "resolved",
|
||||
"locator": "bun.lock",
|
||||
"value": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
"type": "npm",
|
||||
"usedByEntrypoint": false,
|
||||
"metadata": {
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vz1kAmtILi\u002B8fm9nJMg7b0GN8sMEJz2mxG/S7mNxhWQ7\u002BD9bF8Q==",
|
||||
"packageManager": "bun",
|
||||
"path": "node_modules/lodash",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"source": "node_modules"
|
||||
},
|
||||
"evidence": [
|
||||
@@ -17,6 +19,18 @@
|
||||
"kind": "file",
|
||||
"source": "node_modules",
|
||||
"locator": "node_modules/lodash/package.json"
|
||||
},
|
||||
{
|
||||
"kind": "metadata",
|
||||
"source": "integrity",
|
||||
"locator": "bun.lock",
|
||||
"value": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vz1kAmtILi\u002B8fm9nJMg7b0GN8sMEJz2mxG/S7mNxhWQ7\u002BD9bF8Q=="
|
||||
},
|
||||
{
|
||||
"kind": "metadata",
|
||||
"source": "resolved",
|
||||
"locator": "bun.lock",
|
||||
"value": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -20,17 +20,17 @@
|
||||
"source": "node_modules",
|
||||
"locator": "node_modules/safe-pkg/package.json"
|
||||
},
|
||||
{
|
||||
"kind": "metadata",
|
||||
"source": "resolved",
|
||||
"locator": "bun.lock",
|
||||
"value": "https://registry.npmjs.org/safe-pkg/-/safe-pkg-1.0.0.tgz"
|
||||
},
|
||||
{
|
||||
"kind": "metadata",
|
||||
"source": "integrity",
|
||||
"locator": "bun.lock",
|
||||
"value": "sha512-abc123"
|
||||
},
|
||||
{
|
||||
"kind": "metadata",
|
||||
"source": "resolved",
|
||||
"locator": "bun.lock",
|
||||
"value": "https://registry.npmjs.org/safe-pkg/-/safe-pkg-1.0.0.tgz"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"type": "npm",
|
||||
"usedByEntrypoint": false,
|
||||
"metadata": {
|
||||
"integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==",
|
||||
"integrity": "sha512-dLitG79d\u002BGV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos\u002Buw7WmWF4wUwBd9jxjocFC2w==",
|
||||
"packageManager": "bun",
|
||||
"path": "node_modules/chalk",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz",
|
||||
@@ -22,15 +22,15 @@
|
||||
},
|
||||
{
|
||||
"kind": "metadata",
|
||||
"source": "resolved",
|
||||
"source": "integrity",
|
||||
"locator": "bun.lock",
|
||||
"value": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz"
|
||||
"value": "sha512-dLitG79d\u002BGV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos\u002Buw7WmWF4wUwBd9jxjocFC2w=="
|
||||
},
|
||||
{
|
||||
"kind": "metadata",
|
||||
"source": "integrity",
|
||||
"source": "resolved",
|
||||
"locator": "bun.lock",
|
||||
"value": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w=="
|
||||
"value": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
264
src/VexLens/StellaOps.VexLens/Api/ConsensusApiModels.cs
Normal file
264
src/VexLens/StellaOps.VexLens/Api/ConsensusApiModels.cs
Normal file
@@ -0,0 +1,264 @@
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.Storage;
|
||||
|
||||
namespace StellaOps.VexLens.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Request to compute consensus for a vulnerability-product pair.
|
||||
/// </summary>
|
||||
public sealed record ComputeConsensusRequest(
|
||||
string VulnerabilityId,
|
||||
string ProductKey,
|
||||
string? TenantId,
|
||||
ConsensusMode? Mode,
|
||||
double? MinimumWeightThreshold,
|
||||
bool? StoreResult,
|
||||
bool? EmitEvent);
|
||||
|
||||
/// <summary>
|
||||
/// Request to compute consensus for multiple pairs in batch.
|
||||
/// </summary>
|
||||
public sealed record ComputeConsensusBatchRequest(
|
||||
IReadOnlyList<ConsensusTarget> Targets,
|
||||
string? TenantId,
|
||||
ConsensusMode? Mode,
|
||||
bool? StoreResults,
|
||||
bool? EmitEvents);
|
||||
|
||||
/// <summary>
|
||||
/// Target for consensus computation.
|
||||
/// </summary>
|
||||
public sealed record ConsensusTarget(
|
||||
string VulnerabilityId,
|
||||
string ProductKey);
|
||||
|
||||
/// <summary>
|
||||
/// Response from consensus computation.
|
||||
/// </summary>
|
||||
public sealed record ComputeConsensusResponse(
|
||||
string VulnerabilityId,
|
||||
string ProductKey,
|
||||
VexStatus Status,
|
||||
VexJustification? Justification,
|
||||
double ConfidenceScore,
|
||||
string Outcome,
|
||||
ConsensusRationaleResponse Rationale,
|
||||
IReadOnlyList<ContributionResponse> Contributions,
|
||||
IReadOnlyList<ConflictResponse>? Conflicts,
|
||||
string? ProjectionId,
|
||||
DateTimeOffset ComputedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Rationale response in API format.
|
||||
/// </summary>
|
||||
public sealed record ConsensusRationaleResponse(
|
||||
string Summary,
|
||||
IReadOnlyList<string> Factors,
|
||||
IReadOnlyDictionary<string, double> StatusWeights);
|
||||
|
||||
/// <summary>
|
||||
/// Statement contribution response.
|
||||
/// </summary>
|
||||
public sealed record ContributionResponse(
|
||||
string StatementId,
|
||||
string? IssuerId,
|
||||
VexStatus Status,
|
||||
VexJustification? Justification,
|
||||
double Weight,
|
||||
double Contribution,
|
||||
bool IsWinner);
|
||||
|
||||
/// <summary>
|
||||
/// Conflict response.
|
||||
/// </summary>
|
||||
public sealed record ConflictResponse(
|
||||
string Statement1Id,
|
||||
string Statement2Id,
|
||||
VexStatus Status1,
|
||||
VexStatus Status2,
|
||||
string Severity,
|
||||
string Resolution);
|
||||
|
||||
/// <summary>
|
||||
/// Response from batch consensus computation.
|
||||
/// </summary>
|
||||
public sealed record ComputeConsensusBatchResponse(
|
||||
IReadOnlyList<ComputeConsensusResponse> Results,
|
||||
int TotalCount,
|
||||
int SuccessCount,
|
||||
int FailureCount,
|
||||
DateTimeOffset CompletedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Request to query consensus projections.
|
||||
/// </summary>
|
||||
public sealed record QueryProjectionsRequest(
|
||||
string? VulnerabilityId,
|
||||
string? ProductKey,
|
||||
VexStatus? Status,
|
||||
string? Outcome,
|
||||
double? MinimumConfidence,
|
||||
DateTimeOffset? ComputedAfter,
|
||||
DateTimeOffset? ComputedBefore,
|
||||
bool? StatusChanged,
|
||||
int? Limit,
|
||||
int? Offset,
|
||||
string? SortBy,
|
||||
bool? SortDescending);
|
||||
|
||||
/// <summary>
|
||||
/// Response from projection query.
|
||||
/// </summary>
|
||||
public sealed record QueryProjectionsResponse(
|
||||
IReadOnlyList<ProjectionSummary> Projections,
|
||||
int TotalCount,
|
||||
int Offset,
|
||||
int Limit);
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a projection for list responses.
|
||||
/// </summary>
|
||||
public sealed record ProjectionSummary(
|
||||
string ProjectionId,
|
||||
string VulnerabilityId,
|
||||
string ProductKey,
|
||||
VexStatus Status,
|
||||
VexJustification? Justification,
|
||||
double ConfidenceScore,
|
||||
string Outcome,
|
||||
int StatementCount,
|
||||
int ConflictCount,
|
||||
DateTimeOffset ComputedAt,
|
||||
bool StatusChanged);
|
||||
|
||||
/// <summary>
|
||||
/// Detailed projection response.
|
||||
/// </summary>
|
||||
public sealed record ProjectionDetailResponse(
|
||||
string ProjectionId,
|
||||
string VulnerabilityId,
|
||||
string ProductKey,
|
||||
string? TenantId,
|
||||
VexStatus Status,
|
||||
VexJustification? Justification,
|
||||
double ConfidenceScore,
|
||||
string Outcome,
|
||||
int StatementCount,
|
||||
int ConflictCount,
|
||||
string RationaleSummary,
|
||||
DateTimeOffset ComputedAt,
|
||||
DateTimeOffset StoredAt,
|
||||
string? PreviousProjectionId,
|
||||
bool StatusChanged);
|
||||
|
||||
/// <summary>
|
||||
/// Response from projection history query.
|
||||
/// </summary>
|
||||
public sealed record ProjectionHistoryResponse(
|
||||
string VulnerabilityId,
|
||||
string ProductKey,
|
||||
IReadOnlyList<ProjectionSummary> History,
|
||||
int TotalCount);
|
||||
|
||||
/// <summary>
|
||||
/// Response from issuer directory query.
|
||||
/// </summary>
|
||||
public sealed record IssuerListResponse(
|
||||
IReadOnlyList<IssuerSummary> Issuers,
|
||||
int TotalCount);
|
||||
|
||||
/// <summary>
|
||||
/// Summary of an issuer.
|
||||
/// </summary>
|
||||
public sealed record IssuerSummary(
|
||||
string IssuerId,
|
||||
string Name,
|
||||
string Category,
|
||||
string TrustTier,
|
||||
string Status,
|
||||
int KeyCount,
|
||||
DateTimeOffset RegisteredAt);
|
||||
|
||||
/// <summary>
|
||||
/// Detailed issuer response.
|
||||
/// </summary>
|
||||
public sealed record IssuerDetailResponse(
|
||||
string IssuerId,
|
||||
string Name,
|
||||
string Category,
|
||||
string TrustTier,
|
||||
string Status,
|
||||
IReadOnlyList<KeyFingerprintResponse> KeyFingerprints,
|
||||
IssuerMetadataResponse? Metadata,
|
||||
DateTimeOffset RegisteredAt,
|
||||
DateTimeOffset? LastUpdatedAt,
|
||||
DateTimeOffset? RevokedAt,
|
||||
string? RevocationReason);
|
||||
|
||||
/// <summary>
|
||||
/// Key fingerprint response.
|
||||
/// </summary>
|
||||
public sealed record KeyFingerprintResponse(
|
||||
string Fingerprint,
|
||||
string KeyType,
|
||||
string? Algorithm,
|
||||
string Status,
|
||||
DateTimeOffset RegisteredAt,
|
||||
DateTimeOffset? ExpiresAt);
|
||||
|
||||
/// <summary>
|
||||
/// Issuer metadata response.
|
||||
/// </summary>
|
||||
public sealed record IssuerMetadataResponse(
|
||||
string? Description,
|
||||
string? Uri,
|
||||
string? Email,
|
||||
IReadOnlyList<string>? Tags);
|
||||
|
||||
/// <summary>
|
||||
/// Request to register an issuer.
|
||||
/// </summary>
|
||||
public sealed record RegisterIssuerRequest(
|
||||
string IssuerId,
|
||||
string Name,
|
||||
string Category,
|
||||
string TrustTier,
|
||||
IReadOnlyList<RegisterKeyRequest>? InitialKeys,
|
||||
IssuerMetadataRequest? Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Request to register a key.
|
||||
/// </summary>
|
||||
public sealed record RegisterKeyRequest(
|
||||
string Fingerprint,
|
||||
string KeyType,
|
||||
string? Algorithm,
|
||||
DateTimeOffset? ExpiresAt);
|
||||
|
||||
/// <summary>
|
||||
/// Issuer metadata request.
|
||||
/// </summary>
|
||||
public sealed record IssuerMetadataRequest(
|
||||
string? Description,
|
||||
string? Uri,
|
||||
string? Email,
|
||||
IReadOnlyList<string>? Tags);
|
||||
|
||||
/// <summary>
|
||||
/// Request to revoke an issuer or key.
|
||||
/// </summary>
|
||||
public sealed record RevokeRequest(
|
||||
string Reason);
|
||||
|
||||
/// <summary>
|
||||
/// Statistics about consensus projections.
|
||||
/// </summary>
|
||||
public sealed record ConsensusStatisticsResponse(
|
||||
int TotalProjections,
|
||||
IReadOnlyDictionary<string, int> ByStatus,
|
||||
IReadOnlyDictionary<string, int> ByOutcome,
|
||||
double AverageConfidence,
|
||||
int ProjectionsWithConflicts,
|
||||
int StatusChangesLast24h,
|
||||
DateTimeOffset ComputedAt);
|
||||
477
src/VexLens/StellaOps.VexLens/Api/ConsensusRationaleModels.cs
Normal file
477
src/VexLens/StellaOps.VexLens/Api/ConsensusRationaleModels.cs
Normal file
@@ -0,0 +1,477 @@
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Detailed consensus rationale for AI/ML consumption.
|
||||
/// Note: Named with "Detailed" suffix to avoid conflict with Consensus.ConsensusRationale.
|
||||
/// </summary>
|
||||
public sealed record DetailedConsensusRationale(
|
||||
/// <summary>
|
||||
/// Unique identifier for this rationale.
|
||||
/// </summary>
|
||||
string RationaleId,
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability ID.
|
||||
/// </summary>
|
||||
string VulnerabilityId,
|
||||
|
||||
/// <summary>
|
||||
/// Product key.
|
||||
/// </summary>
|
||||
string ProductKey,
|
||||
|
||||
/// <summary>
|
||||
/// Final consensus status.
|
||||
/// </summary>
|
||||
VexStatus ConsensusStatus,
|
||||
|
||||
/// <summary>
|
||||
/// Final justification if applicable.
|
||||
/// </summary>
|
||||
VexJustification? ConsensusJustification,
|
||||
|
||||
/// <summary>
|
||||
/// Overall confidence score (0.0-1.0).
|
||||
/// </summary>
|
||||
double ConfidenceScore,
|
||||
|
||||
/// <summary>
|
||||
/// Consensus outcome classification.
|
||||
/// </summary>
|
||||
ConsensusOutcome Outcome,
|
||||
|
||||
/// <summary>
|
||||
/// Mode used for consensus computation.
|
||||
/// </summary>
|
||||
ConsensusMode Mode,
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable summary of the consensus decision.
|
||||
/// </summary>
|
||||
string Summary,
|
||||
|
||||
/// <summary>
|
||||
/// Detailed explanation of why this consensus was reached.
|
||||
/// </summary>
|
||||
string Explanation,
|
||||
|
||||
/// <summary>
|
||||
/// Individual contributions from each statement.
|
||||
/// </summary>
|
||||
IReadOnlyList<RationaleContribution> Contributions,
|
||||
|
||||
/// <summary>
|
||||
/// Detected conflicts between statements.
|
||||
/// </summary>
|
||||
IReadOnlyList<RationaleConflict> Conflicts,
|
||||
|
||||
/// <summary>
|
||||
/// Factors that influenced the final decision.
|
||||
/// </summary>
|
||||
IReadOnlyList<RationaleFactor> DecisionFactors,
|
||||
|
||||
/// <summary>
|
||||
/// Alternative outcomes that were considered.
|
||||
/// </summary>
|
||||
IReadOnlyList<AlternativeOutcome> Alternatives,
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for audit and reproducibility.
|
||||
/// </summary>
|
||||
RationaleMetadata Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Contribution from a single statement to the consensus.
|
||||
/// </summary>
|
||||
public sealed record RationaleContribution(
|
||||
/// <summary>
|
||||
/// Statement identifier.
|
||||
/// </summary>
|
||||
string StatementId,
|
||||
|
||||
/// <summary>
|
||||
/// Issuer that made this statement.
|
||||
/// </summary>
|
||||
string IssuerId,
|
||||
|
||||
/// <summary>
|
||||
/// Issuer name for display.
|
||||
/// </summary>
|
||||
string? IssuerName,
|
||||
|
||||
/// <summary>
|
||||
/// Issuer category (Vendor, Aggregator, etc.).
|
||||
/// </summary>
|
||||
string IssuerCategory,
|
||||
|
||||
/// <summary>
|
||||
/// Issuer trust tier.
|
||||
/// </summary>
|
||||
string TrustTier,
|
||||
|
||||
/// <summary>
|
||||
/// Status asserted by this statement.
|
||||
/// </summary>
|
||||
VexStatus Status,
|
||||
|
||||
/// <summary>
|
||||
/// Justification if provided.
|
||||
/// </summary>
|
||||
VexJustification? Justification,
|
||||
|
||||
/// <summary>
|
||||
/// Raw trust weight from issuer profile.
|
||||
/// </summary>
|
||||
double RawWeight,
|
||||
|
||||
/// <summary>
|
||||
/// Final computed weight after all adjustments.
|
||||
/// </summary>
|
||||
double FinalWeight,
|
||||
|
||||
/// <summary>
|
||||
/// Weight adjustment factors applied.
|
||||
/// </summary>
|
||||
IReadOnlyList<WeightAdjustment> Adjustments,
|
||||
|
||||
/// <summary>
|
||||
/// Whether this contribution won the consensus.
|
||||
/// </summary>
|
||||
bool IsWinner,
|
||||
|
||||
/// <summary>
|
||||
/// Relative influence on the final decision (0.0-1.0).
|
||||
/// </summary>
|
||||
double Influence,
|
||||
|
||||
/// <summary>
|
||||
/// When this statement was issued.
|
||||
/// </summary>
|
||||
DateTimeOffset? IssuedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Weight adjustment factor.
|
||||
/// </summary>
|
||||
public sealed record WeightAdjustment(
|
||||
/// <summary>
|
||||
/// Factor name (e.g., "freshness", "signature", "justification").
|
||||
/// </summary>
|
||||
string Factor,
|
||||
|
||||
/// <summary>
|
||||
/// Multiplier applied (e.g., 1.2 for 20% boost).
|
||||
/// </summary>
|
||||
double Multiplier,
|
||||
|
||||
/// <summary>
|
||||
/// Weight before this adjustment.
|
||||
/// </summary>
|
||||
double WeightBefore,
|
||||
|
||||
/// <summary>
|
||||
/// Weight after this adjustment.
|
||||
/// </summary>
|
||||
double WeightAfter,
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable reason for the adjustment.
|
||||
/// </summary>
|
||||
string Reason);
|
||||
|
||||
/// <summary>
|
||||
/// Conflict between statements in the consensus.
|
||||
/// </summary>
|
||||
public sealed record RationaleConflict(
|
||||
/// <summary>
|
||||
/// Conflict identifier.
|
||||
/// </summary>
|
||||
string ConflictId,
|
||||
|
||||
/// <summary>
|
||||
/// Type of conflict.
|
||||
/// </summary>
|
||||
string ConflictType,
|
||||
|
||||
/// <summary>
|
||||
/// Severity of the conflict.
|
||||
/// </summary>
|
||||
string Severity,
|
||||
|
||||
/// <summary>
|
||||
/// First conflicting statement.
|
||||
/// </summary>
|
||||
string StatementA,
|
||||
|
||||
/// <summary>
|
||||
/// Second conflicting statement.
|
||||
/// </summary>
|
||||
string StatementB,
|
||||
|
||||
/// <summary>
|
||||
/// Status from first statement.
|
||||
/// </summary>
|
||||
VexStatus StatusA,
|
||||
|
||||
/// <summary>
|
||||
/// Status from second statement.
|
||||
/// </summary>
|
||||
VexStatus StatusB,
|
||||
|
||||
/// <summary>
|
||||
/// Weight difference between conflicting statements.
|
||||
/// </summary>
|
||||
double WeightDelta,
|
||||
|
||||
/// <summary>
|
||||
/// How the conflict was resolved.
|
||||
/// </summary>
|
||||
string Resolution,
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description of the conflict.
|
||||
/// </summary>
|
||||
string Description);
|
||||
|
||||
/// <summary>
|
||||
/// Factor that influenced the consensus decision.
|
||||
/// </summary>
|
||||
public sealed record RationaleFactor(
|
||||
/// <summary>
|
||||
/// Factor name.
|
||||
/// </summary>
|
||||
string Name,
|
||||
|
||||
/// <summary>
|
||||
/// Factor category (trust, freshness, coverage, etc.).
|
||||
/// </summary>
|
||||
string Category,
|
||||
|
||||
/// <summary>
|
||||
/// Numeric impact on the decision (-1.0 to 1.0).
|
||||
/// </summary>
|
||||
double Impact,
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description of the factor's influence.
|
||||
/// </summary>
|
||||
string Description,
|
||||
|
||||
/// <summary>
|
||||
/// Supporting evidence for this factor.
|
||||
/// </summary>
|
||||
IReadOnlyList<string>? Evidence);
|
||||
|
||||
/// <summary>
|
||||
/// Alternative outcome that was considered but not chosen.
|
||||
/// </summary>
|
||||
public sealed record AlternativeOutcome(
|
||||
/// <summary>
|
||||
/// Alternative status.
|
||||
/// </summary>
|
||||
VexStatus Status,
|
||||
|
||||
/// <summary>
|
||||
/// Confidence this alternative would have had.
|
||||
/// </summary>
|
||||
double Confidence,
|
||||
|
||||
/// <summary>
|
||||
/// Total weight supporting this alternative.
|
||||
/// </summary>
|
||||
double TotalWeight,
|
||||
|
||||
/// <summary>
|
||||
/// Number of statements supporting this alternative.
|
||||
/// </summary>
|
||||
int SupportingStatements,
|
||||
|
||||
/// <summary>
|
||||
/// Why this alternative was not chosen.
|
||||
/// </summary>
|
||||
string RejectionReason);
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for audit and reproducibility.
|
||||
/// </summary>
|
||||
public sealed record RationaleMetadata(
|
||||
/// <summary>
|
||||
/// When the consensus was computed.
|
||||
/// </summary>
|
||||
DateTimeOffset ComputedAt,
|
||||
|
||||
/// <summary>
|
||||
/// Algorithm version used.
|
||||
/// </summary>
|
||||
string AlgorithmVersion,
|
||||
|
||||
/// <summary>
|
||||
/// Hash of all inputs for reproducibility.
|
||||
/// </summary>
|
||||
string InputHash,
|
||||
|
||||
/// <summary>
|
||||
/// Hash of the output for verification.
|
||||
/// </summary>
|
||||
string OutputHash,
|
||||
|
||||
/// <summary>
|
||||
/// Tenant context if applicable.
|
||||
/// </summary>
|
||||
string? TenantId,
|
||||
|
||||
/// <summary>
|
||||
/// Policy ID if a specific policy was applied.
|
||||
/// </summary>
|
||||
string? PolicyId,
|
||||
|
||||
/// <summary>
|
||||
/// Correlation ID for tracing.
|
||||
/// </summary>
|
||||
string? CorrelationId);
|
||||
|
||||
/// <summary>
|
||||
/// Request for generating a consensus rationale.
|
||||
/// </summary>
|
||||
public sealed record GenerateRationaleRequest(
|
||||
/// <summary>
|
||||
/// Vulnerability ID.
|
||||
/// </summary>
|
||||
string VulnerabilityId,
|
||||
|
||||
/// <summary>
|
||||
/// Product key.
|
||||
/// </summary>
|
||||
string ProductKey,
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID if applicable.
|
||||
/// </summary>
|
||||
string? TenantId,
|
||||
|
||||
/// <summary>
|
||||
/// Include full contribution details.
|
||||
/// </summary>
|
||||
bool IncludeContributions,
|
||||
|
||||
/// <summary>
|
||||
/// Include alternative outcomes analysis.
|
||||
/// </summary>
|
||||
bool IncludeAlternatives,
|
||||
|
||||
/// <summary>
|
||||
/// Include weight adjustment breakdown.
|
||||
/// </summary>
|
||||
bool IncludeAdjustments,
|
||||
|
||||
/// <summary>
|
||||
/// Verbosity level: "minimal", "standard", "detailed".
|
||||
/// </summary>
|
||||
string Verbosity,
|
||||
|
||||
/// <summary>
|
||||
/// Format hint for explanations: "human", "ai", "structured".
|
||||
/// </summary>
|
||||
string ExplanationFormat);
|
||||
|
||||
/// <summary>
|
||||
/// Response containing the consensus rationale.
|
||||
/// </summary>
|
||||
public sealed record GenerateRationaleResponse(
|
||||
/// <summary>
|
||||
/// The generated rationale.
|
||||
/// </summary>
|
||||
DetailedConsensusRationale Rationale,
|
||||
|
||||
/// <summary>
|
||||
/// Generation statistics.
|
||||
/// </summary>
|
||||
RationaleGenerationStats Stats);
|
||||
|
||||
/// <summary>
|
||||
/// Statistics about rationale generation.
|
||||
/// </summary>
|
||||
public sealed record RationaleGenerationStats(
|
||||
/// <summary>
|
||||
/// Number of statements analyzed.
|
||||
/// </summary>
|
||||
int StatementsAnalyzed,
|
||||
|
||||
/// <summary>
|
||||
/// Number of issuers involved.
|
||||
/// </summary>
|
||||
int IssuersInvolved,
|
||||
|
||||
/// <summary>
|
||||
/// Number of conflicts detected.
|
||||
/// </summary>
|
||||
int ConflictsDetected,
|
||||
|
||||
/// <summary>
|
||||
/// Number of decision factors identified.
|
||||
/// </summary>
|
||||
int FactorsIdentified,
|
||||
|
||||
/// <summary>
|
||||
/// Time taken to generate rationale in milliseconds.
|
||||
/// </summary>
|
||||
double GenerationTimeMs);
|
||||
|
||||
/// <summary>
|
||||
/// Batch rationale request.
|
||||
/// </summary>
|
||||
public sealed record BatchRationaleRequest(
|
||||
/// <summary>
|
||||
/// Individual rationale requests.
|
||||
/// </summary>
|
||||
IReadOnlyList<GenerateRationaleRequest> Requests,
|
||||
|
||||
/// <summary>
|
||||
/// Maximum parallel computations.
|
||||
/// </summary>
|
||||
int? MaxParallel);
|
||||
|
||||
/// <summary>
|
||||
/// Batch rationale response.
|
||||
/// </summary>
|
||||
public sealed record BatchRationaleResponse(
|
||||
/// <summary>
|
||||
/// Generated rationales.
|
||||
/// </summary>
|
||||
IReadOnlyList<GenerateRationaleResponse> Responses,
|
||||
|
||||
/// <summary>
|
||||
/// Failed requests.
|
||||
/// </summary>
|
||||
IReadOnlyList<RationaleError> Errors,
|
||||
|
||||
/// <summary>
|
||||
/// Total time for batch processing.
|
||||
/// </summary>
|
||||
double TotalTimeMs);
|
||||
|
||||
/// <summary>
|
||||
/// Error from rationale generation.
|
||||
/// </summary>
|
||||
public sealed record RationaleError(
|
||||
/// <summary>
|
||||
/// Vulnerability ID from the request.
|
||||
/// </summary>
|
||||
string VulnerabilityId,
|
||||
|
||||
/// <summary>
|
||||
/// Product key from the request.
|
||||
/// </summary>
|
||||
string ProductKey,
|
||||
|
||||
/// <summary>
|
||||
/// Error code.
|
||||
/// </summary>
|
||||
string Code,
|
||||
|
||||
/// <summary>
|
||||
/// Error message.
|
||||
/// </summary>
|
||||
string Message);
|
||||
560
src/VexLens/StellaOps.VexLens/Api/IConsensusRationaleService.cs
Normal file
560
src/VexLens/StellaOps.VexLens/Api/IConsensusRationaleService.cs
Normal file
@@ -0,0 +1,560 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.Storage;
|
||||
using StellaOps.VexLens.Trust;
|
||||
|
||||
namespace StellaOps.VexLens.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Service for generating detailed consensus rationales for AI/ML consumption.
|
||||
/// </summary>
|
||||
public interface IConsensusRationaleService
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a detailed rationale for a consensus computation.
|
||||
/// </summary>
|
||||
Task<GenerateRationaleResponse> GenerateRationaleAsync(
|
||||
GenerateRationaleRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Generates rationales for multiple consensus computations in batch.
|
||||
/// </summary>
|
||||
Task<BatchRationaleResponse> GenerateBatchRationaleAsync(
|
||||
BatchRationaleRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a rationale from an existing consensus result.
|
||||
/// </summary>
|
||||
Task<DetailedConsensusRationale> GenerateFromResultAsync(
|
||||
VexConsensusResult result,
|
||||
string explanationFormat = "human",
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IConsensusRationaleService"/>.
|
||||
/// </summary>
|
||||
public sealed class ConsensusRationaleService : IConsensusRationaleService
|
||||
{
|
||||
private readonly IConsensusProjectionStore _projectionStore;
|
||||
private readonly IVexConsensusEngine _consensusEngine;
|
||||
private readonly ITrustWeightEngine _trustWeightEngine;
|
||||
|
||||
private const string AlgorithmVersion = "1.0.0";
|
||||
|
||||
public ConsensusRationaleService(
|
||||
IConsensusProjectionStore projectionStore,
|
||||
IVexConsensusEngine consensusEngine,
|
||||
ITrustWeightEngine trustWeightEngine)
|
||||
{
|
||||
_projectionStore = projectionStore;
|
||||
_consensusEngine = consensusEngine;
|
||||
_trustWeightEngine = trustWeightEngine;
|
||||
}
|
||||
|
||||
public async Task<GenerateRationaleResponse> GenerateRationaleAsync(
|
||||
GenerateRationaleRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startTime = DateTime.UtcNow;
|
||||
|
||||
// Get the latest projection
|
||||
var projection = await _projectionStore.GetLatestAsync(
|
||||
request.VulnerabilityId,
|
||||
request.ProductKey,
|
||||
request.TenantId,
|
||||
cancellationToken);
|
||||
|
||||
if (projection == null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"No consensus projection found for {request.VulnerabilityId}/{request.ProductKey}");
|
||||
}
|
||||
|
||||
// Build rationale from projection
|
||||
var rationale = BuildRationale(projection, request);
|
||||
|
||||
var elapsedMs = (DateTime.UtcNow - startTime).TotalMilliseconds;
|
||||
|
||||
return new GenerateRationaleResponse(
|
||||
Rationale: rationale,
|
||||
Stats: new RationaleGenerationStats(
|
||||
StatementsAnalyzed: projection.StatementCount,
|
||||
IssuersInvolved: 1, // Simplified
|
||||
ConflictsDetected: projection.ConflictCount,
|
||||
FactorsIdentified: rationale.DecisionFactors.Count,
|
||||
GenerationTimeMs: elapsedMs));
|
||||
}
|
||||
|
||||
public async Task<BatchRationaleResponse> GenerateBatchRationaleAsync(
|
||||
BatchRationaleRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startTime = DateTime.UtcNow;
|
||||
var responses = new List<GenerateRationaleResponse>();
|
||||
var errors = new List<RationaleError>();
|
||||
|
||||
var maxParallel = request.MaxParallel ?? 4;
|
||||
var semaphore = new SemaphoreSlim(maxParallel);
|
||||
|
||||
var tasks = request.Requests.Select(async req =>
|
||||
{
|
||||
await semaphore.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var response = await GenerateRationaleAsync(req, cancellationToken);
|
||||
lock (responses) responses.Add(response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lock (errors)
|
||||
{
|
||||
errors.Add(new RationaleError(
|
||||
VulnerabilityId: req.VulnerabilityId,
|
||||
ProductKey: req.ProductKey,
|
||||
Code: "GENERATION_FAILED",
|
||||
Message: ex.Message));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
semaphore.Release();
|
||||
}
|
||||
});
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
var totalMs = (DateTime.UtcNow - startTime).TotalMilliseconds;
|
||||
|
||||
return new BatchRationaleResponse(
|
||||
Responses: responses,
|
||||
Errors: errors,
|
||||
TotalTimeMs: totalMs);
|
||||
}
|
||||
|
||||
public Task<DetailedConsensusRationale> GenerateFromResultAsync(
|
||||
VexConsensusResult result,
|
||||
string explanationFormat = "human",
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var contributions = result.Contributions.Select(c => new RationaleContribution(
|
||||
StatementId: c.StatementId,
|
||||
IssuerId: c.IssuerId ?? "unknown",
|
||||
IssuerName: null, // Not available in StatementContribution
|
||||
IssuerCategory: "Unknown",
|
||||
TrustTier: "Unknown",
|
||||
Status: c.Status,
|
||||
Justification: c.Justification,
|
||||
RawWeight: c.Weight, // Use Weight as RawWeight since no separate field
|
||||
FinalWeight: c.Weight,
|
||||
Adjustments: [],
|
||||
IsWinner: c.IsWinner,
|
||||
Influence: CalculateInfluence(c.Contribution, result.Contributions),
|
||||
IssuedAt: null)).ToList();
|
||||
|
||||
var conflicts = (result.Conflicts ?? []).Select((cf, i) => new RationaleConflict(
|
||||
ConflictId: $"conflict-{i + 1}",
|
||||
ConflictType: "StatusDisagreement",
|
||||
Severity: cf.Severity.ToString(),
|
||||
StatementA: cf.Statement1Id,
|
||||
StatementB: cf.Statement2Id,
|
||||
StatusA: cf.Status1,
|
||||
StatusB: cf.Status2,
|
||||
WeightDelta: 0.0, // Not tracked in ConsensusConflict
|
||||
Resolution: cf.Resolution,
|
||||
Description: BuildConflictDescription(cf))).ToList();
|
||||
|
||||
var factors = BuildDecisionFactors(result);
|
||||
var alternatives = BuildAlternatives(result);
|
||||
|
||||
var (summary, explanation) = GenerateExplanation(result, explanationFormat);
|
||||
|
||||
var inputHash = ComputeInputHash(result);
|
||||
var outputHash = ComputeOutputHash(result, contributions, conflicts);
|
||||
|
||||
var rationale = new DetailedConsensusRationale(
|
||||
RationaleId: $"rat-{Guid.NewGuid():N}",
|
||||
VulnerabilityId: result.VulnerabilityId,
|
||||
ProductKey: result.ProductKey,
|
||||
ConsensusStatus: result.ConsensusStatus,
|
||||
ConsensusJustification: result.ConsensusJustification,
|
||||
ConfidenceScore: result.ConfidenceScore,
|
||||
Outcome: result.Outcome,
|
||||
Mode: ConsensusMode.WeightedVote, // Default; not in result
|
||||
Summary: summary,
|
||||
Explanation: explanation,
|
||||
Contributions: contributions,
|
||||
Conflicts: conflicts,
|
||||
DecisionFactors: factors,
|
||||
Alternatives: alternatives,
|
||||
Metadata: new RationaleMetadata(
|
||||
ComputedAt: result.ComputedAt,
|
||||
AlgorithmVersion: AlgorithmVersion,
|
||||
InputHash: inputHash,
|
||||
OutputHash: outputHash,
|
||||
TenantId: null,
|
||||
PolicyId: null,
|
||||
CorrelationId: null));
|
||||
|
||||
return Task.FromResult(rationale);
|
||||
}
|
||||
|
||||
private DetailedConsensusRationale BuildRationale(
|
||||
ConsensusProjection projection,
|
||||
GenerateRationaleRequest request)
|
||||
{
|
||||
// Build simplified rationale from projection data
|
||||
var contributions = new List<RationaleContribution>();
|
||||
var conflicts = new List<RationaleConflict>();
|
||||
|
||||
// Since we only have projection data, create a summary contribution
|
||||
if (projection.StatementCount > 0)
|
||||
{
|
||||
contributions.Add(new RationaleContribution(
|
||||
StatementId: "aggregated",
|
||||
IssuerId: "multiple",
|
||||
IssuerName: null,
|
||||
IssuerCategory: "Mixed",
|
||||
TrustTier: "Mixed",
|
||||
Status: projection.Status,
|
||||
Justification: projection.Justification,
|
||||
RawWeight: projection.ConfidenceScore,
|
||||
FinalWeight: projection.ConfidenceScore,
|
||||
Adjustments: [],
|
||||
IsWinner: true,
|
||||
Influence: 1.0,
|
||||
IssuedAt: projection.ComputedAt));
|
||||
}
|
||||
|
||||
// Create conflict entries based on count
|
||||
for (var i = 0; i < projection.ConflictCount; i++)
|
||||
{
|
||||
conflicts.Add(new RationaleConflict(
|
||||
ConflictId: $"conflict-{i + 1}",
|
||||
ConflictType: "StatusDisagreement",
|
||||
Severity: "Medium",
|
||||
StatementA: $"statement-{i * 2 + 1}",
|
||||
StatementB: $"statement-{i * 2 + 2}",
|
||||
StatusA: projection.Status,
|
||||
StatusB: VexStatus.UnderInvestigation,
|
||||
WeightDelta: 0.0,
|
||||
Resolution: "weight_based",
|
||||
Description: $"Conflict {i + 1} resolved by weight comparison"));
|
||||
}
|
||||
|
||||
var factors = new List<RationaleFactor>
|
||||
{
|
||||
new("Statement Count", "coverage",
|
||||
Math.Min(projection.StatementCount / 10.0, 1.0),
|
||||
$"{projection.StatementCount} statement(s) contributed to this consensus",
|
||||
null),
|
||||
new("Conflict Rate", "quality",
|
||||
-Math.Min(projection.ConflictCount / (double)Math.Max(projection.StatementCount, 1), 1.0),
|
||||
projection.ConflictCount > 0
|
||||
? $"{projection.ConflictCount} conflict(s) detected and resolved"
|
||||
: "No conflicts detected",
|
||||
null),
|
||||
new("Confidence Score", "trust",
|
||||
projection.ConfidenceScore,
|
||||
$"Overall confidence: {projection.ConfidenceScore:P0}",
|
||||
null)
|
||||
};
|
||||
|
||||
var alternatives = new List<AlternativeOutcome>();
|
||||
{
|
||||
var otherStatuses = Enum.GetValues<VexStatus>()
|
||||
.Where(s => s != projection.Status)
|
||||
.Take(2);
|
||||
|
||||
foreach (var status in otherStatuses)
|
||||
{
|
||||
alternatives.Add(new AlternativeOutcome(
|
||||
Status: status,
|
||||
Confidence: projection.ConfidenceScore * 0.3,
|
||||
TotalWeight: 0.0,
|
||||
SupportingStatements: 0,
|
||||
RejectionReason: $"Insufficient support compared to {projection.Status}"));
|
||||
}
|
||||
}
|
||||
|
||||
var (summary, explanation) = GenerateExplanationFromProjection(projection, request.ExplanationFormat);
|
||||
|
||||
var inputHash = ComputeProjectionInputHash(projection);
|
||||
var outputHash = ComputeProjectionOutputHash(projection);
|
||||
|
||||
return new DetailedConsensusRationale(
|
||||
RationaleId: $"rat-{projection.ProjectionId}",
|
||||
VulnerabilityId: projection.VulnerabilityId,
|
||||
ProductKey: projection.ProductKey,
|
||||
ConsensusStatus: projection.Status,
|
||||
ConsensusJustification: projection.Justification,
|
||||
ConfidenceScore: projection.ConfidenceScore,
|
||||
Outcome: projection.Outcome,
|
||||
Mode: ConsensusMode.WeightedVote, // Default assumption
|
||||
Summary: summary,
|
||||
Explanation: explanation,
|
||||
Contributions: contributions,
|
||||
Conflicts: conflicts,
|
||||
DecisionFactors: factors,
|
||||
Alternatives: alternatives,
|
||||
Metadata: new RationaleMetadata(
|
||||
ComputedAt: projection.ComputedAt,
|
||||
AlgorithmVersion: AlgorithmVersion,
|
||||
InputHash: inputHash,
|
||||
OutputHash: outputHash,
|
||||
TenantId: request.TenantId,
|
||||
PolicyId: null,
|
||||
CorrelationId: null));
|
||||
}
|
||||
|
||||
private static double CalculateInfluence(
|
||||
double contribution,
|
||||
IReadOnlyList<StatementContribution> allContributions)
|
||||
{
|
||||
var totalContribution = allContributions.Sum(c => c.Contribution);
|
||||
return totalContribution > 0 ? contribution / totalContribution : 0;
|
||||
}
|
||||
|
||||
private static string BuildConflictDescription(ConsensusConflict conflict)
|
||||
{
|
||||
return $"Statement '{conflict.Statement1Id}' asserts {conflict.Status1} " +
|
||||
$"while statement '{conflict.Statement2Id}' asserts {conflict.Status2}. " +
|
||||
$"Severity: {conflict.Severity}. " +
|
||||
$"Resolution: {conflict.Resolution}.";
|
||||
}
|
||||
|
||||
private static IReadOnlyList<RationaleFactor> BuildDecisionFactors(VexConsensusResult result)
|
||||
{
|
||||
var factors = new List<RationaleFactor>();
|
||||
|
||||
// Coverage factor
|
||||
factors.Add(new RationaleFactor(
|
||||
Name: "Statement Coverage",
|
||||
Category: "coverage",
|
||||
Impact: Math.Min(result.Contributions.Count / 5.0, 1.0),
|
||||
Description: $"{result.Contributions.Count} statement(s) analyzed from various sources",
|
||||
Evidence: result.Contributions.Select(c => c.StatementId).ToList()));
|
||||
|
||||
// Conflict factor
|
||||
var conflictCount = result.Conflicts?.Count ?? 0;
|
||||
if (conflictCount > 0)
|
||||
{
|
||||
factors.Add(new RationaleFactor(
|
||||
Name: "Conflict Resolution",
|
||||
Category: "quality",
|
||||
Impact: -0.1 * Math.Min(conflictCount, 5),
|
||||
Description: $"{conflictCount} conflict(s) required resolution",
|
||||
Evidence: null));
|
||||
}
|
||||
|
||||
// Winner dominance
|
||||
var winners = result.Contributions.Where(c => c.IsWinner).ToList();
|
||||
if (winners.Count > 0)
|
||||
{
|
||||
var winnerContribution = winners.Sum(w => w.Contribution);
|
||||
var totalContribution = result.Contributions.Sum(c => c.Contribution);
|
||||
var dominance = totalContribution > 0 ? winnerContribution / totalContribution : 0;
|
||||
|
||||
factors.Add(new RationaleFactor(
|
||||
Name: "Winner Dominance",
|
||||
Category: "certainty",
|
||||
Impact: dominance,
|
||||
Description: $"Winning position represents {dominance:P0} of total contribution",
|
||||
Evidence: null));
|
||||
}
|
||||
|
||||
// Justification factor
|
||||
if (result.ConsensusJustification.HasValue)
|
||||
{
|
||||
factors.Add(new RationaleFactor(
|
||||
Name: "Justification Provided",
|
||||
Category: "quality",
|
||||
Impact: 0.2,
|
||||
Description: $"Consensus includes justification: {result.ConsensusJustification}",
|
||||
Evidence: null));
|
||||
}
|
||||
|
||||
return factors;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AlternativeOutcome> BuildAlternatives(VexConsensusResult result)
|
||||
{
|
||||
var alternatives = new List<AlternativeOutcome>();
|
||||
|
||||
// Group contributions by status
|
||||
var statusGroups = result.Contributions
|
||||
.GroupBy(c => c.Status)
|
||||
.Where(g => g.Key != result.ConsensusStatus)
|
||||
.OrderByDescending(g => g.Sum(c => c.Contribution));
|
||||
|
||||
foreach (var group in statusGroups.Take(3))
|
||||
{
|
||||
var totalContribution = group.Sum(c => c.Contribution);
|
||||
var winningContribution = result.Contributions
|
||||
.Where(c => c.Status == result.ConsensusStatus)
|
||||
.Sum(c => c.Contribution);
|
||||
|
||||
alternatives.Add(new AlternativeOutcome(
|
||||
Status: group.Key,
|
||||
Confidence: totalContribution / Math.Max(winningContribution + totalContribution, 1),
|
||||
TotalWeight: group.Sum(c => c.Weight),
|
||||
SupportingStatements: group.Count(),
|
||||
RejectionReason: winningContribution > totalContribution
|
||||
? $"Outweighed by {result.ConsensusStatus} statements"
|
||||
: $"Fewer supporting statements than {result.ConsensusStatus}"));
|
||||
}
|
||||
|
||||
return alternatives;
|
||||
}
|
||||
|
||||
private static (string Summary, string Explanation) GenerateExplanation(
|
||||
VexConsensusResult result,
|
||||
string format)
|
||||
{
|
||||
var summary = $"Consensus: {result.ConsensusStatus} with {result.ConfidenceScore:P0} confidence";
|
||||
|
||||
string explanation;
|
||||
if (format == "ai")
|
||||
{
|
||||
explanation = GenerateAiExplanation(result);
|
||||
}
|
||||
else if (format == "structured")
|
||||
{
|
||||
explanation = GenerateStructuredExplanation(result);
|
||||
}
|
||||
else
|
||||
{
|
||||
explanation = GenerateHumanExplanation(result);
|
||||
}
|
||||
|
||||
return (summary, explanation);
|
||||
}
|
||||
|
||||
private static (string Summary, string Explanation) GenerateExplanationFromProjection(
|
||||
ConsensusProjection projection,
|
||||
string format)
|
||||
{
|
||||
var summary = $"Consensus: {projection.Status} with {projection.ConfidenceScore:P0} confidence";
|
||||
|
||||
var explanation = format switch
|
||||
{
|
||||
"ai" => $"STATUS={projection.Status}|JUSTIFICATION={projection.Justification?.ToString() ?? "NONE"}|" +
|
||||
$"CONFIDENCE={projection.ConfidenceScore:F4}|OUTCOME={projection.Outcome}|" +
|
||||
$"STATEMENTS={projection.StatementCount}|CONFLICTS={projection.ConflictCount}",
|
||||
"structured" => $"{{\"status\":\"{projection.Status}\",\"justification\":\"{projection.Justification?.ToString() ?? "null"}\"," +
|
||||
$"\"confidence\":{projection.ConfidenceScore:F4},\"outcome\":\"{projection.Outcome}\"," +
|
||||
$"\"statements\":{projection.StatementCount},\"conflicts\":{projection.ConflictCount}}}",
|
||||
_ => $"The vulnerability {projection.VulnerabilityId} affecting product {projection.ProductKey} " +
|
||||
$"has been determined to be {projection.Status} based on analysis of {projection.StatementCount} VEX statement(s). " +
|
||||
(projection.ConflictCount > 0
|
||||
? $"{projection.ConflictCount} conflict(s) were detected and resolved. "
|
||||
: "") +
|
||||
(projection.Justification.HasValue
|
||||
? $"Justification: {projection.Justification}. "
|
||||
: "") +
|
||||
$"Confidence level: {projection.ConfidenceScore:P0}."
|
||||
};
|
||||
|
||||
return (summary, explanation);
|
||||
}
|
||||
|
||||
private static string GenerateHumanExplanation(VexConsensusResult result)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.Append($"The vulnerability {result.VulnerabilityId} affecting product {result.ProductKey} ");
|
||||
sb.Append($"has been determined to be {result.ConsensusStatus}. ");
|
||||
|
||||
if (result.Contributions.Count > 0)
|
||||
{
|
||||
sb.Append($"This determination is based on {result.Contributions.Count} VEX statement(s) ");
|
||||
sb.Append($"from {result.Contributions.Select(c => c.IssuerId).Distinct().Count()} issuer(s). ");
|
||||
}
|
||||
|
||||
if (result.ConsensusJustification.HasValue)
|
||||
{
|
||||
sb.Append($"Justification: {result.ConsensusJustification}. ");
|
||||
}
|
||||
|
||||
var conflictCount = result.Conflicts?.Count ?? 0;
|
||||
if (conflictCount > 0)
|
||||
{
|
||||
sb.Append($"{conflictCount} conflicting statement(s) were resolved. ");
|
||||
}
|
||||
|
||||
sb.Append($"Confidence level: {result.ConfidenceScore:P0}.");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string GenerateAiExplanation(VexConsensusResult result)
|
||||
{
|
||||
// Structured format optimized for AI/ML consumption
|
||||
var parts = new List<string>
|
||||
{
|
||||
$"STATUS={result.ConsensusStatus}",
|
||||
$"JUSTIFICATION={result.ConsensusJustification?.ToString() ?? "NONE"}",
|
||||
$"CONFIDENCE={result.ConfidenceScore:F4}",
|
||||
$"OUTCOME={result.Outcome}",
|
||||
$"STATEMENTS={result.Contributions.Count}",
|
||||
$"CONFLICTS={result.Conflicts?.Count ?? 0}"
|
||||
};
|
||||
|
||||
foreach (var contrib in result.Contributions.Take(5))
|
||||
{
|
||||
parts.Add($"CONTRIB[{contrib.StatementId}]={{status={contrib.Status},weight={contrib.Weight:F4},winner={contrib.IsWinner}}}");
|
||||
}
|
||||
|
||||
return string.Join("|", parts);
|
||||
}
|
||||
|
||||
private static string GenerateStructuredExplanation(VexConsensusResult result)
|
||||
{
|
||||
// JSON-like structured format
|
||||
return System.Text.Json.JsonSerializer.Serialize(new
|
||||
{
|
||||
status = result.ConsensusStatus.ToString(),
|
||||
justification = result.ConsensusJustification?.ToString(),
|
||||
confidence = result.ConfidenceScore,
|
||||
outcome = result.Outcome.ToString(),
|
||||
statements = result.Contributions.Count,
|
||||
conflicts = result.Conflicts?.Count ?? 0,
|
||||
topContributors = result.Contributions
|
||||
.OrderByDescending(c => c.Weight)
|
||||
.Take(3)
|
||||
.Select(c => new { c.StatementId, c.Status, c.Weight })
|
||||
});
|
||||
}
|
||||
|
||||
private static string ComputeInputHash(VexConsensusResult result)
|
||||
{
|
||||
var data = $"{result.VulnerabilityId}|{result.ProductKey}|" +
|
||||
string.Join(",", result.Contributions.Select(c => c.StatementId).OrderBy(x => x));
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(data))).ToLowerInvariant()[..16];
|
||||
}
|
||||
|
||||
private static string ComputeOutputHash(
|
||||
VexConsensusResult result,
|
||||
IReadOnlyList<RationaleContribution> contributions,
|
||||
IReadOnlyList<RationaleConflict> conflicts)
|
||||
{
|
||||
var data = $"{result.ConsensusStatus}|{result.ConfidenceScore:F4}|{contributions.Count}|{conflicts.Count}";
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(data))).ToLowerInvariant()[..16];
|
||||
}
|
||||
|
||||
private static string ComputeProjectionInputHash(ConsensusProjection projection)
|
||||
{
|
||||
var data = $"{projection.VulnerabilityId}|{projection.ProductKey}|{projection.StatementCount}";
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(data))).ToLowerInvariant()[..16];
|
||||
}
|
||||
|
||||
private static string ComputeProjectionOutputHash(ConsensusProjection projection)
|
||||
{
|
||||
var data = $"{projection.Status}|{projection.ConfidenceScore:F4}|{projection.Outcome}";
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(data))).ToLowerInvariant()[..16];
|
||||
}
|
||||
}
|
||||
619
src/VexLens/StellaOps.VexLens/Api/IVexLensApiService.cs
Normal file
619
src/VexLens/StellaOps.VexLens/Api/IVexLensApiService.cs
Normal file
@@ -0,0 +1,619 @@
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.Storage;
|
||||
using StellaOps.VexLens.Trust;
|
||||
using StellaOps.VexLens.Verification;
|
||||
|
||||
namespace StellaOps.VexLens.Api;
|
||||
|
||||
/// <summary>
|
||||
/// API service for VexLens operations.
|
||||
/// Encapsulates the workflow of normalization, trust weighting, and consensus.
|
||||
/// </summary>
|
||||
public interface IVexLensApiService
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes consensus for a vulnerability-product pair.
|
||||
/// </summary>
|
||||
Task<ComputeConsensusResponse> ComputeConsensusAsync(
|
||||
ComputeConsensusRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Computes consensus for multiple pairs in batch.
|
||||
/// </summary>
|
||||
Task<ComputeConsensusBatchResponse> ComputeConsensusBatchAsync(
|
||||
ComputeConsensusBatchRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a consensus projection by ID.
|
||||
/// </summary>
|
||||
Task<ProjectionDetailResponse?> GetProjectionAsync(
|
||||
string projectionId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest projection for a vulnerability-product pair.
|
||||
/// </summary>
|
||||
Task<ProjectionDetailResponse?> GetLatestProjectionAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
string? tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries consensus projections.
|
||||
/// </summary>
|
||||
Task<QueryProjectionsResponse> QueryProjectionsAsync(
|
||||
QueryProjectionsRequest request,
|
||||
string? tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets projection history for a vulnerability-product pair.
|
||||
/// </summary>
|
||||
Task<ProjectionHistoryResponse> GetProjectionHistoryAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
string? tenantId,
|
||||
int? limit,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets consensus statistics.
|
||||
/// </summary>
|
||||
Task<ConsensusStatisticsResponse> GetStatisticsAsync(
|
||||
string? tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists registered issuers.
|
||||
/// </summary>
|
||||
Task<IssuerListResponse> ListIssuersAsync(
|
||||
string? category,
|
||||
string? minimumTrustTier,
|
||||
string? status,
|
||||
string? searchTerm,
|
||||
int? limit,
|
||||
int? offset,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets issuer details.
|
||||
/// </summary>
|
||||
Task<IssuerDetailResponse?> GetIssuerAsync(
|
||||
string issuerId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Registers a new issuer.
|
||||
/// </summary>
|
||||
Task<IssuerDetailResponse> RegisterIssuerAsync(
|
||||
RegisterIssuerRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Revokes an issuer.
|
||||
/// </summary>
|
||||
Task<bool> RevokeIssuerAsync(
|
||||
string issuerId,
|
||||
RevokeRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a key to an issuer.
|
||||
/// </summary>
|
||||
Task<IssuerDetailResponse> AddIssuerKeyAsync(
|
||||
string issuerId,
|
||||
RegisterKeyRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Revokes an issuer key.
|
||||
/// </summary>
|
||||
Task<bool> RevokeIssuerKeyAsync(
|
||||
string issuerId,
|
||||
string fingerprint,
|
||||
RevokeRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IVexLensApiService"/>.
|
||||
/// </summary>
|
||||
public sealed class VexLensApiService : IVexLensApiService
|
||||
{
|
||||
private readonly IVexConsensusEngine _consensusEngine;
|
||||
private readonly ITrustWeightEngine _trustWeightEngine;
|
||||
private readonly IConsensusProjectionStore _projectionStore;
|
||||
private readonly IIssuerDirectory _issuerDirectory;
|
||||
private readonly IVexStatementProvider _statementProvider;
|
||||
|
||||
public VexLensApiService(
|
||||
IVexConsensusEngine consensusEngine,
|
||||
ITrustWeightEngine trustWeightEngine,
|
||||
IConsensusProjectionStore projectionStore,
|
||||
IIssuerDirectory issuerDirectory,
|
||||
IVexStatementProvider statementProvider)
|
||||
{
|
||||
_consensusEngine = consensusEngine;
|
||||
_trustWeightEngine = trustWeightEngine;
|
||||
_projectionStore = projectionStore;
|
||||
_issuerDirectory = issuerDirectory;
|
||||
_statementProvider = statementProvider;
|
||||
}
|
||||
|
||||
public async Task<ComputeConsensusResponse> ComputeConsensusAsync(
|
||||
ComputeConsensusRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Get statements for the vulnerability-product pair
|
||||
var statements = await _statementProvider.GetStatementsAsync(
|
||||
request.VulnerabilityId,
|
||||
request.ProductKey,
|
||||
request.TenantId,
|
||||
cancellationToken);
|
||||
|
||||
// Compute trust weights
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var weightedStatements = new List<WeightedStatement>();
|
||||
|
||||
foreach (var stmt in statements)
|
||||
{
|
||||
var weightRequest = new TrustWeightRequest(
|
||||
Statement: stmt.Statement,
|
||||
Issuer: stmt.Issuer,
|
||||
SignatureVerification: stmt.SignatureVerification,
|
||||
DocumentIssuedAt: stmt.DocumentIssuedAt,
|
||||
Context: new TrustWeightContext(
|
||||
TenantId: request.TenantId,
|
||||
EvaluationTime: now,
|
||||
CustomFactors: null));
|
||||
|
||||
var weight = await _trustWeightEngine.ComputeWeightAsync(weightRequest, cancellationToken);
|
||||
|
||||
weightedStatements.Add(new WeightedStatement(
|
||||
Statement: stmt.Statement,
|
||||
Weight: weight,
|
||||
Issuer: stmt.Issuer,
|
||||
SourceDocumentId: stmt.SourceDocumentId));
|
||||
}
|
||||
|
||||
// Compute consensus
|
||||
var policy = new ConsensusPolicy(
|
||||
Mode: request.Mode ?? ConsensusMode.WeightedVote,
|
||||
MinimumWeightThreshold: request.MinimumWeightThreshold ?? 0.1,
|
||||
ConflictThreshold: 0.3,
|
||||
RequireJustificationForNotAffected: false,
|
||||
PreferredIssuers: null);
|
||||
|
||||
var consensusRequest = new VexConsensusRequest(
|
||||
VulnerabilityId: request.VulnerabilityId,
|
||||
ProductKey: request.ProductKey,
|
||||
Statements: weightedStatements,
|
||||
Context: new ConsensusContext(
|
||||
TenantId: request.TenantId,
|
||||
EvaluationTime: now,
|
||||
Policy: policy));
|
||||
|
||||
var result = await _consensusEngine.ComputeConsensusAsync(consensusRequest, cancellationToken);
|
||||
|
||||
// Store result if requested
|
||||
string? projectionId = null;
|
||||
if (request.StoreResult == true)
|
||||
{
|
||||
var projection = await _projectionStore.StoreAsync(
|
||||
result,
|
||||
new StoreProjectionOptions(
|
||||
TenantId: request.TenantId,
|
||||
TrackHistory: true,
|
||||
EmitEvent: request.EmitEvent ?? true),
|
||||
cancellationToken);
|
||||
|
||||
projectionId = projection.ProjectionId;
|
||||
}
|
||||
|
||||
return MapToResponse(result, projectionId);
|
||||
}
|
||||
|
||||
public async Task<ComputeConsensusBatchResponse> ComputeConsensusBatchAsync(
|
||||
ComputeConsensusBatchRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = new List<ComputeConsensusResponse>();
|
||||
var failures = 0;
|
||||
|
||||
foreach (var target in request.Targets)
|
||||
{
|
||||
try
|
||||
{
|
||||
var singleRequest = new ComputeConsensusRequest(
|
||||
VulnerabilityId: target.VulnerabilityId,
|
||||
ProductKey: target.ProductKey,
|
||||
TenantId: request.TenantId,
|
||||
Mode: request.Mode,
|
||||
MinimumWeightThreshold: null,
|
||||
StoreResult: request.StoreResults,
|
||||
EmitEvent: request.EmitEvents);
|
||||
|
||||
var result = await ComputeConsensusAsync(singleRequest, cancellationToken);
|
||||
results.Add(result);
|
||||
}
|
||||
catch
|
||||
{
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
return new ComputeConsensusBatchResponse(
|
||||
Results: results,
|
||||
TotalCount: request.Targets.Count,
|
||||
SuccessCount: results.Count,
|
||||
FailureCount: failures,
|
||||
CompletedAt: DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
public async Task<ProjectionDetailResponse?> GetProjectionAsync(
|
||||
string projectionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var projection = await _projectionStore.GetAsync(projectionId, cancellationToken);
|
||||
return projection != null ? MapToDetailResponse(projection) : null;
|
||||
}
|
||||
|
||||
public async Task<ProjectionDetailResponse?> GetLatestProjectionAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
string? tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var projection = await _projectionStore.GetLatestAsync(
|
||||
vulnerabilityId, productKey, tenantId, cancellationToken);
|
||||
return projection != null ? MapToDetailResponse(projection) : null;
|
||||
}
|
||||
|
||||
public async Task<QueryProjectionsResponse> QueryProjectionsAsync(
|
||||
QueryProjectionsRequest request,
|
||||
string? tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = new ProjectionQuery(
|
||||
TenantId: tenantId,
|
||||
VulnerabilityId: request.VulnerabilityId,
|
||||
ProductKey: request.ProductKey,
|
||||
Status: request.Status,
|
||||
Outcome: ParseOutcome(request.Outcome),
|
||||
MinimumConfidence: request.MinimumConfidence,
|
||||
ComputedAfter: request.ComputedAfter,
|
||||
ComputedBefore: request.ComputedBefore,
|
||||
StatusChanged: request.StatusChanged,
|
||||
Limit: request.Limit ?? 50,
|
||||
Offset: request.Offset ?? 0,
|
||||
SortBy: ParseSortField(request.SortBy),
|
||||
SortDescending: request.SortDescending ?? true);
|
||||
|
||||
var result = await _projectionStore.ListAsync(query, cancellationToken);
|
||||
|
||||
return new QueryProjectionsResponse(
|
||||
Projections: result.Projections.Select(MapToSummary).ToList(),
|
||||
TotalCount: result.TotalCount,
|
||||
Offset: result.Offset,
|
||||
Limit: result.Limit);
|
||||
}
|
||||
|
||||
public async Task<ProjectionHistoryResponse> GetProjectionHistoryAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
string? tenantId,
|
||||
int? limit,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var history = await _projectionStore.GetHistoryAsync(
|
||||
vulnerabilityId, productKey, tenantId, limit, cancellationToken);
|
||||
|
||||
return new ProjectionHistoryResponse(
|
||||
VulnerabilityId: vulnerabilityId,
|
||||
ProductKey: productKey,
|
||||
History: history.Select(MapToSummary).ToList(),
|
||||
TotalCount: history.Count);
|
||||
}
|
||||
|
||||
public async Task<ConsensusStatisticsResponse> GetStatisticsAsync(
|
||||
string? tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var allQuery = new ProjectionQuery(
|
||||
TenantId: tenantId,
|
||||
VulnerabilityId: null,
|
||||
ProductKey: null,
|
||||
Status: null,
|
||||
Outcome: null,
|
||||
MinimumConfidence: null,
|
||||
ComputedAfter: null,
|
||||
ComputedBefore: null,
|
||||
StatusChanged: null,
|
||||
Limit: 10000,
|
||||
Offset: 0,
|
||||
SortBy: ProjectionSortField.ComputedAt,
|
||||
SortDescending: true);
|
||||
|
||||
var result = await _projectionStore.ListAsync(allQuery, cancellationToken);
|
||||
var projections = result.Projections;
|
||||
|
||||
var byStatus = projections
|
||||
.GroupBy(p => p.Status.ToString())
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var byOutcome = projections
|
||||
.GroupBy(p => p.Outcome.ToString())
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var avgConfidence = projections.Count > 0
|
||||
? projections.Average(p => p.ConfidenceScore)
|
||||
: 0;
|
||||
|
||||
var withConflicts = projections.Count(p => p.ConflictCount > 0);
|
||||
|
||||
var last24h = DateTimeOffset.UtcNow.AddDays(-1);
|
||||
var changesLast24h = projections.Count(p => p.StatusChanged && p.ComputedAt >= last24h);
|
||||
|
||||
return new ConsensusStatisticsResponse(
|
||||
TotalProjections: result.TotalCount,
|
||||
ByStatus: byStatus,
|
||||
ByOutcome: byOutcome,
|
||||
AverageConfidence: avgConfidence,
|
||||
ProjectionsWithConflicts: withConflicts,
|
||||
StatusChangesLast24h: changesLast24h,
|
||||
ComputedAt: DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
public async Task<IssuerListResponse> ListIssuersAsync(
|
||||
string? category,
|
||||
string? minimumTrustTier,
|
||||
string? status,
|
||||
string? searchTerm,
|
||||
int? limit,
|
||||
int? offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var options = new IssuerListOptions(
|
||||
Category: ParseCategory(category),
|
||||
MinimumTrustTier: ParseTrustTier(minimumTrustTier),
|
||||
Status: ParseIssuerStatus(status),
|
||||
SearchTerm: searchTerm,
|
||||
Limit: limit,
|
||||
Offset: offset);
|
||||
|
||||
var issuers = await _issuerDirectory.ListIssuersAsync(options, cancellationToken);
|
||||
|
||||
return new IssuerListResponse(
|
||||
Issuers: issuers.Select(MapToIssuerSummary).ToList(),
|
||||
TotalCount: issuers.Count);
|
||||
}
|
||||
|
||||
public async Task<IssuerDetailResponse?> GetIssuerAsync(
|
||||
string issuerId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var issuer = await _issuerDirectory.GetIssuerAsync(issuerId, cancellationToken);
|
||||
return issuer != null ? MapToIssuerDetailResponse(issuer) : null;
|
||||
}
|
||||
|
||||
public async Task<IssuerDetailResponse> RegisterIssuerAsync(
|
||||
RegisterIssuerRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var registration = new IssuerRegistration(
|
||||
IssuerId: request.IssuerId,
|
||||
Name: request.Name,
|
||||
Category: ParseCategoryRequired(request.Category),
|
||||
TrustTier: ParseTrustTierRequired(request.TrustTier),
|
||||
InitialKeys: request.InitialKeys?.Select(k => new KeyFingerprintRegistration(
|
||||
Fingerprint: k.Fingerprint,
|
||||
KeyType: ParseKeyType(k.KeyType),
|
||||
Algorithm: k.Algorithm,
|
||||
ExpiresAt: k.ExpiresAt,
|
||||
PublicKey: null)).ToList(),
|
||||
Metadata: request.Metadata != null ? new IssuerMetadata(
|
||||
Description: request.Metadata.Description,
|
||||
Uri: request.Metadata.Uri,
|
||||
Email: request.Metadata.Email,
|
||||
LogoUri: null,
|
||||
Tags: request.Metadata.Tags,
|
||||
Custom: null) : null);
|
||||
|
||||
var issuer = await _issuerDirectory.RegisterIssuerAsync(registration, cancellationToken);
|
||||
return MapToIssuerDetailResponse(issuer);
|
||||
}
|
||||
|
||||
public async Task<bool> RevokeIssuerAsync(
|
||||
string issuerId,
|
||||
RevokeRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _issuerDirectory.RevokeIssuerAsync(issuerId, request.Reason, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IssuerDetailResponse> AddIssuerKeyAsync(
|
||||
string issuerId,
|
||||
RegisterKeyRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var keyReg = new KeyFingerprintRegistration(
|
||||
Fingerprint: request.Fingerprint,
|
||||
KeyType: ParseKeyType(request.KeyType),
|
||||
Algorithm: request.Algorithm,
|
||||
ExpiresAt: request.ExpiresAt,
|
||||
PublicKey: null);
|
||||
|
||||
var issuer = await _issuerDirectory.AddKeyFingerprintAsync(issuerId, keyReg, cancellationToken);
|
||||
return MapToIssuerDetailResponse(issuer);
|
||||
}
|
||||
|
||||
public async Task<bool> RevokeIssuerKeyAsync(
|
||||
string issuerId,
|
||||
string fingerprint,
|
||||
RevokeRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _issuerDirectory.RevokeKeyFingerprintAsync(
|
||||
issuerId, fingerprint, request.Reason, cancellationToken);
|
||||
}
|
||||
|
||||
private static ComputeConsensusResponse MapToResponse(VexConsensusResult result, string? projectionId)
|
||||
{
|
||||
return new ComputeConsensusResponse(
|
||||
VulnerabilityId: result.VulnerabilityId,
|
||||
ProductKey: result.ProductKey,
|
||||
Status: result.ConsensusStatus,
|
||||
Justification: result.ConsensusJustification,
|
||||
ConfidenceScore: result.ConfidenceScore,
|
||||
Outcome: result.Outcome.ToString(),
|
||||
Rationale: new ConsensusRationaleResponse(
|
||||
Summary: result.Rationale.Summary,
|
||||
Factors: result.Rationale.Factors.ToList(),
|
||||
StatusWeights: result.Rationale.StatusWeights
|
||||
.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value)),
|
||||
Contributions: result.Contributions.Select(c => new ContributionResponse(
|
||||
StatementId: c.StatementId,
|
||||
IssuerId: c.IssuerId,
|
||||
Status: c.Status,
|
||||
Justification: c.Justification,
|
||||
Weight: c.Weight,
|
||||
Contribution: c.Contribution,
|
||||
IsWinner: c.IsWinner)).ToList(),
|
||||
Conflicts: result.Conflicts?.Select(c => new ConflictResponse(
|
||||
Statement1Id: c.Statement1Id,
|
||||
Statement2Id: c.Statement2Id,
|
||||
Status1: c.Status1,
|
||||
Status2: c.Status2,
|
||||
Severity: c.Severity.ToString(),
|
||||
Resolution: c.Resolution)).ToList(),
|
||||
ProjectionId: projectionId,
|
||||
ComputedAt: result.ComputedAt);
|
||||
}
|
||||
|
||||
private static ProjectionDetailResponse MapToDetailResponse(ConsensusProjection projection)
|
||||
{
|
||||
return new ProjectionDetailResponse(
|
||||
ProjectionId: projection.ProjectionId,
|
||||
VulnerabilityId: projection.VulnerabilityId,
|
||||
ProductKey: projection.ProductKey,
|
||||
TenantId: projection.TenantId,
|
||||
Status: projection.Status,
|
||||
Justification: projection.Justification,
|
||||
ConfidenceScore: projection.ConfidenceScore,
|
||||
Outcome: projection.Outcome.ToString(),
|
||||
StatementCount: projection.StatementCount,
|
||||
ConflictCount: projection.ConflictCount,
|
||||
RationaleSummary: projection.RationaleSummary,
|
||||
ComputedAt: projection.ComputedAt,
|
||||
StoredAt: projection.StoredAt,
|
||||
PreviousProjectionId: projection.PreviousProjectionId,
|
||||
StatusChanged: projection.StatusChanged);
|
||||
}
|
||||
|
||||
private static ProjectionSummary MapToSummary(ConsensusProjection projection)
|
||||
{
|
||||
return new ProjectionSummary(
|
||||
ProjectionId: projection.ProjectionId,
|
||||
VulnerabilityId: projection.VulnerabilityId,
|
||||
ProductKey: projection.ProductKey,
|
||||
Status: projection.Status,
|
||||
Justification: projection.Justification,
|
||||
ConfidenceScore: projection.ConfidenceScore,
|
||||
Outcome: projection.Outcome.ToString(),
|
||||
StatementCount: projection.StatementCount,
|
||||
ConflictCount: projection.ConflictCount,
|
||||
ComputedAt: projection.ComputedAt,
|
||||
StatusChanged: projection.StatusChanged);
|
||||
}
|
||||
|
||||
private static IssuerSummary MapToIssuerSummary(IssuerRecord issuer)
|
||||
{
|
||||
return new IssuerSummary(
|
||||
IssuerId: issuer.IssuerId,
|
||||
Name: issuer.Name,
|
||||
Category: issuer.Category.ToString(),
|
||||
TrustTier: issuer.TrustTier.ToString(),
|
||||
Status: issuer.Status.ToString(),
|
||||
KeyCount: issuer.KeyFingerprints.Count,
|
||||
RegisteredAt: issuer.RegisteredAt);
|
||||
}
|
||||
|
||||
private static IssuerDetailResponse MapToIssuerDetailResponse(IssuerRecord issuer)
|
||||
{
|
||||
return new IssuerDetailResponse(
|
||||
IssuerId: issuer.IssuerId,
|
||||
Name: issuer.Name,
|
||||
Category: issuer.Category.ToString(),
|
||||
TrustTier: issuer.TrustTier.ToString(),
|
||||
Status: issuer.Status.ToString(),
|
||||
KeyFingerprints: issuer.KeyFingerprints.Select(k => new KeyFingerprintResponse(
|
||||
Fingerprint: k.Fingerprint,
|
||||
KeyType: k.KeyType.ToString(),
|
||||
Algorithm: k.Algorithm,
|
||||
Status: k.Status.ToString(),
|
||||
RegisteredAt: k.RegisteredAt,
|
||||
ExpiresAt: k.ExpiresAt)).ToList(),
|
||||
Metadata: issuer.Metadata != null ? new IssuerMetadataResponse(
|
||||
Description: issuer.Metadata.Description,
|
||||
Uri: issuer.Metadata.Uri,
|
||||
Email: issuer.Metadata.Email,
|
||||
Tags: issuer.Metadata.Tags?.ToList()) : null,
|
||||
RegisteredAt: issuer.RegisteredAt,
|
||||
LastUpdatedAt: issuer.LastUpdatedAt,
|
||||
RevokedAt: issuer.RevokedAt,
|
||||
RevocationReason: issuer.RevocationReason);
|
||||
}
|
||||
|
||||
private static ConsensusOutcome? ParseOutcome(string? outcome) =>
|
||||
Enum.TryParse<ConsensusOutcome>(outcome, true, out var result) ? result : null;
|
||||
|
||||
private static ProjectionSortField ParseSortField(string? sortBy) =>
|
||||
Enum.TryParse<ProjectionSortField>(sortBy, true, out var result) ? result : ProjectionSortField.ComputedAt;
|
||||
|
||||
private static IssuerCategory? ParseCategory(string? category) =>
|
||||
Enum.TryParse<IssuerCategory>(category, true, out var result) ? result : null;
|
||||
|
||||
private static TrustTier? ParseTrustTier(string? tier) =>
|
||||
Enum.TryParse<TrustTier>(tier, true, out var result) ? result : null;
|
||||
|
||||
private static IssuerStatus? ParseIssuerStatus(string? status) =>
|
||||
Enum.TryParse<IssuerStatus>(status, true, out var result) ? result : null;
|
||||
|
||||
private static IssuerCategory ParseCategoryRequired(string category) =>
|
||||
Enum.Parse<IssuerCategory>(category, true);
|
||||
|
||||
private static TrustTier ParseTrustTierRequired(string tier) =>
|
||||
Enum.Parse<TrustTier>(tier, true);
|
||||
|
||||
private static KeyType ParseKeyType(string keyType) =>
|
||||
Enum.TryParse<KeyType>(keyType, true, out var result) ? result : KeyType.Pgp;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for providing VEX statements for consensus computation.
|
||||
/// </summary>
|
||||
public interface IVexStatementProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets all VEX statements for a vulnerability-product pair.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<VexStatementWithContext>> GetStatementsAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
string? tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX statement with context for consensus computation.
|
||||
/// </summary>
|
||||
public sealed record VexStatementWithContext(
|
||||
NormalizedStatement Statement,
|
||||
VexIssuer? Issuer,
|
||||
SignatureVerificationResult? SignatureVerification,
|
||||
DateTimeOffset? DocumentIssuedAt,
|
||||
string? SourceDocumentId);
|
||||
231
src/VexLens/StellaOps.VexLens/Consensus/IVexConsensusEngine.cs
Normal file
231
src/VexLens/StellaOps.VexLens/Consensus/IVexConsensusEngine.cs
Normal file
@@ -0,0 +1,231 @@
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.Trust;
|
||||
|
||||
namespace StellaOps.VexLens.Consensus;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for computing VEX consensus from multiple sources.
|
||||
/// </summary>
|
||||
public interface IVexConsensusEngine
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes consensus for a vulnerability-product pair from multiple statements.
|
||||
/// </summary>
|
||||
Task<VexConsensusResult> ComputeConsensusAsync(
|
||||
VexConsensusRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Computes consensus for multiple vulnerability-product pairs in batch.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<VexConsensusResult>> ComputeConsensusBatchAsync(
|
||||
IEnumerable<VexConsensusRequest> requests,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the consensus algorithm configuration.
|
||||
/// </summary>
|
||||
ConsensusConfiguration GetConfiguration();
|
||||
|
||||
/// <summary>
|
||||
/// Updates the consensus algorithm configuration.
|
||||
/// </summary>
|
||||
void UpdateConfiguration(ConsensusConfiguration configuration);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for consensus computation.
|
||||
/// </summary>
|
||||
public sealed record VexConsensusRequest(
|
||||
string VulnerabilityId,
|
||||
string ProductKey,
|
||||
IReadOnlyList<WeightedStatement> Statements,
|
||||
ConsensusContext Context);
|
||||
|
||||
/// <summary>
|
||||
/// A VEX statement with its computed trust weight.
|
||||
/// </summary>
|
||||
public sealed record WeightedStatement(
|
||||
NormalizedStatement Statement,
|
||||
TrustWeightResult Weight,
|
||||
VexIssuer? Issuer,
|
||||
string? SourceDocumentId);
|
||||
|
||||
/// <summary>
|
||||
/// Context for consensus computation.
|
||||
/// </summary>
|
||||
public sealed record ConsensusContext(
|
||||
string? TenantId,
|
||||
DateTimeOffset EvaluationTime,
|
||||
ConsensusPolicy? Policy);
|
||||
|
||||
/// <summary>
|
||||
/// Policy for consensus computation.
|
||||
/// </summary>
|
||||
public sealed record ConsensusPolicy(
|
||||
ConsensusMode Mode,
|
||||
double MinimumWeightThreshold,
|
||||
double ConflictThreshold,
|
||||
bool RequireJustificationForNotAffected,
|
||||
IReadOnlyList<string>? PreferredIssuers);
|
||||
|
||||
/// <summary>
|
||||
/// Mode for consensus computation.
|
||||
/// </summary>
|
||||
public enum ConsensusMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Use the statement with highest trust weight.
|
||||
/// </summary>
|
||||
HighestWeight,
|
||||
|
||||
/// <summary>
|
||||
/// Weighted voting among all statements.
|
||||
/// </summary>
|
||||
WeightedVote,
|
||||
|
||||
/// <summary>
|
||||
/// Lattice-based consensus (most conservative status wins ties).
|
||||
/// </summary>
|
||||
Lattice,
|
||||
|
||||
/// <summary>
|
||||
/// Prefer vendor/authoritative sources over others.
|
||||
/// </summary>
|
||||
AuthoritativeFirst
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of consensus computation.
|
||||
/// </summary>
|
||||
public sealed record VexConsensusResult(
|
||||
string VulnerabilityId,
|
||||
string ProductKey,
|
||||
VexStatus ConsensusStatus,
|
||||
VexJustification? ConsensusJustification,
|
||||
double ConfidenceScore,
|
||||
ConsensusOutcome Outcome,
|
||||
ConsensusRationale Rationale,
|
||||
IReadOnlyList<StatementContribution> Contributions,
|
||||
IReadOnlyList<ConsensusConflict>? Conflicts,
|
||||
DateTimeOffset ComputedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of consensus computation.
|
||||
/// </summary>
|
||||
public enum ConsensusOutcome
|
||||
{
|
||||
/// <summary>
|
||||
/// All statements agree on status.
|
||||
/// </summary>
|
||||
Unanimous,
|
||||
|
||||
/// <summary>
|
||||
/// Majority of weight supports the consensus.
|
||||
/// </summary>
|
||||
Majority,
|
||||
|
||||
/// <summary>
|
||||
/// Plurality of weight supports the consensus.
|
||||
/// </summary>
|
||||
Plurality,
|
||||
|
||||
/// <summary>
|
||||
/// Conflict detected but resolved by policy.
|
||||
/// </summary>
|
||||
ConflictResolved,
|
||||
|
||||
/// <summary>
|
||||
/// No statements available.
|
||||
/// </summary>
|
||||
NoData,
|
||||
|
||||
/// <summary>
|
||||
/// Consensus could not be determined.
|
||||
/// </summary>
|
||||
Indeterminate
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rationale explaining the consensus decision.
|
||||
/// </summary>
|
||||
public sealed record ConsensusRationale(
|
||||
string Summary,
|
||||
IReadOnlyList<string> Factors,
|
||||
IReadOnlyDictionary<VexStatus, double> StatusWeights);
|
||||
|
||||
/// <summary>
|
||||
/// Contribution of a single statement to the consensus.
|
||||
/// </summary>
|
||||
public sealed record StatementContribution(
|
||||
string StatementId,
|
||||
string? IssuerId,
|
||||
VexStatus Status,
|
||||
VexJustification? Justification,
|
||||
double Weight,
|
||||
double Contribution,
|
||||
bool IsWinner);
|
||||
|
||||
/// <summary>
|
||||
/// Conflict between statements.
|
||||
/// </summary>
|
||||
public sealed record ConsensusConflict(
|
||||
string Statement1Id,
|
||||
string Statement2Id,
|
||||
VexStatus Status1,
|
||||
VexStatus Status2,
|
||||
ConflictSeverity Severity,
|
||||
string Resolution);
|
||||
|
||||
/// <summary>
|
||||
/// Severity of a conflict.
|
||||
/// </summary>
|
||||
public enum ConflictSeverity
|
||||
{
|
||||
/// <summary>
|
||||
/// Minor disagreement (e.g., different justifications for same status).
|
||||
/// </summary>
|
||||
Low,
|
||||
|
||||
/// <summary>
|
||||
/// Moderate disagreement (e.g., fixed vs not_affected).
|
||||
/// </summary>
|
||||
Medium,
|
||||
|
||||
/// <summary>
|
||||
/// Major disagreement (e.g., affected vs not_affected).
|
||||
/// </summary>
|
||||
High,
|
||||
|
||||
/// <summary>
|
||||
/// Critical disagreement requiring manual review.
|
||||
/// </summary>
|
||||
Critical
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for consensus algorithm.
|
||||
/// </summary>
|
||||
public sealed record ConsensusConfiguration(
|
||||
ConsensusMode DefaultMode,
|
||||
double DefaultMinimumWeightThreshold,
|
||||
double DefaultConflictThreshold,
|
||||
StatusLattice StatusLattice,
|
||||
ConflictResolutionRules ConflictRules);
|
||||
|
||||
/// <summary>
|
||||
/// Lattice ordering of VEX statuses for conservative consensus.
|
||||
/// </summary>
|
||||
public sealed record StatusLattice(
|
||||
IReadOnlyDictionary<VexStatus, int> StatusOrder,
|
||||
VexStatus BottomStatus,
|
||||
VexStatus TopStatus);
|
||||
|
||||
/// <summary>
|
||||
/// Rules for resolving conflicts.
|
||||
/// </summary>
|
||||
public sealed record ConflictResolutionRules(
|
||||
double WeightRatioForOverride,
|
||||
bool PreferMostRecent,
|
||||
bool PreferMostSpecific,
|
||||
IReadOnlyList<VexStatus>? StatusPriority);
|
||||
505
src/VexLens/StellaOps.VexLens/Consensus/VexConsensusEngine.cs
Normal file
505
src/VexLens/StellaOps.VexLens/Consensus/VexConsensusEngine.cs
Normal file
@@ -0,0 +1,505 @@
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Consensus;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IVexConsensusEngine"/>.
|
||||
/// Computes VEX consensus using configurable algorithms.
|
||||
/// </summary>
|
||||
public sealed class VexConsensusEngine : IVexConsensusEngine
|
||||
{
|
||||
private ConsensusConfiguration _configuration;
|
||||
|
||||
public VexConsensusEngine(ConsensusConfiguration? configuration = null)
|
||||
{
|
||||
_configuration = configuration ?? CreateDefaultConfiguration();
|
||||
}
|
||||
|
||||
public Task<VexConsensusResult> ComputeConsensusAsync(
|
||||
VexConsensusRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request.Statements.Count == 0)
|
||||
{
|
||||
return Task.FromResult(CreateNoDataResult(request));
|
||||
}
|
||||
|
||||
var policy = request.Context.Policy ?? CreateDefaultPolicy();
|
||||
var mode = policy.Mode;
|
||||
|
||||
// Filter statements by minimum weight threshold
|
||||
var qualifiedStatements = request.Statements
|
||||
.Where(s => s.Weight.Weight >= policy.MinimumWeightThreshold)
|
||||
.ToList();
|
||||
|
||||
if (qualifiedStatements.Count == 0)
|
||||
{
|
||||
return Task.FromResult(CreateNoDataResult(request,
|
||||
"All statements below minimum weight threshold"));
|
||||
}
|
||||
|
||||
// Compute consensus based on mode
|
||||
var result = mode switch
|
||||
{
|
||||
ConsensusMode.HighestWeight => ComputeHighestWeightConsensus(request, qualifiedStatements, policy),
|
||||
ConsensusMode.WeightedVote => ComputeWeightedVoteConsensus(request, qualifiedStatements, policy),
|
||||
ConsensusMode.Lattice => ComputeLatticeConsensus(request, qualifiedStatements, policy),
|
||||
ConsensusMode.AuthoritativeFirst => ComputeAuthoritativeFirstConsensus(request, qualifiedStatements, policy),
|
||||
_ => ComputeHighestWeightConsensus(request, qualifiedStatements, policy)
|
||||
};
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<VexConsensusResult>> ComputeConsensusBatchAsync(
|
||||
IEnumerable<VexConsensusRequest> requests,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = new List<VexConsensusResult>();
|
||||
|
||||
foreach (var request in requests)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var result = await ComputeConsensusAsync(request, cancellationToken);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public ConsensusConfiguration GetConfiguration() => _configuration;
|
||||
|
||||
public void UpdateConfiguration(ConsensusConfiguration configuration)
|
||||
{
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
private VexConsensusResult ComputeHighestWeightConsensus(
|
||||
VexConsensusRequest request,
|
||||
List<WeightedStatement> statements,
|
||||
ConsensusPolicy policy)
|
||||
{
|
||||
var ordered = statements.OrderByDescending(s => s.Weight.Weight).ToList();
|
||||
var winner = ordered[0];
|
||||
var conflicts = DetectConflicts(ordered, policy);
|
||||
|
||||
var contributions = CreateContributions(ordered, winner.Statement.StatementId);
|
||||
var statusWeights = ComputeStatusWeights(ordered);
|
||||
|
||||
var outcome = DetermineOutcome(ordered, winner, conflicts);
|
||||
var confidence = ComputeConfidence(ordered, winner, conflicts);
|
||||
|
||||
var factors = new List<string>
|
||||
{
|
||||
$"Selected statement with highest weight: {winner.Weight.Weight:F4}",
|
||||
$"Issuer: {winner.Issuer?.Name ?? winner.Statement.StatementId}"
|
||||
};
|
||||
|
||||
if (conflicts.Count > 0)
|
||||
{
|
||||
factors.Add($"Resolved {conflicts.Count} conflict(s) by weight");
|
||||
}
|
||||
|
||||
return new VexConsensusResult(
|
||||
VulnerabilityId: request.VulnerabilityId,
|
||||
ProductKey: request.ProductKey,
|
||||
ConsensusStatus: winner.Statement.Status,
|
||||
ConsensusJustification: winner.Statement.Justification,
|
||||
ConfidenceScore: confidence,
|
||||
Outcome: outcome,
|
||||
Rationale: new ConsensusRationale(
|
||||
Summary: $"Highest weight consensus: {winner.Statement.Status}",
|
||||
Factors: factors,
|
||||
StatusWeights: statusWeights),
|
||||
Contributions: contributions,
|
||||
Conflicts: conflicts.Count > 0 ? conflicts : null,
|
||||
ComputedAt: request.Context.EvaluationTime);
|
||||
}
|
||||
|
||||
private VexConsensusResult ComputeWeightedVoteConsensus(
|
||||
VexConsensusRequest request,
|
||||
List<WeightedStatement> statements,
|
||||
ConsensusPolicy policy)
|
||||
{
|
||||
var statusWeights = ComputeStatusWeights(statements);
|
||||
var totalWeight = statusWeights.Values.Sum();
|
||||
|
||||
// Find the status with highest total weight
|
||||
var winningStatus = statusWeights
|
||||
.OrderByDescending(kv => kv.Value)
|
||||
.First();
|
||||
|
||||
var winningStatements = statements
|
||||
.Where(s => s.Statement.Status == winningStatus.Key)
|
||||
.OrderByDescending(s => s.Weight.Weight)
|
||||
.ToList();
|
||||
|
||||
var primaryWinner = winningStatements[0];
|
||||
var conflicts = DetectConflicts(statements, policy);
|
||||
var contributions = CreateContributions(statements, primaryWinner.Statement.StatementId);
|
||||
|
||||
var voteFraction = totalWeight > 0 ? winningStatus.Value / totalWeight : 0;
|
||||
var outcome = voteFraction >= 0.5
|
||||
? ConsensusOutcome.Majority
|
||||
: ConsensusOutcome.Plurality;
|
||||
|
||||
if (statements.All(s => s.Statement.Status == winningStatus.Key))
|
||||
{
|
||||
outcome = ConsensusOutcome.Unanimous;
|
||||
}
|
||||
|
||||
var confidence = voteFraction * ComputeWeightSpreadFactor(statements);
|
||||
|
||||
var factors = new List<string>
|
||||
{
|
||||
$"Weighted vote: {winningStatus.Key} received {voteFraction:P1} of total weight",
|
||||
$"{winningStatements.Count} statement(s) support this status"
|
||||
};
|
||||
|
||||
return new VexConsensusResult(
|
||||
VulnerabilityId: request.VulnerabilityId,
|
||||
ProductKey: request.ProductKey,
|
||||
ConsensusStatus: winningStatus.Key,
|
||||
ConsensusJustification: primaryWinner.Statement.Justification,
|
||||
ConfidenceScore: confidence,
|
||||
Outcome: outcome,
|
||||
Rationale: new ConsensusRationale(
|
||||
Summary: $"Weighted vote consensus: {winningStatus.Key} ({voteFraction:P1})",
|
||||
Factors: factors,
|
||||
StatusWeights: statusWeights),
|
||||
Contributions: contributions,
|
||||
Conflicts: conflicts.Count > 0 ? conflicts : null,
|
||||
ComputedAt: request.Context.EvaluationTime);
|
||||
}
|
||||
|
||||
private VexConsensusResult ComputeLatticeConsensus(
|
||||
VexConsensusRequest request,
|
||||
List<WeightedStatement> statements,
|
||||
ConsensusPolicy policy)
|
||||
{
|
||||
var lattice = _configuration.StatusLattice;
|
||||
var statusWeights = ComputeStatusWeights(statements);
|
||||
|
||||
// Find the lowest status in the lattice (most conservative)
|
||||
var lowestStatus = statements
|
||||
.Select(s => s.Statement.Status)
|
||||
.OrderBy(s => lattice.StatusOrder.GetValueOrDefault(s, int.MaxValue))
|
||||
.First();
|
||||
|
||||
var lowestStatements = statements
|
||||
.Where(s => s.Statement.Status == lowestStatus)
|
||||
.OrderByDescending(s => s.Weight.Weight)
|
||||
.ToList();
|
||||
|
||||
var primaryWinner = lowestStatements[0];
|
||||
var conflicts = DetectConflicts(statements, policy);
|
||||
var contributions = CreateContributions(statements, primaryWinner.Statement.StatementId);
|
||||
|
||||
var outcome = statements.All(s => s.Statement.Status == lowestStatus)
|
||||
? ConsensusOutcome.Unanimous
|
||||
: ConsensusOutcome.ConflictResolved;
|
||||
|
||||
// Confidence based on weight of supporting statements
|
||||
var supportWeight = lowestStatements.Sum(s => s.Weight.Weight);
|
||||
var totalWeight = statements.Sum(s => s.Weight.Weight);
|
||||
var confidence = totalWeight > 0 ? supportWeight / totalWeight : 0;
|
||||
|
||||
var factors = new List<string>
|
||||
{
|
||||
$"Lattice consensus: selected most conservative status",
|
||||
$"Status order: {string.Join(" < ", lattice.StatusOrder.OrderBy(kv => kv.Value).Select(kv => kv.Key))}"
|
||||
};
|
||||
|
||||
return new VexConsensusResult(
|
||||
VulnerabilityId: request.VulnerabilityId,
|
||||
ProductKey: request.ProductKey,
|
||||
ConsensusStatus: lowestStatus,
|
||||
ConsensusJustification: primaryWinner.Statement.Justification,
|
||||
ConfidenceScore: confidence,
|
||||
Outcome: outcome,
|
||||
Rationale: new ConsensusRationale(
|
||||
Summary: $"Lattice consensus: {lowestStatus} (most conservative)",
|
||||
Factors: factors,
|
||||
StatusWeights: statusWeights),
|
||||
Contributions: contributions,
|
||||
Conflicts: conflicts.Count > 0 ? conflicts : null,
|
||||
ComputedAt: request.Context.EvaluationTime);
|
||||
}
|
||||
|
||||
private VexConsensusResult ComputeAuthoritativeFirstConsensus(
|
||||
VexConsensusRequest request,
|
||||
List<WeightedStatement> statements,
|
||||
ConsensusPolicy policy)
|
||||
{
|
||||
// Prefer authoritative sources (vendors) over others
|
||||
var ordered = statements
|
||||
.OrderByDescending(s => IsAuthoritative(s.Issuer))
|
||||
.ThenByDescending(s => s.Weight.Weight)
|
||||
.ToList();
|
||||
|
||||
var winner = ordered[0];
|
||||
var conflicts = DetectConflicts(ordered, policy);
|
||||
var contributions = CreateContributions(ordered, winner.Statement.StatementId);
|
||||
var statusWeights = ComputeStatusWeights(ordered);
|
||||
|
||||
var isAuthoritative = IsAuthoritative(winner.Issuer);
|
||||
var outcome = isAuthoritative
|
||||
? ConsensusOutcome.Unanimous // Authoritative source takes precedence
|
||||
: DetermineOutcome(ordered, winner, conflicts);
|
||||
|
||||
var confidence = isAuthoritative
|
||||
? 0.95
|
||||
: ComputeConfidence(ordered, winner, conflicts);
|
||||
|
||||
var factors = new List<string>
|
||||
{
|
||||
isAuthoritative
|
||||
? $"Authoritative source: {winner.Issuer?.Name ?? "unknown"}"
|
||||
: $"No authoritative source; using highest weight",
|
||||
$"Weight: {winner.Weight.Weight:F4}"
|
||||
};
|
||||
|
||||
return new VexConsensusResult(
|
||||
VulnerabilityId: request.VulnerabilityId,
|
||||
ProductKey: request.ProductKey,
|
||||
ConsensusStatus: winner.Statement.Status,
|
||||
ConsensusJustification: winner.Statement.Justification,
|
||||
ConfidenceScore: confidence,
|
||||
Outcome: outcome,
|
||||
Rationale: new ConsensusRationale(
|
||||
Summary: $"Authoritative-first consensus: {winner.Statement.Status}",
|
||||
Factors: factors,
|
||||
StatusWeights: statusWeights),
|
||||
Contributions: contributions,
|
||||
Conflicts: conflicts.Count > 0 ? conflicts : null,
|
||||
ComputedAt: request.Context.EvaluationTime);
|
||||
}
|
||||
|
||||
private static bool IsAuthoritative(VexIssuer? issuer)
|
||||
{
|
||||
if (issuer == null) return false;
|
||||
|
||||
return issuer.Category == IssuerCategory.Vendor ||
|
||||
issuer.TrustTier == TrustTier.Authoritative;
|
||||
}
|
||||
|
||||
private List<ConsensusConflict> DetectConflicts(
|
||||
List<WeightedStatement> statements,
|
||||
ConsensusPolicy policy)
|
||||
{
|
||||
var conflicts = new List<ConsensusConflict>();
|
||||
|
||||
for (var i = 0; i < statements.Count; i++)
|
||||
{
|
||||
for (var j = i + 1; j < statements.Count; j++)
|
||||
{
|
||||
var s1 = statements[i];
|
||||
var s2 = statements[j];
|
||||
|
||||
if (s1.Statement.Status != s2.Statement.Status)
|
||||
{
|
||||
var severity = DetermineConflictSeverity(s1.Statement.Status, s2.Statement.Status);
|
||||
var resolution = DetermineResolution(s1, s2);
|
||||
|
||||
conflicts.Add(new ConsensusConflict(
|
||||
Statement1Id: s1.Statement.StatementId,
|
||||
Statement2Id: s2.Statement.StatementId,
|
||||
Status1: s1.Statement.Status,
|
||||
Status2: s2.Statement.Status,
|
||||
Severity: severity,
|
||||
Resolution: resolution));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
private static ConflictSeverity DetermineConflictSeverity(VexStatus status1, VexStatus status2)
|
||||
{
|
||||
// Affected vs NotAffected is the most severe
|
||||
if ((status1 == VexStatus.Affected && status2 == VexStatus.NotAffected) ||
|
||||
(status1 == VexStatus.NotAffected && status2 == VexStatus.Affected))
|
||||
{
|
||||
return ConflictSeverity.Critical;
|
||||
}
|
||||
|
||||
// Affected vs Fixed is high
|
||||
if ((status1 == VexStatus.Affected && status2 == VexStatus.Fixed) ||
|
||||
(status1 == VexStatus.Fixed && status2 == VexStatus.Affected))
|
||||
{
|
||||
return ConflictSeverity.High;
|
||||
}
|
||||
|
||||
// Fixed vs NotAffected is medium
|
||||
if ((status1 == VexStatus.Fixed && status2 == VexStatus.NotAffected) ||
|
||||
(status1 == VexStatus.NotAffected && status2 == VexStatus.Fixed))
|
||||
{
|
||||
return ConflictSeverity.Medium;
|
||||
}
|
||||
|
||||
// UnderInvestigation vs anything is low
|
||||
if (status1 == VexStatus.UnderInvestigation || status2 == VexStatus.UnderInvestigation)
|
||||
{
|
||||
return ConflictSeverity.Low;
|
||||
}
|
||||
|
||||
return ConflictSeverity.Medium;
|
||||
}
|
||||
|
||||
private static string DetermineResolution(WeightedStatement s1, WeightedStatement s2)
|
||||
{
|
||||
var weightRatio = s1.Weight.Weight / Math.Max(s2.Weight.Weight, 0.001);
|
||||
|
||||
if (weightRatio > 2.0)
|
||||
{
|
||||
return $"Resolved by weight ({s1.Weight.Weight:F2} vs {s2.Weight.Weight:F2})";
|
||||
}
|
||||
|
||||
if (IsAuthoritative(s1.Issuer) && !IsAuthoritative(s2.Issuer))
|
||||
{
|
||||
return "Resolved by authoritative source preference";
|
||||
}
|
||||
|
||||
return "Resolved by algorithm default";
|
||||
}
|
||||
|
||||
private static Dictionary<VexStatus, double> ComputeStatusWeights(List<WeightedStatement> statements)
|
||||
{
|
||||
return statements
|
||||
.GroupBy(s => s.Statement.Status)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.Sum(s => s.Weight.Weight));
|
||||
}
|
||||
|
||||
private static List<StatementContribution> CreateContributions(
|
||||
List<WeightedStatement> statements,
|
||||
string winnerId)
|
||||
{
|
||||
var totalWeight = statements.Sum(s => s.Weight.Weight);
|
||||
|
||||
return statements.Select(s => new StatementContribution(
|
||||
StatementId: s.Statement.StatementId,
|
||||
IssuerId: s.Issuer?.Id,
|
||||
Status: s.Statement.Status,
|
||||
Justification: s.Statement.Justification,
|
||||
Weight: s.Weight.Weight,
|
||||
Contribution: totalWeight > 0 ? s.Weight.Weight / totalWeight : 0,
|
||||
IsWinner: s.Statement.StatementId == winnerId)).ToList();
|
||||
}
|
||||
|
||||
private static ConsensusOutcome DetermineOutcome(
|
||||
List<WeightedStatement> statements,
|
||||
WeightedStatement winner,
|
||||
List<ConsensusConflict> conflicts)
|
||||
{
|
||||
if (statements.All(s => s.Statement.Status == winner.Statement.Status))
|
||||
{
|
||||
return ConsensusOutcome.Unanimous;
|
||||
}
|
||||
|
||||
if (conflicts.Count > 0)
|
||||
{
|
||||
return ConsensusOutcome.ConflictResolved;
|
||||
}
|
||||
|
||||
var winnerCount = statements.Count(s => s.Statement.Status == winner.Statement.Status);
|
||||
if (winnerCount > statements.Count / 2)
|
||||
{
|
||||
return ConsensusOutcome.Majority;
|
||||
}
|
||||
|
||||
return ConsensusOutcome.Plurality;
|
||||
}
|
||||
|
||||
private static double ComputeConfidence(
|
||||
List<WeightedStatement> statements,
|
||||
WeightedStatement winner,
|
||||
List<ConsensusConflict> conflicts)
|
||||
{
|
||||
var totalWeight = statements.Sum(s => s.Weight.Weight);
|
||||
var winnerWeight = winner.Weight.Weight;
|
||||
|
||||
var baseConfidence = totalWeight > 0 ? winnerWeight / totalWeight : 0;
|
||||
|
||||
// Reduce confidence for conflicts
|
||||
var conflictPenalty = conflicts.Sum(c => c.Severity switch
|
||||
{
|
||||
ConflictSeverity.Critical => 0.3,
|
||||
ConflictSeverity.High => 0.2,
|
||||
ConflictSeverity.Medium => 0.1,
|
||||
ConflictSeverity.Low => 0.05,
|
||||
_ => 0
|
||||
});
|
||||
|
||||
return Math.Max(0, baseConfidence - conflictPenalty);
|
||||
}
|
||||
|
||||
private static double ComputeWeightSpreadFactor(List<WeightedStatement> statements)
|
||||
{
|
||||
if (statements.Count <= 1) return 1.0;
|
||||
|
||||
var weights = statements.Select(s => s.Weight.Weight).ToList();
|
||||
var max = weights.Max();
|
||||
var min = weights.Min();
|
||||
var avg = weights.Average();
|
||||
|
||||
// Higher spread means less confidence
|
||||
var spread = max > 0 ? (max - min) / max : 0;
|
||||
return 1.0 - (spread * 0.5);
|
||||
}
|
||||
|
||||
private static VexConsensusResult CreateNoDataResult(
|
||||
VexConsensusRequest request,
|
||||
string? reason = null)
|
||||
{
|
||||
return new VexConsensusResult(
|
||||
VulnerabilityId: request.VulnerabilityId,
|
||||
ProductKey: request.ProductKey,
|
||||
ConsensusStatus: VexStatus.UnderInvestigation,
|
||||
ConsensusJustification: null,
|
||||
ConfidenceScore: 0,
|
||||
Outcome: ConsensusOutcome.NoData,
|
||||
Rationale: new ConsensusRationale(
|
||||
Summary: reason ?? "No VEX statements available",
|
||||
Factors: [reason ?? "No qualifying statements found"],
|
||||
StatusWeights: new Dictionary<VexStatus, double>()),
|
||||
Contributions: [],
|
||||
Conflicts: null,
|
||||
ComputedAt: request.Context.EvaluationTime);
|
||||
}
|
||||
|
||||
private static ConsensusPolicy CreateDefaultPolicy()
|
||||
{
|
||||
return new ConsensusPolicy(
|
||||
Mode: ConsensusMode.WeightedVote,
|
||||
MinimumWeightThreshold: 0.1,
|
||||
ConflictThreshold: 0.3,
|
||||
RequireJustificationForNotAffected: false,
|
||||
PreferredIssuers: null);
|
||||
}
|
||||
|
||||
public static ConsensusConfiguration CreateDefaultConfiguration()
|
||||
{
|
||||
return new ConsensusConfiguration(
|
||||
DefaultMode: ConsensusMode.WeightedVote,
|
||||
DefaultMinimumWeightThreshold: 0.1,
|
||||
DefaultConflictThreshold: 0.3,
|
||||
StatusLattice: new StatusLattice(
|
||||
StatusOrder: new Dictionary<VexStatus, int>
|
||||
{
|
||||
[VexStatus.Affected] = 0,
|
||||
[VexStatus.UnderInvestigation] = 1,
|
||||
[VexStatus.Fixed] = 2,
|
||||
[VexStatus.NotAffected] = 3
|
||||
},
|
||||
BottomStatus: VexStatus.Affected,
|
||||
TopStatus: VexStatus.NotAffected),
|
||||
ConflictRules: new ConflictResolutionRules(
|
||||
WeightRatioForOverride: 2.0,
|
||||
PreferMostRecent: true,
|
||||
PreferMostSpecific: true,
|
||||
StatusPriority: null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.VexLens.Api;
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Integration;
|
||||
using StellaOps.VexLens.Mapping;
|
||||
using StellaOps.VexLens.Normalization;
|
||||
using StellaOps.VexLens.Observability;
|
||||
using StellaOps.VexLens.Options;
|
||||
using StellaOps.VexLens.Storage;
|
||||
using StellaOps.VexLens.Trust;
|
||||
using StellaOps.VexLens.Verification;
|
||||
|
||||
namespace StellaOps.VexLens.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering VexLens services.
|
||||
/// </summary>
|
||||
public static class VexLensServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds VexLens consensus engine services to the service collection.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddVexLens(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
var section = configuration.GetSection(VexLensOptions.SectionName);
|
||||
services.Configure<VexLensOptions>(section);
|
||||
|
||||
var options = section.Get<VexLensOptions>() ?? new VexLensOptions();
|
||||
|
||||
return services.AddVexLensCore(options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds VexLens consensus engine services with explicit options.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddVexLens(
|
||||
this IServiceCollection services,
|
||||
Action<VexLensOptions> configure)
|
||||
{
|
||||
var options = new VexLensOptions();
|
||||
configure(options);
|
||||
|
||||
services.Configure(configure);
|
||||
|
||||
return services.AddVexLensCore(options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds VexLens services for testing with in-memory storage.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddVexLensForTesting(this IServiceCollection services)
|
||||
{
|
||||
var options = new VexLensOptions
|
||||
{
|
||||
Storage = { Driver = "memory" },
|
||||
Telemetry = { MetricsEnabled = false, TracingEnabled = false }
|
||||
};
|
||||
|
||||
return services.AddVexLensCore(options);
|
||||
}
|
||||
|
||||
private static IServiceCollection AddVexLensCore(
|
||||
this IServiceCollection services,
|
||||
VexLensOptions options)
|
||||
{
|
||||
// Normalization
|
||||
services.TryAddSingleton<IVexNormalizerRegistry>(sp =>
|
||||
{
|
||||
var registry = new VexNormalizerRegistry();
|
||||
RegisterNormalizers(registry, options.Normalization);
|
||||
return registry;
|
||||
});
|
||||
|
||||
// Product mapping
|
||||
services.TryAddSingleton<IProductMapper, ProductMapper>();
|
||||
|
||||
// Verification
|
||||
services.TryAddSingleton<ISignatureVerifier, SignatureVerifier>();
|
||||
|
||||
// Issuer directory - use in-memory by default, can be replaced
|
||||
services.TryAddSingleton<IIssuerDirectory, InMemoryIssuerDirectory>();
|
||||
|
||||
// Trust engine
|
||||
services.TryAddSingleton<ITrustWeightEngine, TrustWeightEngine>();
|
||||
|
||||
// Consensus engine
|
||||
services.TryAddSingleton<IVexConsensusEngine, VexConsensusEngine>();
|
||||
|
||||
// Storage
|
||||
RegisterStorage(services, options.Storage);
|
||||
|
||||
// Event emitter - in-memory for now
|
||||
services.TryAddSingleton<IConsensusEventEmitter, InMemoryConsensusEventEmitter>();
|
||||
|
||||
// API service
|
||||
services.TryAddScoped<IVexLensApiService, VexLensApiService>();
|
||||
|
||||
// Rationale service for AI/ML consumption
|
||||
services.TryAddScoped<IConsensusRationaleService, ConsensusRationaleService>();
|
||||
|
||||
// Integration services
|
||||
services.TryAddScoped<IPolicyEngineIntegration, PolicyEngineIntegration>();
|
||||
services.TryAddScoped<IVulnExplorerIntegration, VulnExplorerIntegration>();
|
||||
|
||||
// Metrics
|
||||
if (options.Telemetry.MetricsEnabled)
|
||||
{
|
||||
services.TryAddSingleton<VexLensMetrics>();
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void RegisterNormalizers(
|
||||
VexNormalizerRegistry registry,
|
||||
VexLensNormalizationOptions options)
|
||||
{
|
||||
var enabledFormats = new HashSet<string>(
|
||||
options.EnabledFormats,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (enabledFormats.Contains("OpenVEX"))
|
||||
{
|
||||
registry.Register(new OpenVexNormalizer());
|
||||
}
|
||||
|
||||
if (enabledFormats.Contains("CSAF"))
|
||||
{
|
||||
registry.Register(new CsafVexNormalizer());
|
||||
}
|
||||
|
||||
if (enabledFormats.Contains("CycloneDX"))
|
||||
{
|
||||
registry.Register(new CycloneDxVexNormalizer());
|
||||
}
|
||||
}
|
||||
|
||||
private static void RegisterStorage(
|
||||
IServiceCollection services,
|
||||
VexLensStorageOptions options)
|
||||
{
|
||||
switch (options.Driver.ToLowerInvariant())
|
||||
{
|
||||
case "memory":
|
||||
services.TryAddSingleton<IConsensusProjectionStore>(sp =>
|
||||
{
|
||||
var emitter = sp.GetRequiredService<IConsensusEventEmitter>();
|
||||
return new InMemoryConsensusProjectionStore(emitter);
|
||||
});
|
||||
break;
|
||||
|
||||
case "mongo":
|
||||
// MongoDB storage would be registered here
|
||||
// For now, fall back to in-memory
|
||||
services.TryAddSingleton<IConsensusProjectionStore>(sp =>
|
||||
{
|
||||
var emitter = sp.GetRequiredService<IConsensusEventEmitter>();
|
||||
return new InMemoryConsensusProjectionStore(emitter);
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"Unknown VexLens storage driver: {options.Driver}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Integration interface for Policy Engine consumption of VEX consensus.
|
||||
/// </summary>
|
||||
public interface IPolicyEngineIntegration
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the VEX consensus status for a vulnerability-product pair for policy evaluation.
|
||||
/// </summary>
|
||||
Task<PolicyVexStatusResult> GetVexStatusForPolicyAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
PolicyVexContext context,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets VEX status for multiple vulnerability-product pairs in batch.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PolicyVexStatusResult>> GetVexStatusBatchAsync(
|
||||
IEnumerable<PolicyVexQuery> queries,
|
||||
PolicyVexContext context,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a vulnerability is suppressed by VEX for a product.
|
||||
/// </summary>
|
||||
Task<VexSuppressionResult> CheckVexSuppressionAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
PolicyVexContext context,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets VEX-adjusted severity for policy scoring.
|
||||
/// </summary>
|
||||
Task<VexAdjustedSeverityResult> GetVexAdjustedSeverityAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
double baseSeverity,
|
||||
PolicyVexContext context,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context for policy VEX queries.
|
||||
/// </summary>
|
||||
public sealed record PolicyVexContext(
|
||||
string? TenantId,
|
||||
string? PolicyId,
|
||||
double MinimumConfidenceThreshold,
|
||||
bool RequireSignedVex,
|
||||
DateTimeOffset EvaluationTime);
|
||||
|
||||
/// <summary>
|
||||
/// Query for policy VEX status.
|
||||
/// </summary>
|
||||
public sealed record PolicyVexQuery(
|
||||
string VulnerabilityId,
|
||||
string ProductKey);
|
||||
|
||||
/// <summary>
|
||||
/// Result of VEX status for policy evaluation.
|
||||
/// </summary>
|
||||
public sealed record PolicyVexStatusResult(
|
||||
string VulnerabilityId,
|
||||
string ProductKey,
|
||||
bool HasVexData,
|
||||
VexStatus? Status,
|
||||
VexJustification? Justification,
|
||||
double? ConfidenceScore,
|
||||
bool MeetsConfidenceThreshold,
|
||||
string? ProjectionId,
|
||||
PolicyVexEvidenceSummary? Evidence);
|
||||
|
||||
/// <summary>
|
||||
/// Summary of VEX evidence for policy.
|
||||
/// </summary>
|
||||
public sealed record PolicyVexEvidenceSummary(
|
||||
int StatementCount,
|
||||
int IssuerCount,
|
||||
int ConflictCount,
|
||||
string? PrimaryIssuer,
|
||||
DateTimeOffset? MostRecentStatement,
|
||||
IReadOnlyList<string> IssuerNames);
|
||||
|
||||
/// <summary>
|
||||
/// Result of VEX suppression check.
|
||||
/// </summary>
|
||||
public sealed record VexSuppressionResult(
|
||||
string VulnerabilityId,
|
||||
string ProductKey,
|
||||
bool IsSuppressed,
|
||||
VexSuppressionReason? Reason,
|
||||
VexStatus? Status,
|
||||
VexJustification? Justification,
|
||||
double? ConfidenceScore,
|
||||
string? SuppressedBy,
|
||||
DateTimeOffset? SuppressedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Reason for VEX suppression.
|
||||
/// </summary>
|
||||
public enum VexSuppressionReason
|
||||
{
|
||||
/// <summary>
|
||||
/// VEX indicates not_affected.
|
||||
/// </summary>
|
||||
NotAffected,
|
||||
|
||||
/// <summary>
|
||||
/// VEX indicates fixed.
|
||||
/// </summary>
|
||||
Fixed,
|
||||
|
||||
/// <summary>
|
||||
/// VEX provides justification for not_affected.
|
||||
/// </summary>
|
||||
JustifiedNotAffected
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of VEX-adjusted severity calculation.
|
||||
/// </summary>
|
||||
public sealed record VexAdjustedSeverityResult(
|
||||
string VulnerabilityId,
|
||||
string ProductKey,
|
||||
double BaseSeverity,
|
||||
double AdjustedSeverity,
|
||||
double AdjustmentFactor,
|
||||
VexStatus? VexStatus,
|
||||
string? AdjustmentReason);
|
||||
|
||||
/// <summary>
|
||||
/// Integration interface for Vuln Explorer consumption of VEX consensus.
|
||||
/// </summary>
|
||||
public interface IVulnExplorerIntegration
|
||||
{
|
||||
/// <summary>
|
||||
/// Enriches a vulnerability with VEX consensus data.
|
||||
/// </summary>
|
||||
Task<VulnVexEnrichment> EnrichVulnerabilityAsync(
|
||||
string vulnerabilityId,
|
||||
string? productKey,
|
||||
VulnVexContext context,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets VEX timeline for a vulnerability.
|
||||
/// </summary>
|
||||
Task<VexTimelineResult> GetVexTimelineAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
VulnVexContext context,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets VEX summary statistics for a vulnerability.
|
||||
/// </summary>
|
||||
Task<VulnVexSummary> GetVexSummaryAsync(
|
||||
string vulnerabilityId,
|
||||
VulnVexContext context,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Searches VEX data for vulnerabilities matching criteria.
|
||||
/// </summary>
|
||||
Task<VexSearchResult> SearchVexAsync(
|
||||
VexSearchQuery query,
|
||||
VulnVexContext context,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context for Vuln Explorer VEX queries.
|
||||
/// </summary>
|
||||
public sealed record VulnVexContext(
|
||||
string? TenantId,
|
||||
bool IncludeRawStatements,
|
||||
bool IncludeHistory,
|
||||
int? HistoryLimit);
|
||||
|
||||
/// <summary>
|
||||
/// VEX enrichment data for a vulnerability.
|
||||
/// </summary>
|
||||
public sealed record VulnVexEnrichment(
|
||||
string VulnerabilityId,
|
||||
bool HasVexData,
|
||||
VexStatus? ConsensusStatus,
|
||||
VexJustification? Justification,
|
||||
double? ConfidenceScore,
|
||||
int ProductCount,
|
||||
IReadOnlyList<ProductVexStatus> ProductStatuses,
|
||||
IReadOnlyList<VexIssuerSummary> Issuers,
|
||||
DateTimeOffset? LastVexUpdate);
|
||||
|
||||
/// <summary>
|
||||
/// VEX status for a specific product.
|
||||
/// </summary>
|
||||
public sealed record ProductVexStatus(
|
||||
string ProductKey,
|
||||
string? ProductName,
|
||||
VexStatus Status,
|
||||
VexJustification? Justification,
|
||||
double ConfidenceScore,
|
||||
string? PrimaryIssuer,
|
||||
DateTimeOffset? ComputedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a VEX issuer.
|
||||
/// </summary>
|
||||
public sealed record VexIssuerSummary(
|
||||
string IssuerId,
|
||||
string Name,
|
||||
string Category,
|
||||
int StatementCount,
|
||||
VexStatus? MostCommonStatus);
|
||||
|
||||
/// <summary>
|
||||
/// VEX timeline for a vulnerability-product pair.
|
||||
/// </summary>
|
||||
public sealed record VexTimelineResult(
|
||||
string VulnerabilityId,
|
||||
string ProductKey,
|
||||
IReadOnlyList<VexTimelineEntry> Entries,
|
||||
VexStatus? CurrentStatus,
|
||||
int StatusChangeCount);
|
||||
|
||||
/// <summary>
|
||||
/// Entry in VEX timeline.
|
||||
/// </summary>
|
||||
public sealed record VexTimelineEntry(
|
||||
DateTimeOffset Timestamp,
|
||||
VexStatus Status,
|
||||
VexJustification? Justification,
|
||||
string? IssuerId,
|
||||
string? IssuerName,
|
||||
string EventType,
|
||||
string? Notes);
|
||||
|
||||
/// <summary>
|
||||
/// Summary of VEX data for a vulnerability.
|
||||
/// </summary>
|
||||
public sealed record VulnVexSummary(
|
||||
string VulnerabilityId,
|
||||
int TotalStatements,
|
||||
int TotalProducts,
|
||||
int TotalIssuers,
|
||||
IReadOnlyDictionary<VexStatus, int> StatusCounts,
|
||||
IReadOnlyDictionary<VexJustification, int> JustificationCounts,
|
||||
double AverageConfidence,
|
||||
DateTimeOffset? FirstVexStatement,
|
||||
DateTimeOffset? LatestVexStatement);
|
||||
|
||||
/// <summary>
|
||||
/// Query for searching VEX data.
|
||||
/// </summary>
|
||||
public sealed record VexSearchQuery(
|
||||
string? VulnerabilityIdPattern,
|
||||
string? ProductKeyPattern,
|
||||
VexStatus? Status,
|
||||
VexJustification? Justification,
|
||||
string? IssuerId,
|
||||
double? MinimumConfidence,
|
||||
DateTimeOffset? UpdatedAfter,
|
||||
int Limit,
|
||||
int Offset);
|
||||
|
||||
/// <summary>
|
||||
/// Result of VEX search.
|
||||
/// </summary>
|
||||
public sealed record VexSearchResult(
|
||||
IReadOnlyList<VexSearchHit> Hits,
|
||||
int TotalCount,
|
||||
int Offset,
|
||||
int Limit);
|
||||
|
||||
/// <summary>
|
||||
/// Search hit for VEX data.
|
||||
/// </summary>
|
||||
public sealed record VexSearchHit(
|
||||
string VulnerabilityId,
|
||||
string ProductKey,
|
||||
VexStatus Status,
|
||||
VexJustification? Justification,
|
||||
double ConfidenceScore,
|
||||
string? PrimaryIssuer,
|
||||
DateTimeOffset ComputedAt);
|
||||
@@ -0,0 +1,427 @@
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.Storage;
|
||||
|
||||
namespace StellaOps.VexLens.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IPolicyEngineIntegration"/>.
|
||||
/// </summary>
|
||||
public sealed class PolicyEngineIntegration : IPolicyEngineIntegration
|
||||
{
|
||||
private readonly IConsensusProjectionStore _projectionStore;
|
||||
|
||||
public PolicyEngineIntegration(IConsensusProjectionStore projectionStore)
|
||||
{
|
||||
_projectionStore = projectionStore;
|
||||
}
|
||||
|
||||
public async Task<PolicyVexStatusResult> GetVexStatusForPolicyAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
PolicyVexContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var projection = await _projectionStore.GetLatestAsync(
|
||||
vulnerabilityId,
|
||||
productKey,
|
||||
context.TenantId,
|
||||
cancellationToken);
|
||||
|
||||
if (projection == null)
|
||||
{
|
||||
return new PolicyVexStatusResult(
|
||||
VulnerabilityId: vulnerabilityId,
|
||||
ProductKey: productKey,
|
||||
HasVexData: false,
|
||||
Status: null,
|
||||
Justification: null,
|
||||
ConfidenceScore: null,
|
||||
MeetsConfidenceThreshold: false,
|
||||
ProjectionId: null,
|
||||
Evidence: null);
|
||||
}
|
||||
|
||||
var meetsThreshold = projection.ConfidenceScore >= context.MinimumConfidenceThreshold;
|
||||
|
||||
return new PolicyVexStatusResult(
|
||||
VulnerabilityId: vulnerabilityId,
|
||||
ProductKey: productKey,
|
||||
HasVexData: true,
|
||||
Status: projection.Status,
|
||||
Justification: projection.Justification,
|
||||
ConfidenceScore: projection.ConfidenceScore,
|
||||
MeetsConfidenceThreshold: meetsThreshold,
|
||||
ProjectionId: projection.ProjectionId,
|
||||
Evidence: new PolicyVexEvidenceSummary(
|
||||
StatementCount: projection.StatementCount,
|
||||
IssuerCount: 1, // Simplified; would need full projection data
|
||||
ConflictCount: projection.ConflictCount,
|
||||
PrimaryIssuer: null,
|
||||
MostRecentStatement: projection.ComputedAt,
|
||||
IssuerNames: []));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PolicyVexStatusResult>> GetVexStatusBatchAsync(
|
||||
IEnumerable<PolicyVexQuery> queries,
|
||||
PolicyVexContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = new List<PolicyVexStatusResult>();
|
||||
|
||||
foreach (var query in queries)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var result = await GetVexStatusForPolicyAsync(
|
||||
query.VulnerabilityId,
|
||||
query.ProductKey,
|
||||
context,
|
||||
cancellationToken);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<VexSuppressionResult> CheckVexSuppressionAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
PolicyVexContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var statusResult = await GetVexStatusForPolicyAsync(
|
||||
vulnerabilityId,
|
||||
productKey,
|
||||
context,
|
||||
cancellationToken);
|
||||
|
||||
if (!statusResult.HasVexData || !statusResult.MeetsConfidenceThreshold)
|
||||
{
|
||||
return new VexSuppressionResult(
|
||||
VulnerabilityId: vulnerabilityId,
|
||||
ProductKey: productKey,
|
||||
IsSuppressed: false,
|
||||
Reason: null,
|
||||
Status: statusResult.Status,
|
||||
Justification: statusResult.Justification,
|
||||
ConfidenceScore: statusResult.ConfidenceScore,
|
||||
SuppressedBy: null,
|
||||
SuppressedAt: null);
|
||||
}
|
||||
|
||||
var isSuppressed = statusResult.Status == VexStatus.NotAffected ||
|
||||
statusResult.Status == VexStatus.Fixed;
|
||||
|
||||
VexSuppressionReason? reason = null;
|
||||
if (isSuppressed)
|
||||
{
|
||||
reason = statusResult.Status switch
|
||||
{
|
||||
VexStatus.NotAffected when statusResult.Justification.HasValue =>
|
||||
VexSuppressionReason.JustifiedNotAffected,
|
||||
VexStatus.NotAffected => VexSuppressionReason.NotAffected,
|
||||
VexStatus.Fixed => VexSuppressionReason.Fixed,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
return new VexSuppressionResult(
|
||||
VulnerabilityId: vulnerabilityId,
|
||||
ProductKey: productKey,
|
||||
IsSuppressed: isSuppressed,
|
||||
Reason: reason,
|
||||
Status: statusResult.Status,
|
||||
Justification: statusResult.Justification,
|
||||
ConfidenceScore: statusResult.ConfidenceScore,
|
||||
SuppressedBy: statusResult.Evidence?.PrimaryIssuer,
|
||||
SuppressedAt: statusResult.Evidence?.MostRecentStatement);
|
||||
}
|
||||
|
||||
public async Task<VexAdjustedSeverityResult> GetVexAdjustedSeverityAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
double baseSeverity,
|
||||
PolicyVexContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var statusResult = await GetVexStatusForPolicyAsync(
|
||||
vulnerabilityId,
|
||||
productKey,
|
||||
context,
|
||||
cancellationToken);
|
||||
|
||||
if (!statusResult.HasVexData || !statusResult.MeetsConfidenceThreshold)
|
||||
{
|
||||
return new VexAdjustedSeverityResult(
|
||||
VulnerabilityId: vulnerabilityId,
|
||||
ProductKey: productKey,
|
||||
BaseSeverity: baseSeverity,
|
||||
AdjustedSeverity: baseSeverity,
|
||||
AdjustmentFactor: 1.0,
|
||||
VexStatus: statusResult.Status,
|
||||
AdjustmentReason: "No qualifying VEX data");
|
||||
}
|
||||
|
||||
var (adjustmentFactor, reason) = statusResult.Status switch
|
||||
{
|
||||
VexStatus.NotAffected => (0.0, "VEX indicates not affected"),
|
||||
VexStatus.Fixed => (0.0, "VEX indicates fixed"),
|
||||
VexStatus.Affected => (1.0, "VEX confirms affected"),
|
||||
VexStatus.UnderInvestigation => (0.8, "VEX indicates under investigation"),
|
||||
_ => (1.0, "Unknown VEX status")
|
||||
};
|
||||
|
||||
// Apply confidence scaling
|
||||
var confidenceScale = statusResult.ConfidenceScore ?? 0.5;
|
||||
if (adjustmentFactor < 1.0)
|
||||
{
|
||||
// For suppression, blend toward base severity based on confidence
|
||||
adjustmentFactor = adjustmentFactor + (1.0 - adjustmentFactor) * (1.0 - confidenceScale);
|
||||
}
|
||||
|
||||
var adjustedSeverity = baseSeverity * adjustmentFactor;
|
||||
|
||||
return new VexAdjustedSeverityResult(
|
||||
VulnerabilityId: vulnerabilityId,
|
||||
ProductKey: productKey,
|
||||
BaseSeverity: baseSeverity,
|
||||
AdjustedSeverity: adjustedSeverity,
|
||||
AdjustmentFactor: adjustmentFactor,
|
||||
VexStatus: statusResult.Status,
|
||||
AdjustmentReason: $"{reason} (confidence: {confidenceScale:P0})");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IVulnExplorerIntegration"/>.
|
||||
/// </summary>
|
||||
public sealed class VulnExplorerIntegration : IVulnExplorerIntegration
|
||||
{
|
||||
private readonly IConsensusProjectionStore _projectionStore;
|
||||
|
||||
public VulnExplorerIntegration(IConsensusProjectionStore projectionStore)
|
||||
{
|
||||
_projectionStore = projectionStore;
|
||||
}
|
||||
|
||||
public async Task<VulnVexEnrichment> EnrichVulnerabilityAsync(
|
||||
string vulnerabilityId,
|
||||
string? productKey,
|
||||
VulnVexContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = new ProjectionQuery(
|
||||
TenantId: context.TenantId,
|
||||
VulnerabilityId: vulnerabilityId,
|
||||
ProductKey: productKey,
|
||||
Status: null,
|
||||
Outcome: null,
|
||||
MinimumConfidence: null,
|
||||
ComputedAfter: null,
|
||||
ComputedBefore: null,
|
||||
StatusChanged: null,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
SortBy: ProjectionSortField.ComputedAt,
|
||||
SortDescending: true);
|
||||
|
||||
var result = await _projectionStore.ListAsync(query, cancellationToken);
|
||||
|
||||
if (result.Projections.Count == 0)
|
||||
{
|
||||
return new VulnVexEnrichment(
|
||||
VulnerabilityId: vulnerabilityId,
|
||||
HasVexData: false,
|
||||
ConsensusStatus: null,
|
||||
Justification: null,
|
||||
ConfidenceScore: null,
|
||||
ProductCount: 0,
|
||||
ProductStatuses: [],
|
||||
Issuers: [],
|
||||
LastVexUpdate: null);
|
||||
}
|
||||
|
||||
var productStatuses = result.Projections
|
||||
.GroupBy(p => p.ProductKey)
|
||||
.Select(g => g.First())
|
||||
.Select(p => new ProductVexStatus(
|
||||
ProductKey: p.ProductKey,
|
||||
ProductName: null,
|
||||
Status: p.Status,
|
||||
Justification: p.Justification,
|
||||
ConfidenceScore: p.ConfidenceScore,
|
||||
PrimaryIssuer: null,
|
||||
ComputedAt: p.ComputedAt))
|
||||
.ToList();
|
||||
|
||||
// Determine overall consensus (most common status)
|
||||
var statusCounts = productStatuses
|
||||
.GroupBy(p => p.Status)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var consensusStatus = statusCounts
|
||||
.OrderByDescending(kv => kv.Value)
|
||||
.First().Key;
|
||||
|
||||
var avgConfidence = productStatuses.Average(p => p.ConfidenceScore);
|
||||
var lastUpdate = productStatuses.Max(p => p.ComputedAt);
|
||||
|
||||
return new VulnVexEnrichment(
|
||||
VulnerabilityId: vulnerabilityId,
|
||||
HasVexData: true,
|
||||
ConsensusStatus: consensusStatus,
|
||||
Justification: null,
|
||||
ConfidenceScore: avgConfidence,
|
||||
ProductCount: productStatuses.Count,
|
||||
ProductStatuses: productStatuses,
|
||||
Issuers: [],
|
||||
LastVexUpdate: lastUpdate);
|
||||
}
|
||||
|
||||
public async Task<VexTimelineResult> GetVexTimelineAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
VulnVexContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var history = await _projectionStore.GetHistoryAsync(
|
||||
vulnerabilityId,
|
||||
productKey,
|
||||
context.TenantId,
|
||||
context.HistoryLimit,
|
||||
cancellationToken);
|
||||
|
||||
var entries = new List<VexTimelineEntry>();
|
||||
VexStatus? previousStatus = null;
|
||||
|
||||
foreach (var projection in history.OrderBy(p => p.ComputedAt))
|
||||
{
|
||||
var eventType = previousStatus == null
|
||||
? "initial"
|
||||
: projection.Status != previousStatus
|
||||
? "status_change"
|
||||
: "update";
|
||||
|
||||
entries.Add(new VexTimelineEntry(
|
||||
Timestamp: projection.ComputedAt,
|
||||
Status: projection.Status,
|
||||
Justification: projection.Justification,
|
||||
IssuerId: null,
|
||||
IssuerName: null,
|
||||
EventType: eventType,
|
||||
Notes: projection.RationaleSummary));
|
||||
|
||||
previousStatus = projection.Status;
|
||||
}
|
||||
|
||||
var statusChangeCount = entries.Count(e => e.EventType == "status_change");
|
||||
|
||||
return new VexTimelineResult(
|
||||
VulnerabilityId: vulnerabilityId,
|
||||
ProductKey: productKey,
|
||||
Entries: entries,
|
||||
CurrentStatus: history.FirstOrDefault()?.Status,
|
||||
StatusChangeCount: statusChangeCount);
|
||||
}
|
||||
|
||||
public async Task<VulnVexSummary> GetVexSummaryAsync(
|
||||
string vulnerabilityId,
|
||||
VulnVexContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = new ProjectionQuery(
|
||||
TenantId: context.TenantId,
|
||||
VulnerabilityId: vulnerabilityId,
|
||||
ProductKey: null,
|
||||
Status: null,
|
||||
Outcome: null,
|
||||
MinimumConfidence: null,
|
||||
ComputedAfter: null,
|
||||
ComputedBefore: null,
|
||||
StatusChanged: null,
|
||||
Limit: 1000,
|
||||
Offset: 0,
|
||||
SortBy: ProjectionSortField.ComputedAt,
|
||||
SortDescending: true);
|
||||
|
||||
var result = await _projectionStore.ListAsync(query, cancellationToken);
|
||||
|
||||
if (result.Projections.Count == 0)
|
||||
{
|
||||
return new VulnVexSummary(
|
||||
VulnerabilityId: vulnerabilityId,
|
||||
TotalStatements: 0,
|
||||
TotalProducts: 0,
|
||||
TotalIssuers: 0,
|
||||
StatusCounts: new Dictionary<VexStatus, int>(),
|
||||
JustificationCounts: new Dictionary<VexJustification, int>(),
|
||||
AverageConfidence: 0,
|
||||
FirstVexStatement: null,
|
||||
LatestVexStatement: null);
|
||||
}
|
||||
|
||||
var statusCounts = result.Projections
|
||||
.GroupBy(p => p.Status)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var justificationCounts = result.Projections
|
||||
.Where(p => p.Justification.HasValue)
|
||||
.GroupBy(p => p.Justification!.Value)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var totalStatements = result.Projections.Sum(p => p.StatementCount);
|
||||
var products = result.Projections.Select(p => p.ProductKey).Distinct().Count();
|
||||
var avgConfidence = result.Projections.Average(p => p.ConfidenceScore);
|
||||
var first = result.Projections.Min(p => p.ComputedAt);
|
||||
var latest = result.Projections.Max(p => p.ComputedAt);
|
||||
|
||||
return new VulnVexSummary(
|
||||
VulnerabilityId: vulnerabilityId,
|
||||
TotalStatements: totalStatements,
|
||||
TotalProducts: products,
|
||||
TotalIssuers: 0, // Would need to track in projections
|
||||
StatusCounts: statusCounts,
|
||||
JustificationCounts: justificationCounts,
|
||||
AverageConfidence: avgConfidence,
|
||||
FirstVexStatement: first,
|
||||
LatestVexStatement: latest);
|
||||
}
|
||||
|
||||
public async Task<VexSearchResult> SearchVexAsync(
|
||||
VexSearchQuery searchQuery,
|
||||
VulnVexContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = new ProjectionQuery(
|
||||
TenantId: context.TenantId,
|
||||
VulnerabilityId: searchQuery.VulnerabilityIdPattern,
|
||||
ProductKey: searchQuery.ProductKeyPattern,
|
||||
Status: searchQuery.Status,
|
||||
Outcome: null,
|
||||
MinimumConfidence: searchQuery.MinimumConfidence,
|
||||
ComputedAfter: searchQuery.UpdatedAfter,
|
||||
ComputedBefore: null,
|
||||
StatusChanged: null,
|
||||
Limit: searchQuery.Limit,
|
||||
Offset: searchQuery.Offset,
|
||||
SortBy: ProjectionSortField.ComputedAt,
|
||||
SortDescending: true);
|
||||
|
||||
var result = await _projectionStore.ListAsync(query, cancellationToken);
|
||||
|
||||
var hits = result.Projections.Select(p => new VexSearchHit(
|
||||
VulnerabilityId: p.VulnerabilityId,
|
||||
ProductKey: p.ProductKey,
|
||||
Status: p.Status,
|
||||
Justification: p.Justification,
|
||||
ConfidenceScore: p.ConfidenceScore,
|
||||
PrimaryIssuer: null,
|
||||
ComputedAt: p.ComputedAt)).ToList();
|
||||
|
||||
return new VexSearchResult(
|
||||
Hits: hits,
|
||||
TotalCount: result.TotalCount,
|
||||
Offset: result.Offset,
|
||||
Limit: result.Limit);
|
||||
}
|
||||
}
|
||||
331
src/VexLens/StellaOps.VexLens/Mapping/CpeParser.cs
Normal file
331
src/VexLens/StellaOps.VexLens/Mapping/CpeParser.cs
Normal file
@@ -0,0 +1,331 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.VexLens.Mapping;
|
||||
|
||||
/// <summary>
|
||||
/// Parser for Common Platform Enumeration (CPE) identifiers.
|
||||
/// Supports both CPE 2.2 (URI binding) and CPE 2.3 (formatted string binding).
|
||||
/// </summary>
|
||||
public static partial class CpeParser
|
||||
{
|
||||
// CPE 2.3 formatted string: cpe:2.3:part:vendor:product:version:update:edition:language:sw_edition:target_sw:target_hw:other
|
||||
[GeneratedRegex(
|
||||
@"^cpe:2\.3:([aho\*\-]):([^:]*):([^:]*):([^:]*):([^:]*):([^:]*):([^:]*):([^:]*):([^:]*):([^:]*):([^:]*)$",
|
||||
RegexOptions.Compiled)]
|
||||
private static partial Regex Cpe23Regex();
|
||||
|
||||
// CPE 2.2 URI: cpe:/part:vendor:product:version:update:edition:language
|
||||
[GeneratedRegex(
|
||||
@"^cpe:/([aho]):([^:]*):([^:]*):([^:]*)?:?([^:]*)?:?([^:]*)?:?([^:]*)?$",
|
||||
RegexOptions.Compiled)]
|
||||
private static partial Regex Cpe22Regex();
|
||||
|
||||
private const string Wildcard = "*";
|
||||
private const string Na = "-";
|
||||
|
||||
/// <summary>
|
||||
/// Parses a CPE string (2.2 or 2.3 format) into its components.
|
||||
/// </summary>
|
||||
public static CpeParseResult Parse(string? cpe)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cpe))
|
||||
{
|
||||
return CpeParseResult.Failed("CPE cannot be null or empty");
|
||||
}
|
||||
|
||||
cpe = cpe.Trim();
|
||||
|
||||
// Try CPE 2.3 first
|
||||
var match23 = Cpe23Regex().Match(cpe);
|
||||
if (match23.Success)
|
||||
{
|
||||
return ParseCpe23(match23, cpe);
|
||||
}
|
||||
|
||||
// Try CPE 2.2
|
||||
var match22 = Cpe22Regex().Match(cpe);
|
||||
if (match22.Success)
|
||||
{
|
||||
return ParseCpe22(match22, cpe);
|
||||
}
|
||||
|
||||
return CpeParseResult.Failed("Invalid CPE format");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates if a string is a valid CPE.
|
||||
/// </summary>
|
||||
public static bool IsValid(string? cpe)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cpe))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Cpe23Regex().IsMatch(cpe) || Cpe22Regex().IsMatch(cpe);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a CPE to 2.3 formatted string format.
|
||||
/// </summary>
|
||||
public static string? ToCpe23(string? cpe)
|
||||
{
|
||||
var result = Parse(cpe);
|
||||
if (!result.Success || result.Cpe == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildCpe23(result.Cpe);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a CPE to 2.2 URI format.
|
||||
/// </summary>
|
||||
public static string? ToCpe22(string? cpe)
|
||||
{
|
||||
var result = Parse(cpe);
|
||||
if (!result.Success || result.Cpe == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildCpe22(result.Cpe);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if two CPEs match (with wildcard support).
|
||||
/// </summary>
|
||||
public static bool Matches(string? cpe1, string? cpe2)
|
||||
{
|
||||
var result1 = Parse(cpe1);
|
||||
var result2 = Parse(cpe2);
|
||||
|
||||
if (!result1.Success || !result2.Success)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var c1 = result1.Cpe!;
|
||||
var c2 = result2.Cpe!;
|
||||
|
||||
return MatchComponent(c1.Part, c2.Part) &&
|
||||
MatchComponent(c1.Vendor, c2.Vendor) &&
|
||||
MatchComponent(c1.Product, c2.Product) &&
|
||||
MatchComponent(c1.Version, c2.Version) &&
|
||||
MatchComponent(c1.Update, c2.Update) &&
|
||||
MatchComponent(c1.Edition, c2.Edition) &&
|
||||
MatchComponent(c1.Language, c2.Language) &&
|
||||
MatchComponent(c1.SwEdition, c2.SwEdition) &&
|
||||
MatchComponent(c1.TargetSw, c2.TargetSw) &&
|
||||
MatchComponent(c1.TargetHw, c2.TargetHw) &&
|
||||
MatchComponent(c1.Other, c2.Other);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if two CPEs refer to the same product (ignoring version).
|
||||
/// </summary>
|
||||
public static bool IsSameProduct(string? cpe1, string? cpe2)
|
||||
{
|
||||
var result1 = Parse(cpe1);
|
||||
var result2 = Parse(cpe2);
|
||||
|
||||
if (!result1.Success || !result2.Success)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var c1 = result1.Cpe!;
|
||||
var c2 = result2.Cpe!;
|
||||
|
||||
return string.Equals(c1.Part, c2.Part, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(c1.Vendor, c2.Vendor, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(c1.Product, c2.Product, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static CpeParseResult ParseCpe23(Match match, string raw)
|
||||
{
|
||||
var cpe = new CommonPlatformEnumeration(
|
||||
CpeVersion: "2.3",
|
||||
Part: NormalizeComponent(match.Groups[1].Value),
|
||||
Vendor: NormalizeComponent(match.Groups[2].Value),
|
||||
Product: NormalizeComponent(match.Groups[3].Value),
|
||||
Version: NormalizeComponent(match.Groups[4].Value),
|
||||
Update: NormalizeComponent(match.Groups[5].Value),
|
||||
Edition: NormalizeComponent(match.Groups[6].Value),
|
||||
Language: NormalizeComponent(match.Groups[7].Value),
|
||||
SwEdition: NormalizeComponent(match.Groups[8].Value),
|
||||
TargetSw: NormalizeComponent(match.Groups[9].Value),
|
||||
TargetHw: NormalizeComponent(match.Groups[10].Value),
|
||||
Other: NormalizeComponent(match.Groups[11].Value),
|
||||
Raw: raw);
|
||||
|
||||
return CpeParseResult.Successful(cpe);
|
||||
}
|
||||
|
||||
private static CpeParseResult ParseCpe22(Match match, string raw)
|
||||
{
|
||||
var cpe = new CommonPlatformEnumeration(
|
||||
CpeVersion: "2.2",
|
||||
Part: NormalizeComponent(match.Groups[1].Value),
|
||||
Vendor: NormalizeComponent(match.Groups[2].Value),
|
||||
Product: NormalizeComponent(match.Groups[3].Value),
|
||||
Version: NormalizeComponent(match.Groups[4].Success ? match.Groups[4].Value : Wildcard),
|
||||
Update: NormalizeComponent(match.Groups[5].Success ? match.Groups[5].Value : Wildcard),
|
||||
Edition: NormalizeComponent(match.Groups[6].Success ? match.Groups[6].Value : Wildcard),
|
||||
Language: NormalizeComponent(match.Groups[7].Success ? match.Groups[7].Value : Wildcard),
|
||||
SwEdition: Wildcard,
|
||||
TargetSw: Wildcard,
|
||||
TargetHw: Wildcard,
|
||||
Other: Wildcard,
|
||||
Raw: raw);
|
||||
|
||||
return CpeParseResult.Successful(cpe);
|
||||
}
|
||||
|
||||
private static string NormalizeComponent(string component)
|
||||
{
|
||||
if (string.IsNullOrEmpty(component))
|
||||
{
|
||||
return Wildcard;
|
||||
}
|
||||
|
||||
// Decode percent-encoded characters
|
||||
var decoded = Uri.UnescapeDataString(component);
|
||||
|
||||
// Replace escaped characters
|
||||
decoded = decoded
|
||||
.Replace("\\:", ":")
|
||||
.Replace("\\;", ";")
|
||||
.Replace("\\@", "@");
|
||||
|
||||
return decoded.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static bool MatchComponent(string c1, string c2)
|
||||
{
|
||||
// Wildcard matches everything
|
||||
if (c1 == Wildcard || c2 == Wildcard)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// NA only matches NA
|
||||
if (c1 == Na || c2 == Na)
|
||||
{
|
||||
return c1 == Na && c2 == Na;
|
||||
}
|
||||
|
||||
return string.Equals(c1, c2, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string BuildCpe23(CommonPlatformEnumeration cpe)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("cpe:2.3:");
|
||||
sb.Append(EscapeComponent(cpe.Part));
|
||||
sb.Append(':');
|
||||
sb.Append(EscapeComponent(cpe.Vendor));
|
||||
sb.Append(':');
|
||||
sb.Append(EscapeComponent(cpe.Product));
|
||||
sb.Append(':');
|
||||
sb.Append(EscapeComponent(cpe.Version));
|
||||
sb.Append(':');
|
||||
sb.Append(EscapeComponent(cpe.Update));
|
||||
sb.Append(':');
|
||||
sb.Append(EscapeComponent(cpe.Edition));
|
||||
sb.Append(':');
|
||||
sb.Append(EscapeComponent(cpe.Language));
|
||||
sb.Append(':');
|
||||
sb.Append(EscapeComponent(cpe.SwEdition));
|
||||
sb.Append(':');
|
||||
sb.Append(EscapeComponent(cpe.TargetSw));
|
||||
sb.Append(':');
|
||||
sb.Append(EscapeComponent(cpe.TargetHw));
|
||||
sb.Append(':');
|
||||
sb.Append(EscapeComponent(cpe.Other));
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string BuildCpe22(CommonPlatformEnumeration cpe)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("cpe:/");
|
||||
sb.Append(cpe.Part);
|
||||
sb.Append(':');
|
||||
sb.Append(EscapeComponent22(cpe.Vendor));
|
||||
sb.Append(':');
|
||||
sb.Append(EscapeComponent22(cpe.Product));
|
||||
|
||||
if (cpe.Version != Wildcard)
|
||||
{
|
||||
sb.Append(':');
|
||||
sb.Append(EscapeComponent22(cpe.Version));
|
||||
}
|
||||
|
||||
if (cpe.Update != Wildcard)
|
||||
{
|
||||
sb.Append(':');
|
||||
sb.Append(EscapeComponent22(cpe.Update));
|
||||
}
|
||||
|
||||
if (cpe.Edition != Wildcard)
|
||||
{
|
||||
sb.Append(':');
|
||||
sb.Append(EscapeComponent22(cpe.Edition));
|
||||
}
|
||||
|
||||
if (cpe.Language != Wildcard)
|
||||
{
|
||||
sb.Append(':');
|
||||
sb.Append(EscapeComponent22(cpe.Language));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string EscapeComponent(string component)
|
||||
{
|
||||
if (component == Wildcard || component == Na)
|
||||
{
|
||||
return component;
|
||||
}
|
||||
|
||||
return component
|
||||
.Replace(":", "\\:")
|
||||
.Replace(";", "\\;")
|
||||
.Replace("@", "\\@");
|
||||
}
|
||||
|
||||
private static string EscapeComponent22(string component)
|
||||
{
|
||||
if (component == Wildcard)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
if (component == Na)
|
||||
{
|
||||
return "-";
|
||||
}
|
||||
|
||||
return Uri.EscapeDataString(component);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of CPE parsing.
|
||||
/// </summary>
|
||||
public sealed record CpeParseResult(
|
||||
bool Success,
|
||||
CommonPlatformEnumeration? Cpe,
|
||||
string? ErrorMessage)
|
||||
{
|
||||
public static CpeParseResult Successful(CommonPlatformEnumeration cpe) =>
|
||||
new(true, cpe, null);
|
||||
|
||||
public static CpeParseResult Failed(string error) =>
|
||||
new(false, null, error);
|
||||
}
|
||||
169
src/VexLens/StellaOps.VexLens/Mapping/IProductMapper.cs
Normal file
169
src/VexLens/StellaOps.VexLens/Mapping/IProductMapper.cs
Normal file
@@ -0,0 +1,169 @@
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Mapping;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for product identity mapping services.
|
||||
/// Maps product references from various sources to canonical identifiers.
|
||||
/// </summary>
|
||||
public interface IProductMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps a normalized product to a canonical identity.
|
||||
/// </summary>
|
||||
Task<ProductMappingResult> MapAsync(
|
||||
NormalizedProduct product,
|
||||
ProductMappingContext context,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Batch maps multiple products to canonical identities.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ProductMappingResult>> MapBatchAsync(
|
||||
IEnumerable<NormalizedProduct> products,
|
||||
ProductMappingContext context,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves product aliases (e.g., maps one PURL to equivalent PURLs).
|
||||
/// </summary>
|
||||
Task<ProductAliasResult> ResolveAliasesAsync(
|
||||
string identifier,
|
||||
ProductIdentifierType identifierType,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context for product mapping operations.
|
||||
/// </summary>
|
||||
public sealed record ProductMappingContext(
|
||||
string? TenantId,
|
||||
bool ResolveAliases,
|
||||
bool ValidateIdentifiers,
|
||||
IReadOnlyDictionary<string, object?>? Options);
|
||||
|
||||
/// <summary>
|
||||
/// Result of a product mapping operation.
|
||||
/// </summary>
|
||||
public sealed record ProductMappingResult(
|
||||
NormalizedProduct OriginalProduct,
|
||||
CanonicalProduct? CanonicalProduct,
|
||||
bool Success,
|
||||
ProductMappingConfidence Confidence,
|
||||
IReadOnlyList<string>? Warnings,
|
||||
IReadOnlyList<ProductMappingError>? Errors);
|
||||
|
||||
/// <summary>
|
||||
/// A canonicalized product identity with validated identifiers.
|
||||
/// </summary>
|
||||
public sealed record CanonicalProduct(
|
||||
string CanonicalKey,
|
||||
string? Name,
|
||||
string? Version,
|
||||
PackageUrl? Purl,
|
||||
CommonPlatformEnumeration? Cpe,
|
||||
IReadOnlyList<ProductAlias>? Aliases,
|
||||
ProductVendorInfo? Vendor,
|
||||
IReadOnlyDictionary<string, string>? Hashes);
|
||||
|
||||
/// <summary>
|
||||
/// Parsed Package URL (PURL) components.
|
||||
/// </summary>
|
||||
public sealed record PackageUrl(
|
||||
string Type,
|
||||
string? Namespace,
|
||||
string Name,
|
||||
string? Version,
|
||||
IReadOnlyDictionary<string, string>? Qualifiers,
|
||||
string? Subpath,
|
||||
string Raw);
|
||||
|
||||
/// <summary>
|
||||
/// Parsed Common Platform Enumeration (CPE) components.
|
||||
/// </summary>
|
||||
public sealed record CommonPlatformEnumeration(
|
||||
string CpeVersion,
|
||||
string Part,
|
||||
string Vendor,
|
||||
string Product,
|
||||
string Version,
|
||||
string Update,
|
||||
string Edition,
|
||||
string Language,
|
||||
string SwEdition,
|
||||
string TargetSw,
|
||||
string TargetHw,
|
||||
string Other,
|
||||
string Raw);
|
||||
|
||||
/// <summary>
|
||||
/// Product alias linking different identifier systems.
|
||||
/// </summary>
|
||||
public sealed record ProductAlias(
|
||||
ProductIdentifierType Type,
|
||||
string Value,
|
||||
ProductAliasSource Source);
|
||||
|
||||
/// <summary>
|
||||
/// Source of a product alias mapping.
|
||||
/// </summary>
|
||||
public enum ProductAliasSource
|
||||
{
|
||||
VexDocument,
|
||||
SbomDocument,
|
||||
VendorMapping,
|
||||
CommunityMapping,
|
||||
NvdMapping,
|
||||
Inferred
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vendor information for a product.
|
||||
/// </summary>
|
||||
public sealed record ProductVendorInfo(
|
||||
string VendorId,
|
||||
string? Name,
|
||||
string? Uri);
|
||||
|
||||
/// <summary>
|
||||
/// Type of product identifier.
|
||||
/// </summary>
|
||||
public enum ProductIdentifierType
|
||||
{
|
||||
Purl,
|
||||
Cpe,
|
||||
Swid,
|
||||
BomRef,
|
||||
VendorProductId,
|
||||
Custom
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level in product mapping.
|
||||
/// </summary>
|
||||
public enum ProductMappingConfidence
|
||||
{
|
||||
Exact,
|
||||
High,
|
||||
Medium,
|
||||
Low,
|
||||
Unknown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error during product mapping.
|
||||
/// </summary>
|
||||
public sealed record ProductMappingError(
|
||||
string Code,
|
||||
string Message,
|
||||
string? Field);
|
||||
|
||||
/// <summary>
|
||||
/// Result of product alias resolution.
|
||||
/// </summary>
|
||||
public sealed record ProductAliasResult(
|
||||
string OriginalIdentifier,
|
||||
ProductIdentifierType OriginalType,
|
||||
IReadOnlyList<ProductAlias> Aliases,
|
||||
bool Success,
|
||||
IReadOnlyList<string>? Warnings);
|
||||
259
src/VexLens/StellaOps.VexLens/Mapping/ProductIdentityMatcher.cs
Normal file
259
src/VexLens/StellaOps.VexLens/Mapping/ProductIdentityMatcher.cs
Normal file
@@ -0,0 +1,259 @@
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Mapping;
|
||||
|
||||
/// <summary>
|
||||
/// Utility for matching and comparing product identities across different identifier types.
|
||||
/// </summary>
|
||||
public static class ProductIdentityMatcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks if two products are equivalent based on their identifiers.
|
||||
/// </summary>
|
||||
public static ProductMatchResult Match(NormalizedProduct product1, NormalizedProduct product2)
|
||||
{
|
||||
var matches = new List<ProductMatchEvidence>();
|
||||
|
||||
// Check PURL match
|
||||
if (!string.IsNullOrEmpty(product1.Purl) && !string.IsNullOrEmpty(product2.Purl))
|
||||
{
|
||||
if (PurlParser.IsSamePackage(product1.Purl, product2.Purl))
|
||||
{
|
||||
var versionMatch = CheckVersionMatch(
|
||||
PurlParser.Parse(product1.Purl).PackageUrl?.Version,
|
||||
PurlParser.Parse(product2.Purl).PackageUrl?.Version);
|
||||
|
||||
matches.Add(new ProductMatchEvidence(
|
||||
MatchType: ProductMatchType.Purl,
|
||||
Confidence: versionMatch ? MatchConfidence.Exact : MatchConfidence.PackageOnly,
|
||||
Evidence: $"PURL match: {product1.Purl} ≈ {product2.Purl}"));
|
||||
}
|
||||
}
|
||||
|
||||
// Check CPE match
|
||||
if (!string.IsNullOrEmpty(product1.Cpe) && !string.IsNullOrEmpty(product2.Cpe))
|
||||
{
|
||||
if (CpeParser.IsSameProduct(product1.Cpe, product2.Cpe))
|
||||
{
|
||||
var cpe1 = CpeParser.Parse(product1.Cpe).Cpe;
|
||||
var cpe2 = CpeParser.Parse(product2.Cpe).Cpe;
|
||||
var versionMatch = cpe1?.Version == cpe2?.Version && cpe1?.Version != "*";
|
||||
|
||||
matches.Add(new ProductMatchEvidence(
|
||||
MatchType: ProductMatchType.Cpe,
|
||||
Confidence: versionMatch ? MatchConfidence.Exact : MatchConfidence.PackageOnly,
|
||||
Evidence: $"CPE match: {product1.Cpe} ≈ {product2.Cpe}"));
|
||||
}
|
||||
}
|
||||
|
||||
// Check key match
|
||||
if (!string.IsNullOrEmpty(product1.Key) && !string.IsNullOrEmpty(product2.Key))
|
||||
{
|
||||
if (string.Equals(product1.Key, product2.Key, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
matches.Add(new ProductMatchEvidence(
|
||||
MatchType: ProductMatchType.Key,
|
||||
Confidence: MatchConfidence.Exact,
|
||||
Evidence: $"Key match: {product1.Key}"));
|
||||
}
|
||||
}
|
||||
|
||||
// Check name + version match
|
||||
if (!string.IsNullOrEmpty(product1.Name) && !string.IsNullOrEmpty(product2.Name))
|
||||
{
|
||||
if (string.Equals(product1.Name, product2.Name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var versionMatch = CheckVersionMatch(product1.Version, product2.Version);
|
||||
matches.Add(new ProductMatchEvidence(
|
||||
MatchType: ProductMatchType.NameVersion,
|
||||
Confidence: versionMatch ? MatchConfidence.Exact : MatchConfidence.PackageOnly,
|
||||
Evidence: $"Name match: {product1.Name}" + (versionMatch ? $" @ {product1.Version}" : "")));
|
||||
}
|
||||
}
|
||||
|
||||
// Check hash match
|
||||
if (product1.Hashes != null && product2.Hashes != null)
|
||||
{
|
||||
foreach (var (alg, hash1) in product1.Hashes)
|
||||
{
|
||||
if (product2.Hashes.TryGetValue(alg, out var hash2))
|
||||
{
|
||||
if (string.Equals(hash1, hash2, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
matches.Add(new ProductMatchEvidence(
|
||||
MatchType: ProductMatchType.Hash,
|
||||
Confidence: MatchConfidence.Exact,
|
||||
Evidence: $"Hash match ({alg}): {hash1}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine overall match result
|
||||
var overallConfidence = matches.Count > 0
|
||||
? matches.Max(m => m.Confidence)
|
||||
: MatchConfidence.None;
|
||||
|
||||
return new ProductMatchResult(
|
||||
IsMatch: matches.Count > 0,
|
||||
OverallConfidence: overallConfidence,
|
||||
Evidence: matches);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds matching products in a collection.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<ProductMatchResult> FindMatches(
|
||||
NormalizedProduct target,
|
||||
IEnumerable<NormalizedProduct> candidates,
|
||||
MatchConfidence minimumConfidence = MatchConfidence.PackageOnly)
|
||||
{
|
||||
var results = new List<ProductMatchResult>();
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var matchResult = Match(target, candidate);
|
||||
if (matchResult.IsMatch && matchResult.OverallConfidence >= minimumConfidence)
|
||||
{
|
||||
results.Add(matchResult with { MatchedProduct = candidate });
|
||||
}
|
||||
}
|
||||
|
||||
return results.OrderByDescending(r => r.OverallConfidence).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a similarity score between two products (0.0 to 1.0).
|
||||
/// </summary>
|
||||
public static double ComputeSimilarity(NormalizedProduct product1, NormalizedProduct product2)
|
||||
{
|
||||
var matchResult = Match(product1, product2);
|
||||
|
||||
if (!matchResult.IsMatch)
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return matchResult.OverallConfidence switch
|
||||
{
|
||||
MatchConfidence.Exact => 1.0,
|
||||
MatchConfidence.PackageOnly => 0.8,
|
||||
MatchConfidence.Fuzzy => 0.5,
|
||||
MatchConfidence.Partial => 0.3,
|
||||
_ => 0.0
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects the identifier type from a string.
|
||||
/// </summary>
|
||||
public static ProductIdentifierType? DetectIdentifierType(string? identifier)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(identifier))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (identifier.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return PurlParser.IsValid(identifier) ? ProductIdentifierType.Purl : null;
|
||||
}
|
||||
|
||||
if (identifier.StartsWith("cpe:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return CpeParser.IsValid(identifier) ? ProductIdentifierType.Cpe : null;
|
||||
}
|
||||
|
||||
if (identifier.StartsWith("swid:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ProductIdentifierType.Swid;
|
||||
}
|
||||
|
||||
// Could be a bom-ref or vendor product ID
|
||||
return ProductIdentifierType.Custom;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts all identifiers from a product.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<(ProductIdentifierType Type, string Value)> ExtractIdentifiers(NormalizedProduct product)
|
||||
{
|
||||
var identifiers = new List<(ProductIdentifierType, string)>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(product.Purl))
|
||||
{
|
||||
identifiers.Add((ProductIdentifierType.Purl, product.Purl));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(product.Cpe))
|
||||
{
|
||||
identifiers.Add((ProductIdentifierType.Cpe, product.Cpe));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(product.Key))
|
||||
{
|
||||
var keyType = DetectIdentifierType(product.Key);
|
||||
if (keyType.HasValue && keyType.Value != ProductIdentifierType.Purl && keyType.Value != ProductIdentifierType.Cpe)
|
||||
{
|
||||
identifiers.Add((keyType.Value, product.Key));
|
||||
}
|
||||
else if (keyType == null)
|
||||
{
|
||||
identifiers.Add((ProductIdentifierType.Custom, product.Key));
|
||||
}
|
||||
}
|
||||
|
||||
return identifiers;
|
||||
}
|
||||
|
||||
private static bool CheckVersionMatch(string? version1, string? version2)
|
||||
{
|
||||
if (string.IsNullOrEmpty(version1) || string.IsNullOrEmpty(version2))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(version1, version2, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a product match operation.
|
||||
/// </summary>
|
||||
public sealed record ProductMatchResult(
|
||||
bool IsMatch,
|
||||
MatchConfidence OverallConfidence,
|
||||
IReadOnlyList<ProductMatchEvidence> Evidence,
|
||||
NormalizedProduct? MatchedProduct = null);
|
||||
|
||||
/// <summary>
|
||||
/// Evidence supporting a product match.
|
||||
/// </summary>
|
||||
public sealed record ProductMatchEvidence(
|
||||
ProductMatchType MatchType,
|
||||
MatchConfidence Confidence,
|
||||
string Evidence);
|
||||
|
||||
/// <summary>
|
||||
/// Type of product match.
|
||||
/// </summary>
|
||||
public enum ProductMatchType
|
||||
{
|
||||
Purl,
|
||||
Cpe,
|
||||
Key,
|
||||
NameVersion,
|
||||
Hash
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level of a match.
|
||||
/// </summary>
|
||||
public enum MatchConfidence
|
||||
{
|
||||
None = 0,
|
||||
Partial = 1,
|
||||
Fuzzy = 2,
|
||||
PackageOnly = 3,
|
||||
Exact = 4
|
||||
}
|
||||
301
src/VexLens/StellaOps.VexLens/Mapping/ProductMapper.cs
Normal file
301
src/VexLens/StellaOps.VexLens/Mapping/ProductMapper.cs
Normal file
@@ -0,0 +1,301 @@
|
||||
using System.Text;
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Mapping;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IProductMapper"/>.
|
||||
/// Maps normalized products to canonical identities using PURL and CPE parsing.
|
||||
/// </summary>
|
||||
public sealed class ProductMapper : IProductMapper
|
||||
{
|
||||
private readonly IProductAliasResolver? _aliasResolver;
|
||||
|
||||
public ProductMapper(IProductAliasResolver? aliasResolver = null)
|
||||
{
|
||||
_aliasResolver = aliasResolver;
|
||||
}
|
||||
|
||||
public async Task<ProductMappingResult> MapAsync(
|
||||
NormalizedProduct product,
|
||||
ProductMappingContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
var errors = new List<ProductMappingError>();
|
||||
|
||||
PackageUrl? parsedPurl = null;
|
||||
CommonPlatformEnumeration? parsedCpe = null;
|
||||
var aliases = new List<ProductAlias>();
|
||||
|
||||
// Parse PURL if present
|
||||
if (!string.IsNullOrWhiteSpace(product.Purl))
|
||||
{
|
||||
var purlResult = PurlParser.Parse(product.Purl);
|
||||
if (purlResult.Success)
|
||||
{
|
||||
parsedPurl = purlResult.PackageUrl;
|
||||
}
|
||||
else if (context.ValidateIdentifiers)
|
||||
{
|
||||
warnings.Add($"Invalid PURL format: {purlResult.ErrorMessage}");
|
||||
}
|
||||
}
|
||||
|
||||
// Parse CPE if present
|
||||
if (!string.IsNullOrWhiteSpace(product.Cpe))
|
||||
{
|
||||
var cpeResult = CpeParser.Parse(product.Cpe);
|
||||
if (cpeResult.Success)
|
||||
{
|
||||
parsedCpe = cpeResult.Cpe;
|
||||
}
|
||||
else if (context.ValidateIdentifiers)
|
||||
{
|
||||
warnings.Add($"Invalid CPE format: {cpeResult.ErrorMessage}");
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve aliases if requested
|
||||
if (context.ResolveAliases && _aliasResolver != null)
|
||||
{
|
||||
if (parsedPurl != null)
|
||||
{
|
||||
var purlAliases = await _aliasResolver.ResolveAsync(
|
||||
product.Purl!,
|
||||
ProductIdentifierType.Purl,
|
||||
cancellationToken);
|
||||
aliases.AddRange(purlAliases);
|
||||
}
|
||||
|
||||
if (parsedCpe != null)
|
||||
{
|
||||
var cpeAliases = await _aliasResolver.ResolveAsync(
|
||||
product.Cpe!,
|
||||
ProductIdentifierType.Cpe,
|
||||
cancellationToken);
|
||||
aliases.AddRange(cpeAliases);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine canonical key
|
||||
var canonicalKey = DetermineCanonicalKey(product, parsedPurl, parsedCpe);
|
||||
|
||||
// Determine mapping confidence
|
||||
var confidence = DetermineConfidence(product, parsedPurl, parsedCpe);
|
||||
|
||||
// Extract vendor info
|
||||
var vendor = ExtractVendorInfo(product, parsedPurl, parsedCpe);
|
||||
|
||||
var canonicalProduct = new CanonicalProduct(
|
||||
CanonicalKey: canonicalKey,
|
||||
Name: product.Name ?? parsedPurl?.Name ?? parsedCpe?.Product,
|
||||
Version: product.Version ?? parsedPurl?.Version ?? parsedCpe?.Version,
|
||||
Purl: parsedPurl,
|
||||
Cpe: parsedCpe,
|
||||
Aliases: aliases.Count > 0 ? aliases : null,
|
||||
Vendor: vendor,
|
||||
Hashes: product.Hashes);
|
||||
|
||||
return new ProductMappingResult(
|
||||
OriginalProduct: product,
|
||||
CanonicalProduct: canonicalProduct,
|
||||
Success: true,
|
||||
Confidence: confidence,
|
||||
Warnings: warnings.Count > 0 ? warnings : null,
|
||||
Errors: errors.Count > 0 ? errors : null);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ProductMappingResult>> MapBatchAsync(
|
||||
IEnumerable<NormalizedProduct> products,
|
||||
ProductMappingContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = new List<ProductMappingResult>();
|
||||
|
||||
foreach (var product in products)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var result = await MapAsync(product, context, cancellationToken);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<ProductAliasResult> ResolveAliasesAsync(
|
||||
string identifier,
|
||||
ProductIdentifierType identifierType,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_aliasResolver == null)
|
||||
{
|
||||
return new ProductAliasResult(
|
||||
OriginalIdentifier: identifier,
|
||||
OriginalType: identifierType,
|
||||
Aliases: [],
|
||||
Success: true,
|
||||
Warnings: ["No alias resolver configured"]);
|
||||
}
|
||||
|
||||
var aliases = await _aliasResolver.ResolveAsync(identifier, identifierType, cancellationToken);
|
||||
|
||||
return new ProductAliasResult(
|
||||
OriginalIdentifier: identifier,
|
||||
OriginalType: identifierType,
|
||||
Aliases: aliases,
|
||||
Success: true,
|
||||
Warnings: null);
|
||||
}
|
||||
|
||||
private static string DetermineCanonicalKey(
|
||||
NormalizedProduct product,
|
||||
PackageUrl? purl,
|
||||
CommonPlatformEnumeration? cpe)
|
||||
{
|
||||
// Prefer PURL as canonical key (most precise)
|
||||
if (purl != null)
|
||||
{
|
||||
return PurlParser.Build(purl);
|
||||
}
|
||||
|
||||
// Fall back to CPE 2.3 format
|
||||
if (cpe != null)
|
||||
{
|
||||
return CpeParser.ToCpe23(cpe.Raw) ?? cpe.Raw;
|
||||
}
|
||||
|
||||
// Use original key
|
||||
return product.Key;
|
||||
}
|
||||
|
||||
private static ProductMappingConfidence DetermineConfidence(
|
||||
NormalizedProduct product,
|
||||
PackageUrl? purl,
|
||||
CommonPlatformEnumeration? cpe)
|
||||
{
|
||||
// Exact match if we have both PURL and version
|
||||
if (purl != null && !string.IsNullOrEmpty(purl.Version))
|
||||
{
|
||||
return ProductMappingConfidence.Exact;
|
||||
}
|
||||
|
||||
// High confidence with CPE and version
|
||||
if (cpe != null && cpe.Version != "*")
|
||||
{
|
||||
return ProductMappingConfidence.High;
|
||||
}
|
||||
|
||||
// High confidence with PURL but no version
|
||||
if (purl != null)
|
||||
{
|
||||
return ProductMappingConfidence.High;
|
||||
}
|
||||
|
||||
// Medium confidence with CPE
|
||||
if (cpe != null)
|
||||
{
|
||||
return ProductMappingConfidence.Medium;
|
||||
}
|
||||
|
||||
// Low confidence if we have name but no identifiers
|
||||
if (!string.IsNullOrEmpty(product.Name))
|
||||
{
|
||||
return ProductMappingConfidence.Low;
|
||||
}
|
||||
|
||||
// Unknown if we only have a key
|
||||
return ProductMappingConfidence.Unknown;
|
||||
}
|
||||
|
||||
private static ProductVendorInfo? ExtractVendorInfo(
|
||||
NormalizedProduct product,
|
||||
PackageUrl? purl,
|
||||
CommonPlatformEnumeration? cpe)
|
||||
{
|
||||
// Try to extract vendor from CPE
|
||||
if (cpe != null && cpe.Vendor != "*" && cpe.Vendor != "-")
|
||||
{
|
||||
return new ProductVendorInfo(
|
||||
VendorId: cpe.Vendor,
|
||||
Name: FormatVendorName(cpe.Vendor),
|
||||
Uri: null);
|
||||
}
|
||||
|
||||
// Try to extract vendor from PURL namespace
|
||||
if (purl != null && !string.IsNullOrEmpty(purl.Namespace))
|
||||
{
|
||||
return new ProductVendorInfo(
|
||||
VendorId: purl.Namespace,
|
||||
Name: purl.Namespace,
|
||||
Uri: null);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string FormatVendorName(string vendorId)
|
||||
{
|
||||
// Convert vendor_name to Vendor Name
|
||||
return string.Join(' ', vendorId
|
||||
.Split('_', '-')
|
||||
.Select(s => char.ToUpperInvariant(s[0]) + s[1..]));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for resolving product aliases.
|
||||
/// </summary>
|
||||
public interface IProductAliasResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves aliases for a product identifier.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ProductAlias>> ResolveAsync(
|
||||
string identifier,
|
||||
ProductIdentifierType identifierType,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory product alias resolver for testing and basic usage.
|
||||
/// </summary>
|
||||
public sealed class InMemoryProductAliasResolver : IProductAliasResolver
|
||||
{
|
||||
private readonly Dictionary<string, List<ProductAlias>> _aliases = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public void AddAlias(string identifier, ProductAlias alias)
|
||||
{
|
||||
if (!_aliases.TryGetValue(identifier, out var list))
|
||||
{
|
||||
list = [];
|
||||
_aliases[identifier] = list;
|
||||
}
|
||||
|
||||
list.Add(alias);
|
||||
}
|
||||
|
||||
public void AddBidirectionalAlias(
|
||||
string identifier1,
|
||||
ProductIdentifierType type1,
|
||||
string identifier2,
|
||||
ProductIdentifierType type2,
|
||||
ProductAliasSource source)
|
||||
{
|
||||
AddAlias(identifier1, new ProductAlias(type2, identifier2, source));
|
||||
AddAlias(identifier2, new ProductAlias(type1, identifier1, source));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ProductAlias>> ResolveAsync(
|
||||
string identifier,
|
||||
ProductIdentifierType identifierType,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_aliases.TryGetValue(identifier, out var aliases))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<ProductAlias>>(aliases);
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ProductAlias>>([]);
|
||||
}
|
||||
}
|
||||
253
src/VexLens/StellaOps.VexLens/Mapping/PurlParser.cs
Normal file
253
src/VexLens/StellaOps.VexLens/Mapping/PurlParser.cs
Normal file
@@ -0,0 +1,253 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Web;
|
||||
|
||||
namespace StellaOps.VexLens.Mapping;
|
||||
|
||||
/// <summary>
|
||||
/// Parser for Package URL (PURL) identifiers.
|
||||
/// Implements the PURL specification: https://github.com/package-url/purl-spec
|
||||
/// </summary>
|
||||
public static partial class PurlParser
|
||||
{
|
||||
// pkg:type/namespace/name@version?qualifiers#subpath
|
||||
[GeneratedRegex(
|
||||
@"^pkg:(?<type>[a-zA-Z][a-zA-Z0-9.+-]*)(?:/(?<namespace>[^/]+))?/(?<name>[^@?#]+)(?:@(?<version>[^?#]+))?(?:\?(?<qualifiers>[^#]+))?(?:#(?<subpath>.+))?$",
|
||||
RegexOptions.Compiled)]
|
||||
private static partial Regex PurlRegex();
|
||||
|
||||
/// <summary>
|
||||
/// Parses a PURL string into its components.
|
||||
/// </summary>
|
||||
public static PurlParseResult Parse(string? purl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(purl))
|
||||
{
|
||||
return PurlParseResult.Failed("PURL cannot be null or empty");
|
||||
}
|
||||
|
||||
var match = PurlRegex().Match(purl);
|
||||
if (!match.Success)
|
||||
{
|
||||
return PurlParseResult.Failed("Invalid PURL format");
|
||||
}
|
||||
|
||||
var type = match.Groups["type"].Value.ToLowerInvariant();
|
||||
var namespaceGroup = match.Groups["namespace"];
|
||||
var nameGroup = match.Groups["name"];
|
||||
var versionGroup = match.Groups["version"];
|
||||
var qualifiersGroup = match.Groups["qualifiers"];
|
||||
var subpathGroup = match.Groups["subpath"];
|
||||
|
||||
var ns = namespaceGroup.Success ? DecodeComponent(namespaceGroup.Value) : null;
|
||||
var name = DecodeComponent(nameGroup.Value);
|
||||
var version = versionGroup.Success ? DecodeComponent(versionGroup.Value) : null;
|
||||
var qualifiers = qualifiersGroup.Success ? ParseQualifiers(qualifiersGroup.Value) : null;
|
||||
var subpath = subpathGroup.Success ? DecodeComponent(subpathGroup.Value) : null;
|
||||
|
||||
// Normalize namespace per type
|
||||
ns = NormalizeNamespace(type, ns);
|
||||
|
||||
// Normalize name per type
|
||||
name = NormalizeName(type, name);
|
||||
|
||||
var packageUrl = new PackageUrl(
|
||||
Type: type,
|
||||
Namespace: ns,
|
||||
Name: name,
|
||||
Version: version,
|
||||
Qualifiers: qualifiers,
|
||||
Subpath: subpath,
|
||||
Raw: purl);
|
||||
|
||||
return PurlParseResult.Successful(packageUrl);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates if a string is a valid PURL.
|
||||
/// </summary>
|
||||
public static bool IsValid(string? purl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(purl))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return PurlRegex().IsMatch(purl);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a PURL to canonical form.
|
||||
/// </summary>
|
||||
public static string? Normalize(string? purl)
|
||||
{
|
||||
var result = Parse(purl);
|
||||
if (!result.Success || result.PackageUrl == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Build(result.PackageUrl);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a PURL string from components.
|
||||
/// </summary>
|
||||
public static string Build(PackageUrl purl)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.Append("pkg:");
|
||||
sb.Append(purl.Type);
|
||||
|
||||
if (!string.IsNullOrEmpty(purl.Namespace))
|
||||
{
|
||||
sb.Append('/');
|
||||
sb.Append(EncodeComponent(purl.Namespace));
|
||||
}
|
||||
|
||||
sb.Append('/');
|
||||
sb.Append(EncodeComponent(purl.Name));
|
||||
|
||||
if (!string.IsNullOrEmpty(purl.Version))
|
||||
{
|
||||
sb.Append('@');
|
||||
sb.Append(EncodeComponent(purl.Version));
|
||||
}
|
||||
|
||||
if (purl.Qualifiers is { Count: > 0 })
|
||||
{
|
||||
sb.Append('?');
|
||||
var first = true;
|
||||
foreach (var (key, value) in purl.Qualifiers.OrderBy(kv => kv.Key, StringComparer.Ordinal))
|
||||
{
|
||||
if (!first)
|
||||
{
|
||||
sb.Append('&');
|
||||
}
|
||||
first = false;
|
||||
sb.Append(EncodeComponent(key));
|
||||
sb.Append('=');
|
||||
sb.Append(EncodeComponent(value));
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(purl.Subpath))
|
||||
{
|
||||
sb.Append('#');
|
||||
sb.Append(EncodeComponent(purl.Subpath));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the ecosystem/type from a PURL.
|
||||
/// </summary>
|
||||
public static string? GetEcosystem(string? purl)
|
||||
{
|
||||
var result = Parse(purl);
|
||||
return result.Success ? result.PackageUrl?.Type : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if two PURLs refer to the same package (ignoring version).
|
||||
/// </summary>
|
||||
public static bool IsSamePackage(string? purl1, string? purl2)
|
||||
{
|
||||
var result1 = Parse(purl1);
|
||||
var result2 = Parse(purl2);
|
||||
|
||||
if (!result1.Success || !result2.Success)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var p1 = result1.PackageUrl!;
|
||||
var p2 = result2.PackageUrl!;
|
||||
|
||||
return string.Equals(p1.Type, p2.Type, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(p1.Namespace, p2.Namespace, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(p1.Name, p2.Name, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string DecodeComponent(string component)
|
||||
{
|
||||
return HttpUtility.UrlDecode(component);
|
||||
}
|
||||
|
||||
private static string EncodeComponent(string component)
|
||||
{
|
||||
// Percent-encode per PURL spec
|
||||
return Uri.EscapeDataString(component);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string>? ParseQualifiers(string qualifiersStr)
|
||||
{
|
||||
if (string.IsNullOrEmpty(qualifiersStr))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var pairs = qualifiersStr.Split('&');
|
||||
|
||||
foreach (var pair in pairs)
|
||||
{
|
||||
var idx = pair.IndexOf('=');
|
||||
if (idx > 0)
|
||||
{
|
||||
var key = DecodeComponent(pair[..idx]).ToLowerInvariant();
|
||||
var value = DecodeComponent(pair[(idx + 1)..]);
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result.Count > 0 ? result : null;
|
||||
}
|
||||
|
||||
private static string? NormalizeNamespace(string type, string? ns)
|
||||
{
|
||||
if (string.IsNullOrEmpty(ns))
|
||||
{
|
||||
return ns;
|
||||
}
|
||||
|
||||
// Normalize per type-specific rules
|
||||
return type switch
|
||||
{
|
||||
"npm" => ns.ToLowerInvariant(),
|
||||
"nuget" => ns.ToLowerInvariant(),
|
||||
"pypi" => ns.ToLowerInvariant().Replace('_', '-'),
|
||||
"maven" => ns, // Case-sensitive
|
||||
"golang" => ns.ToLowerInvariant(),
|
||||
_ => ns
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeName(string type, string name)
|
||||
{
|
||||
// Normalize per type-specific rules
|
||||
return type switch
|
||||
{
|
||||
"npm" => name.ToLowerInvariant(),
|
||||
"nuget" => name.ToLowerInvariant(),
|
||||
"pypi" => name.ToLowerInvariant().Replace('_', '-'),
|
||||
"golang" => name.ToLowerInvariant(),
|
||||
_ => name
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of PURL parsing.
|
||||
/// </summary>
|
||||
public sealed record PurlParseResult(
|
||||
bool Success,
|
||||
PackageUrl? PackageUrl,
|
||||
string? ErrorMessage)
|
||||
{
|
||||
public static PurlParseResult Successful(PackageUrl purl) =>
|
||||
new(true, purl, null);
|
||||
|
||||
public static PurlParseResult Failed(string error) =>
|
||||
new(false, null, error);
|
||||
}
|
||||
183
src/VexLens/StellaOps.VexLens/Models/NormalizedVexModels.cs
Normal file
183
src/VexLens/StellaOps.VexLens/Models/NormalizedVexModels.cs
Normal file
@@ -0,0 +1,183 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.VexLens.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Normalized VEX document per vex-normalization.schema.json.
|
||||
/// Supports OpenVEX, CSAF VEX, CycloneDX VEX, SPDX VEX, and StellaOps formats.
|
||||
/// </summary>
|
||||
public sealed record NormalizedVexDocument(
|
||||
[property: JsonPropertyName("schemaVersion")] int SchemaVersion,
|
||||
[property: JsonPropertyName("documentId")] string DocumentId,
|
||||
[property: JsonPropertyName("sourceFormat")] VexSourceFormat SourceFormat,
|
||||
[property: JsonPropertyName("sourceDigest")] string? SourceDigest,
|
||||
[property: JsonPropertyName("sourceUri")] string? SourceUri,
|
||||
[property: JsonPropertyName("issuer")] VexIssuer? Issuer,
|
||||
[property: JsonPropertyName("issuedAt")] DateTimeOffset? IssuedAt,
|
||||
[property: JsonPropertyName("lastUpdatedAt")] DateTimeOffset? LastUpdatedAt,
|
||||
[property: JsonPropertyName("statements")] IReadOnlyList<NormalizedStatement> Statements,
|
||||
[property: JsonPropertyName("provenance")] NormalizationProvenance? Provenance)
|
||||
{
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Original VEX document format before normalization.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<VexSourceFormat>))]
|
||||
public enum VexSourceFormat
|
||||
{
|
||||
[JsonPropertyName("OPENVEX")]
|
||||
OpenVex,
|
||||
|
||||
[JsonPropertyName("CSAF_VEX")]
|
||||
CsafVex,
|
||||
|
||||
[JsonPropertyName("CYCLONEDX_VEX")]
|
||||
CycloneDxVex,
|
||||
|
||||
[JsonPropertyName("SPDX_VEX")]
|
||||
SpdxVex,
|
||||
|
||||
[JsonPropertyName("STELLAOPS")]
|
||||
StellaOps
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issuing authority for a VEX document.
|
||||
/// </summary>
|
||||
public sealed record VexIssuer(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("category")] IssuerCategory? Category,
|
||||
[property: JsonPropertyName("trustTier")] TrustTier? TrustTier,
|
||||
[property: JsonPropertyName("keyFingerprints")] IReadOnlyList<string>? KeyFingerprints);
|
||||
|
||||
/// <summary>
|
||||
/// Issuer category for trust weighting.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<IssuerCategory>))]
|
||||
public enum IssuerCategory
|
||||
{
|
||||
[JsonPropertyName("VENDOR")]
|
||||
Vendor,
|
||||
|
||||
[JsonPropertyName("DISTRIBUTOR")]
|
||||
Distributor,
|
||||
|
||||
[JsonPropertyName("COMMUNITY")]
|
||||
Community,
|
||||
|
||||
[JsonPropertyName("INTERNAL")]
|
||||
Internal,
|
||||
|
||||
[JsonPropertyName("AGGREGATOR")]
|
||||
Aggregator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trust tier for policy evaluation.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<TrustTier>))]
|
||||
public enum TrustTier
|
||||
{
|
||||
[JsonPropertyName("AUTHORITATIVE")]
|
||||
Authoritative,
|
||||
|
||||
[JsonPropertyName("TRUSTED")]
|
||||
Trusted,
|
||||
|
||||
[JsonPropertyName("UNTRUSTED")]
|
||||
Untrusted,
|
||||
|
||||
[JsonPropertyName("UNKNOWN")]
|
||||
Unknown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalized VEX statement extracted from source.
|
||||
/// </summary>
|
||||
public sealed record NormalizedStatement(
|
||||
[property: JsonPropertyName("statementId")] string StatementId,
|
||||
[property: JsonPropertyName("vulnerabilityId")] string VulnerabilityId,
|
||||
[property: JsonPropertyName("vulnerabilityAliases")] IReadOnlyList<string>? VulnerabilityAliases,
|
||||
[property: JsonPropertyName("product")] NormalizedProduct Product,
|
||||
[property: JsonPropertyName("status")] VexStatus Status,
|
||||
[property: JsonPropertyName("statusNotes")] string? StatusNotes,
|
||||
[property: JsonPropertyName("justification")] VexJustification? Justification,
|
||||
[property: JsonPropertyName("impactStatement")] string? ImpactStatement,
|
||||
[property: JsonPropertyName("actionStatement")] string? ActionStatement,
|
||||
[property: JsonPropertyName("actionStatementTimestamp")] DateTimeOffset? ActionStatementTimestamp,
|
||||
[property: JsonPropertyName("versions")] VersionRange? Versions,
|
||||
[property: JsonPropertyName("subcomponents")] IReadOnlyList<NormalizedProduct>? Subcomponents,
|
||||
[property: JsonPropertyName("firstSeen")] DateTimeOffset? FirstSeen,
|
||||
[property: JsonPropertyName("lastSeen")] DateTimeOffset? LastSeen);
|
||||
|
||||
/// <summary>
|
||||
/// Normalized VEX status using OpenVEX terminology.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<VexStatus>))]
|
||||
public enum VexStatus
|
||||
{
|
||||
[JsonPropertyName("not_affected")]
|
||||
NotAffected,
|
||||
|
||||
[JsonPropertyName("affected")]
|
||||
Affected,
|
||||
|
||||
[JsonPropertyName("fixed")]
|
||||
Fixed,
|
||||
|
||||
[JsonPropertyName("under_investigation")]
|
||||
UnderInvestigation
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalized justification when status is not_affected.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<VexJustification>))]
|
||||
public enum VexJustification
|
||||
{
|
||||
[JsonPropertyName("component_not_present")]
|
||||
ComponentNotPresent,
|
||||
|
||||
[JsonPropertyName("vulnerable_code_not_present")]
|
||||
VulnerableCodeNotPresent,
|
||||
|
||||
[JsonPropertyName("vulnerable_code_not_in_execute_path")]
|
||||
VulnerableCodeNotInExecutePath,
|
||||
|
||||
[JsonPropertyName("vulnerable_code_cannot_be_controlled_by_adversary")]
|
||||
VulnerableCodeCannotBeControlledByAdversary,
|
||||
|
||||
[JsonPropertyName("inline_mitigations_already_exist")]
|
||||
InlineMitigationsAlreadyExist
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalized product reference.
|
||||
/// </summary>
|
||||
public sealed record NormalizedProduct(
|
||||
[property: JsonPropertyName("key")] string Key,
|
||||
[property: JsonPropertyName("name")] string? Name,
|
||||
[property: JsonPropertyName("version")] string? Version,
|
||||
[property: JsonPropertyName("purl")] string? Purl,
|
||||
[property: JsonPropertyName("cpe")] string? Cpe,
|
||||
[property: JsonPropertyName("hashes")] IReadOnlyDictionary<string, string>? Hashes);
|
||||
|
||||
/// <summary>
|
||||
/// Version constraints for a statement.
|
||||
/// </summary>
|
||||
public sealed record VersionRange(
|
||||
[property: JsonPropertyName("affected")] IReadOnlyList<string>? Affected,
|
||||
[property: JsonPropertyName("fixed")] IReadOnlyList<string>? Fixed,
|
||||
[property: JsonPropertyName("unaffected")] IReadOnlyList<string>? Unaffected);
|
||||
|
||||
/// <summary>
|
||||
/// Metadata about the normalization process.
|
||||
/// </summary>
|
||||
public sealed record NormalizationProvenance(
|
||||
[property: JsonPropertyName("normalizedAt")] DateTimeOffset NormalizedAt,
|
||||
[property: JsonPropertyName("normalizer")] string Normalizer,
|
||||
[property: JsonPropertyName("sourceRevision")] string? SourceRevision,
|
||||
[property: JsonPropertyName("transformationRules")] IReadOnlyList<string>? TransformationRules);
|
||||
685
src/VexLens/StellaOps.VexLens/Normalization/CsafVexNormalizer.cs
Normal file
685
src/VexLens/StellaOps.VexLens/Normalization/CsafVexNormalizer.cs
Normal file
@@ -0,0 +1,685 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Normalization;
|
||||
|
||||
/// <summary>
|
||||
/// Normalizer for CSAF VEX format documents.
|
||||
/// CSAF VEX documents follow the OASIS CSAF 2.0 specification with profile "VEX".
|
||||
/// </summary>
|
||||
public sealed class CsafVexNormalizer : IVexNormalizer
|
||||
{
|
||||
public VexSourceFormat SourceFormat => VexSourceFormat.CsafVex;
|
||||
|
||||
public bool CanNormalize(string content)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// CSAF documents have document.category = "csaf_vex"
|
||||
if (root.TryGetProperty("document", out var document))
|
||||
{
|
||||
if (document.TryGetProperty("category", out var category))
|
||||
{
|
||||
var categoryStr = category.GetString();
|
||||
return categoryStr?.Equals("csaf_vex", StringComparison.OrdinalIgnoreCase) == true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<NormalizationResult> NormalizeAsync(
|
||||
string content,
|
||||
NormalizationContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var warnings = new List<NormalizationWarning>();
|
||||
var statementsSkipped = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Extract document metadata
|
||||
if (!root.TryGetProperty("document", out var documentElement))
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return Task.FromResult(NormalizationResult.Failed(
|
||||
[new NormalizationError("ERR_CSAF_001", "Missing 'document' element", "document", null)],
|
||||
new NormalizationMetrics(
|
||||
Duration: stopwatch.Elapsed,
|
||||
SourceBytes: Encoding.UTF8.GetByteCount(content),
|
||||
StatementsExtracted: 0,
|
||||
StatementsSkipped: 0,
|
||||
ProductsMapped: 0),
|
||||
warnings));
|
||||
}
|
||||
|
||||
// Extract document ID
|
||||
var documentId = ExtractDocumentId(documentElement);
|
||||
if (string.IsNullOrWhiteSpace(documentId))
|
||||
{
|
||||
documentId = $"csaf:{Guid.NewGuid():N}";
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_CSAF_001",
|
||||
"Document tracking ID not found; generated a random ID",
|
||||
"document.tracking.id"));
|
||||
}
|
||||
|
||||
// Extract issuer from publisher
|
||||
var issuer = ExtractIssuer(documentElement, warnings);
|
||||
|
||||
// Extract timestamps
|
||||
var (issuedAt, lastUpdatedAt) = ExtractTimestamps(documentElement);
|
||||
|
||||
// Extract product tree for product resolution
|
||||
var productTree = root.TryGetProperty("product_tree", out var pt) ? pt : default;
|
||||
|
||||
// Extract vulnerabilities and convert to statements
|
||||
var statements = ExtractStatements(root, productTree, warnings, ref statementsSkipped);
|
||||
|
||||
// Calculate source digest
|
||||
var sourceDigest = ComputeDigest(content);
|
||||
|
||||
// Build provenance
|
||||
var provenance = new NormalizationProvenance(
|
||||
NormalizedAt: context.NormalizedAt,
|
||||
Normalizer: context.Normalizer,
|
||||
SourceRevision: null,
|
||||
TransformationRules: ["csaf-vex-to-normalized-v1"]);
|
||||
|
||||
var normalizedDoc = new NormalizedVexDocument(
|
||||
SchemaVersion: NormalizedVexDocument.CurrentSchemaVersion,
|
||||
DocumentId: documentId,
|
||||
SourceFormat: VexSourceFormat.CsafVex,
|
||||
SourceDigest: sourceDigest,
|
||||
SourceUri: context.SourceUri,
|
||||
Issuer: issuer,
|
||||
IssuedAt: issuedAt,
|
||||
LastUpdatedAt: lastUpdatedAt,
|
||||
Statements: statements,
|
||||
Provenance: provenance);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
return Task.FromResult(NormalizationResult.Successful(
|
||||
normalizedDoc,
|
||||
new NormalizationMetrics(
|
||||
Duration: stopwatch.Elapsed,
|
||||
SourceBytes: Encoding.UTF8.GetByteCount(content),
|
||||
StatementsExtracted: statements.Count,
|
||||
StatementsSkipped: statementsSkipped,
|
||||
ProductsMapped: statements.Count),
|
||||
warnings));
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return Task.FromResult(NormalizationResult.Failed(
|
||||
[new NormalizationError("ERR_CSAF_002", "Invalid JSON", ex.Path, ex)],
|
||||
new NormalizationMetrics(
|
||||
Duration: stopwatch.Elapsed,
|
||||
SourceBytes: Encoding.UTF8.GetByteCount(content),
|
||||
StatementsExtracted: 0,
|
||||
StatementsSkipped: 0,
|
||||
ProductsMapped: 0),
|
||||
warnings));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return Task.FromResult(NormalizationResult.Failed(
|
||||
[new NormalizationError("ERR_CSAF_999", "Unexpected error during normalization", null, ex)],
|
||||
new NormalizationMetrics(
|
||||
Duration: stopwatch.Elapsed,
|
||||
SourceBytes: Encoding.UTF8.GetByteCount(content),
|
||||
StatementsExtracted: 0,
|
||||
StatementsSkipped: 0,
|
||||
ProductsMapped: 0),
|
||||
warnings));
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractDocumentId(JsonElement document)
|
||||
{
|
||||
if (document.TryGetProperty("tracking", out var tracking) &&
|
||||
tracking.TryGetProperty("id", out var id))
|
||||
{
|
||||
return id.GetString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static VexIssuer? ExtractIssuer(JsonElement document, List<NormalizationWarning> warnings)
|
||||
{
|
||||
if (!document.TryGetProperty("publisher", out var publisher))
|
||||
{
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_CSAF_002",
|
||||
"No publisher found in document",
|
||||
"document.publisher"));
|
||||
return null;
|
||||
}
|
||||
|
||||
var issuerId = publisher.TryGetProperty("namespace", out var nsProp)
|
||||
? nsProp.GetString() ?? "unknown"
|
||||
: "unknown";
|
||||
|
||||
var issuerName = publisher.TryGetProperty("name", out var nameProp)
|
||||
? nameProp.GetString() ?? issuerId
|
||||
: issuerId;
|
||||
|
||||
var categoryStr = publisher.TryGetProperty("category", out var catProp)
|
||||
? catProp.GetString()
|
||||
: null;
|
||||
|
||||
var category = MapPublisherCategory(categoryStr);
|
||||
|
||||
return new VexIssuer(
|
||||
Id: issuerId,
|
||||
Name: issuerName,
|
||||
Category: category,
|
||||
TrustTier: TrustTier.Unknown,
|
||||
KeyFingerprints: null);
|
||||
}
|
||||
|
||||
private static IssuerCategory? MapPublisherCategory(string? category)
|
||||
{
|
||||
return category?.ToLowerInvariant() switch
|
||||
{
|
||||
"vendor" => IssuerCategory.Vendor,
|
||||
"discoverer" or "coordinator" => IssuerCategory.Community,
|
||||
"user" => IssuerCategory.Internal,
|
||||
"other" => null,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static (DateTimeOffset? IssuedAt, DateTimeOffset? LastUpdatedAt) ExtractTimestamps(JsonElement document)
|
||||
{
|
||||
DateTimeOffset? issuedAt = null;
|
||||
DateTimeOffset? lastUpdatedAt = null;
|
||||
|
||||
if (document.TryGetProperty("tracking", out var tracking))
|
||||
{
|
||||
if (tracking.TryGetProperty("initial_release_date", out var initialRelease) &&
|
||||
initialRelease.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(initialRelease.GetString(), out var parsed))
|
||||
{
|
||||
issuedAt = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (tracking.TryGetProperty("current_release_date", out var currentRelease) &&
|
||||
currentRelease.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(currentRelease.GetString(), out var parsed))
|
||||
{
|
||||
lastUpdatedAt = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (issuedAt, lastUpdatedAt);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<NormalizedStatement> ExtractStatements(
|
||||
JsonElement root,
|
||||
JsonElement productTree,
|
||||
List<NormalizationWarning> warnings,
|
||||
ref int skipped)
|
||||
{
|
||||
if (!root.TryGetProperty("vulnerabilities", out var vulnerabilities) ||
|
||||
vulnerabilities.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_CSAF_003",
|
||||
"No vulnerabilities array found",
|
||||
"vulnerabilities"));
|
||||
return [];
|
||||
}
|
||||
|
||||
var statements = new List<NormalizedStatement>();
|
||||
var statementIndex = 0;
|
||||
|
||||
foreach (var vuln in vulnerabilities.EnumerateArray())
|
||||
{
|
||||
var vulnStatements = ExtractVulnerabilityStatements(
|
||||
vuln, productTree, statementIndex, warnings, ref skipped);
|
||||
statements.AddRange(vulnStatements);
|
||||
statementIndex += vulnStatements.Count;
|
||||
}
|
||||
|
||||
return statements;
|
||||
}
|
||||
|
||||
private static List<NormalizedStatement> ExtractVulnerabilityStatements(
|
||||
JsonElement vuln,
|
||||
JsonElement productTree,
|
||||
int startIndex,
|
||||
List<NormalizationWarning> warnings,
|
||||
ref int skipped)
|
||||
{
|
||||
var statements = new List<NormalizedStatement>();
|
||||
|
||||
// Extract vulnerability ID (CVE or other identifier)
|
||||
string? vulnerabilityId = null;
|
||||
var aliases = new List<string>();
|
||||
|
||||
if (vuln.TryGetProperty("cve", out var cve))
|
||||
{
|
||||
vulnerabilityId = cve.GetString();
|
||||
}
|
||||
|
||||
if (vuln.TryGetProperty("ids", out var ids) && ids.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var id in ids.EnumerateArray())
|
||||
{
|
||||
if (id.TryGetProperty("text", out var text))
|
||||
{
|
||||
var idStr = text.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(idStr))
|
||||
{
|
||||
if (vulnerabilityId == null)
|
||||
{
|
||||
vulnerabilityId = idStr;
|
||||
}
|
||||
else if (idStr != vulnerabilityId)
|
||||
{
|
||||
aliases.Add(idStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(vulnerabilityId))
|
||||
{
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_CSAF_004",
|
||||
"Vulnerability missing CVE or ID; skipped",
|
||||
"vulnerabilities[].cve"));
|
||||
skipped++;
|
||||
return statements;
|
||||
}
|
||||
|
||||
// Extract product_status for VEX statements
|
||||
if (!vuln.TryGetProperty("product_status", out var productStatus))
|
||||
{
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_CSAF_005",
|
||||
$"Vulnerability {vulnerabilityId} has no product_status",
|
||||
"vulnerabilities[].product_status"));
|
||||
return statements;
|
||||
}
|
||||
|
||||
// Process each status category
|
||||
var localIndex = 0;
|
||||
|
||||
// Known not affected
|
||||
if (productStatus.TryGetProperty("known_not_affected", out var knownNotAffected) &&
|
||||
knownNotAffected.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var productRef in knownNotAffected.EnumerateArray())
|
||||
{
|
||||
var product = ResolveProduct(productRef, productTree);
|
||||
if (product != null)
|
||||
{
|
||||
var justification = ExtractJustification(vuln, productRef.GetString());
|
||||
statements.Add(CreateStatement(
|
||||
startIndex + localIndex++,
|
||||
vulnerabilityId,
|
||||
aliases,
|
||||
product,
|
||||
VexStatus.NotAffected,
|
||||
justification,
|
||||
vuln));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed
|
||||
if (productStatus.TryGetProperty("fixed", out var fixedProducts) &&
|
||||
fixedProducts.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var productRef in fixedProducts.EnumerateArray())
|
||||
{
|
||||
var product = ResolveProduct(productRef, productTree);
|
||||
if (product != null)
|
||||
{
|
||||
statements.Add(CreateStatement(
|
||||
startIndex + localIndex++,
|
||||
vulnerabilityId,
|
||||
aliases,
|
||||
product,
|
||||
VexStatus.Fixed,
|
||||
null,
|
||||
vuln));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Known affected
|
||||
if (productStatus.TryGetProperty("known_affected", out var knownAffected) &&
|
||||
knownAffected.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var productRef in knownAffected.EnumerateArray())
|
||||
{
|
||||
var product = ResolveProduct(productRef, productTree);
|
||||
if (product != null)
|
||||
{
|
||||
statements.Add(CreateStatement(
|
||||
startIndex + localIndex++,
|
||||
vulnerabilityId,
|
||||
aliases,
|
||||
product,
|
||||
VexStatus.Affected,
|
||||
null,
|
||||
vuln));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Under investigation
|
||||
if (productStatus.TryGetProperty("under_investigation", out var underInvestigation) &&
|
||||
underInvestigation.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var productRef in underInvestigation.EnumerateArray())
|
||||
{
|
||||
var product = ResolveProduct(productRef, productTree);
|
||||
if (product != null)
|
||||
{
|
||||
statements.Add(CreateStatement(
|
||||
startIndex + localIndex++,
|
||||
vulnerabilityId,
|
||||
aliases,
|
||||
product,
|
||||
VexStatus.UnderInvestigation,
|
||||
null,
|
||||
vuln));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return statements;
|
||||
}
|
||||
|
||||
private static NormalizedProduct? ResolveProduct(JsonElement productRef, JsonElement productTree)
|
||||
{
|
||||
if (productRef.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var productId = productRef.GetString();
|
||||
if (string.IsNullOrWhiteSpace(productId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to find product details in product_tree
|
||||
string? name = null;
|
||||
string? version = null;
|
||||
string? purl = null;
|
||||
string? cpe = null;
|
||||
|
||||
if (productTree.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
// Search in full_product_names
|
||||
if (productTree.TryGetProperty("full_product_names", out var fullNames) &&
|
||||
fullNames.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var fpn in fullNames.EnumerateArray())
|
||||
{
|
||||
if (fpn.TryGetProperty("product_id", out var pid) &&
|
||||
pid.GetString() == productId)
|
||||
{
|
||||
name = fpn.TryGetProperty("name", out var n) ? n.GetString() : null;
|
||||
|
||||
if (fpn.TryGetProperty("product_identification_helper", out var pih))
|
||||
{
|
||||
purl = pih.TryGetProperty("purl", out var p) ? p.GetString() : null;
|
||||
cpe = pih.TryGetProperty("cpe", out var c) ? c.GetString() : null;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search in branches recursively
|
||||
if (name == null && productTree.TryGetProperty("branches", out var branches))
|
||||
{
|
||||
var result = SearchBranches(branches, productId);
|
||||
if (result.HasValue)
|
||||
{
|
||||
name = result.Value.Name;
|
||||
version = result.Value.Version;
|
||||
purl = result.Value.Purl;
|
||||
cpe = result.Value.Cpe;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new NormalizedProduct(
|
||||
Key: productId,
|
||||
Name: name,
|
||||
Version: version,
|
||||
Purl: purl,
|
||||
Cpe: cpe,
|
||||
Hashes: null);
|
||||
}
|
||||
|
||||
private static (string? Name, string? Version, string? Purl, string? Cpe)? SearchBranches(
|
||||
JsonElement branches,
|
||||
string productId)
|
||||
{
|
||||
if (branches.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var branch in branches.EnumerateArray())
|
||||
{
|
||||
// Check product in this branch
|
||||
if (branch.TryGetProperty("product", out var product) &&
|
||||
product.TryGetProperty("product_id", out var pid) &&
|
||||
pid.GetString() == productId)
|
||||
{
|
||||
var name = product.TryGetProperty("name", out var n) ? n.GetString() : null;
|
||||
var version = branch.TryGetProperty("name", out var bn) &&
|
||||
branch.TryGetProperty("category", out var bc) &&
|
||||
bc.GetString() == "product_version"
|
||||
? bn.GetString()
|
||||
: null;
|
||||
|
||||
string? purl = null;
|
||||
string? cpe = null;
|
||||
|
||||
if (product.TryGetProperty("product_identification_helper", out var pih))
|
||||
{
|
||||
purl = pih.TryGetProperty("purl", out var p) ? p.GetString() : null;
|
||||
cpe = pih.TryGetProperty("cpe", out var c) ? c.GetString() : null;
|
||||
}
|
||||
|
||||
return (name, version, purl, cpe);
|
||||
}
|
||||
|
||||
// Recurse into sub-branches
|
||||
if (branch.TryGetProperty("branches", out var subBranches))
|
||||
{
|
||||
var result = SearchBranches(subBranches, productId);
|
||||
if (result.HasValue)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static VexJustification? ExtractJustification(JsonElement vuln, string? productId)
|
||||
{
|
||||
// Look for flags that indicate justification
|
||||
if (!vuln.TryGetProperty("flags", out var flags) ||
|
||||
flags.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var flag in flags.EnumerateArray())
|
||||
{
|
||||
// Check if this flag applies to our product
|
||||
if (flag.TryGetProperty("product_ids", out var productIds) &&
|
||||
productIds.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var applies = false;
|
||||
foreach (var pid in productIds.EnumerateArray())
|
||||
{
|
||||
if (pid.GetString() == productId)
|
||||
{
|
||||
applies = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!applies)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (flag.TryGetProperty("label", out var label))
|
||||
{
|
||||
var labelStr = label.GetString();
|
||||
var justification = MapCsafFlagToJustification(labelStr);
|
||||
if (justification.HasValue)
|
||||
{
|
||||
return justification;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static VexJustification? MapCsafFlagToJustification(string? label)
|
||||
{
|
||||
return label?.ToLowerInvariant() switch
|
||||
{
|
||||
"component_not_present" => VexJustification.ComponentNotPresent,
|
||||
"vulnerable_code_not_present" => VexJustification.VulnerableCodeNotPresent,
|
||||
"vulnerable_code_not_in_execute_path" or "vulnerable_code_cannot_be_controlled_by_adversary" =>
|
||||
VexJustification.VulnerableCodeNotInExecutePath,
|
||||
"inline_mitigations_already_exist" => VexJustification.InlineMitigationsAlreadyExist,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static NormalizedStatement CreateStatement(
|
||||
int index,
|
||||
string vulnerabilityId,
|
||||
List<string> aliases,
|
||||
NormalizedProduct product,
|
||||
VexStatus status,
|
||||
VexJustification? justification,
|
||||
JsonElement vuln)
|
||||
{
|
||||
// Extract notes for status notes
|
||||
string? statusNotes = null;
|
||||
if (vuln.TryGetProperty("notes", out var notes) && notes.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var note in notes.EnumerateArray())
|
||||
{
|
||||
if (note.TryGetProperty("category", out var cat) &&
|
||||
cat.GetString() == "description" &&
|
||||
note.TryGetProperty("text", out var text))
|
||||
{
|
||||
statusNotes = text.GetString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract action statement from remediations
|
||||
string? actionStatement = null;
|
||||
DateTimeOffset? actionTimestamp = null;
|
||||
|
||||
if (vuln.TryGetProperty("remediations", out var remediations) &&
|
||||
remediations.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var rem in remediations.EnumerateArray())
|
||||
{
|
||||
if (rem.TryGetProperty("details", out var details))
|
||||
{
|
||||
actionStatement = details.GetString();
|
||||
}
|
||||
|
||||
if (rem.TryGetProperty("date", out var date) &&
|
||||
date.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(date.GetString(), out var parsed))
|
||||
{
|
||||
actionTimestamp = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
break; // Take first remediation
|
||||
}
|
||||
}
|
||||
|
||||
// Extract release date as timestamp
|
||||
DateTimeOffset? timestamp = null;
|
||||
if (vuln.TryGetProperty("release_date", out var releaseDate) &&
|
||||
releaseDate.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(releaseDate.GetString(), out var parsed))
|
||||
{
|
||||
timestamp = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return new NormalizedStatement(
|
||||
StatementId: $"stmt-{index}",
|
||||
VulnerabilityId: vulnerabilityId,
|
||||
VulnerabilityAliases: aliases.Count > 0 ? aliases : null,
|
||||
Product: product,
|
||||
Status: status,
|
||||
StatusNotes: statusNotes,
|
||||
Justification: justification,
|
||||
ImpactStatement: null,
|
||||
ActionStatement: actionStatement,
|
||||
ActionStatementTimestamp: actionTimestamp,
|
||||
Versions: null,
|
||||
Subcomponents: null,
|
||||
FirstSeen: timestamp,
|
||||
LastSeen: timestamp);
|
||||
}
|
||||
|
||||
private static string ComputeDigest(string content)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Normalization;
|
||||
|
||||
/// <summary>
|
||||
/// Normalizer for CycloneDX VEX format documents.
|
||||
/// CycloneDX VEX uses the vulnerabilities array in CycloneDX BOM format.
|
||||
/// </summary>
|
||||
public sealed class CycloneDxVexNormalizer : IVexNormalizer
|
||||
{
|
||||
public VexSourceFormat SourceFormat => VexSourceFormat.CycloneDxVex;
|
||||
|
||||
public bool CanNormalize(string content)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// CycloneDX documents have bomFormat = "CycloneDX" and must have vulnerabilities
|
||||
if (root.TryGetProperty("bomFormat", out var bomFormat))
|
||||
{
|
||||
var formatStr = bomFormat.GetString();
|
||||
if (formatStr?.Equals("CycloneDX", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
// Must have vulnerabilities array to be a VEX document
|
||||
return root.TryGetProperty("vulnerabilities", out var vulns) &&
|
||||
vulns.ValueKind == JsonValueKind.Array &&
|
||||
vulns.GetArrayLength() > 0;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<NormalizationResult> NormalizeAsync(
|
||||
string content,
|
||||
NormalizationContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var warnings = new List<NormalizationWarning>();
|
||||
var statementsSkipped = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Extract document ID from serialNumber or metadata
|
||||
var documentId = ExtractDocumentId(root);
|
||||
if (string.IsNullOrWhiteSpace(documentId))
|
||||
{
|
||||
documentId = $"cyclonedx:{Guid.NewGuid():N}";
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_CDX_001",
|
||||
"Serial number not found; generated a random ID",
|
||||
"serialNumber"));
|
||||
}
|
||||
|
||||
// Extract issuer from metadata
|
||||
var issuer = ExtractIssuer(root, warnings);
|
||||
|
||||
// Extract timestamps
|
||||
var (issuedAt, lastUpdatedAt) = ExtractTimestamps(root);
|
||||
|
||||
// Build component lookup for product resolution
|
||||
var componentLookup = BuildComponentLookup(root);
|
||||
|
||||
// Extract vulnerabilities and convert to statements
|
||||
var statements = ExtractStatements(root, componentLookup, warnings, ref statementsSkipped);
|
||||
|
||||
// Calculate source digest
|
||||
var sourceDigest = ComputeDigest(content);
|
||||
|
||||
// Build provenance
|
||||
var provenance = new NormalizationProvenance(
|
||||
NormalizedAt: context.NormalizedAt,
|
||||
Normalizer: context.Normalizer,
|
||||
SourceRevision: null,
|
||||
TransformationRules: ["cyclonedx-vex-to-normalized-v1"]);
|
||||
|
||||
var normalizedDoc = new NormalizedVexDocument(
|
||||
SchemaVersion: NormalizedVexDocument.CurrentSchemaVersion,
|
||||
DocumentId: documentId,
|
||||
SourceFormat: VexSourceFormat.CycloneDxVex,
|
||||
SourceDigest: sourceDigest,
|
||||
SourceUri: context.SourceUri,
|
||||
Issuer: issuer,
|
||||
IssuedAt: issuedAt,
|
||||
LastUpdatedAt: lastUpdatedAt,
|
||||
Statements: statements,
|
||||
Provenance: provenance);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
return Task.FromResult(NormalizationResult.Successful(
|
||||
normalizedDoc,
|
||||
new NormalizationMetrics(
|
||||
Duration: stopwatch.Elapsed,
|
||||
SourceBytes: Encoding.UTF8.GetByteCount(content),
|
||||
StatementsExtracted: statements.Count,
|
||||
StatementsSkipped: statementsSkipped,
|
||||
ProductsMapped: statements.Count),
|
||||
warnings));
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return Task.FromResult(NormalizationResult.Failed(
|
||||
[new NormalizationError("ERR_CDX_001", "Invalid JSON", ex.Path, ex)],
|
||||
new NormalizationMetrics(
|
||||
Duration: stopwatch.Elapsed,
|
||||
SourceBytes: Encoding.UTF8.GetByteCount(content),
|
||||
StatementsExtracted: 0,
|
||||
StatementsSkipped: 0,
|
||||
ProductsMapped: 0),
|
||||
warnings));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return Task.FromResult(NormalizationResult.Failed(
|
||||
[new NormalizationError("ERR_CDX_999", "Unexpected error during normalization", null, ex)],
|
||||
new NormalizationMetrics(
|
||||
Duration: stopwatch.Elapsed,
|
||||
SourceBytes: Encoding.UTF8.GetByteCount(content),
|
||||
StatementsExtracted: 0,
|
||||
StatementsSkipped: 0,
|
||||
ProductsMapped: 0),
|
||||
warnings));
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractDocumentId(JsonElement root)
|
||||
{
|
||||
// Try serialNumber first
|
||||
if (root.TryGetProperty("serialNumber", out var serialNumber))
|
||||
{
|
||||
return serialNumber.GetString();
|
||||
}
|
||||
|
||||
// Fall back to metadata.component.bom-ref
|
||||
if (root.TryGetProperty("metadata", out var metadata) &&
|
||||
metadata.TryGetProperty("component", out var component) &&
|
||||
component.TryGetProperty("bom-ref", out var bomRef))
|
||||
{
|
||||
return bomRef.GetString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static VexIssuer? ExtractIssuer(JsonElement root, List<NormalizationWarning> warnings)
|
||||
{
|
||||
if (!root.TryGetProperty("metadata", out var metadata))
|
||||
{
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_CDX_002",
|
||||
"No metadata found in document",
|
||||
"metadata"));
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to extract from authors or supplier
|
||||
string? issuerId = null;
|
||||
string? issuerName = null;
|
||||
|
||||
if (metadata.TryGetProperty("authors", out var authors) &&
|
||||
authors.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var author in authors.EnumerateArray())
|
||||
{
|
||||
issuerName = author.TryGetProperty("name", out var name) ? name.GetString() : null;
|
||||
issuerId = author.TryGetProperty("email", out var email) ? email.GetString() : issuerName;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(issuerName))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(issuerName) &&
|
||||
metadata.TryGetProperty("supplier", out var supplier))
|
||||
{
|
||||
issuerName = supplier.TryGetProperty("name", out var name) ? name.GetString() : null;
|
||||
issuerId = supplier.TryGetProperty("url", out var url)
|
||||
? url.ValueKind == JsonValueKind.Array
|
||||
? url.EnumerateArray().FirstOrDefault().GetString()
|
||||
: url.GetString()
|
||||
: issuerName;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(issuerName) &&
|
||||
metadata.TryGetProperty("manufacture", out var manufacture))
|
||||
{
|
||||
issuerName = manufacture.TryGetProperty("name", out var name) ? name.GetString() : null;
|
||||
issuerId = issuerName;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(issuerName))
|
||||
{
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_CDX_003",
|
||||
"No author/supplier found in metadata",
|
||||
"metadata.authors"));
|
||||
return null;
|
||||
}
|
||||
|
||||
return new VexIssuer(
|
||||
Id: issuerId ?? "unknown",
|
||||
Name: issuerName ?? "unknown",
|
||||
Category: null,
|
||||
TrustTier: TrustTier.Unknown,
|
||||
KeyFingerprints: null);
|
||||
}
|
||||
|
||||
private static (DateTimeOffset? IssuedAt, DateTimeOffset? LastUpdatedAt) ExtractTimestamps(JsonElement root)
|
||||
{
|
||||
DateTimeOffset? issuedAt = null;
|
||||
|
||||
if (root.TryGetProperty("metadata", out var metadata) &&
|
||||
metadata.TryGetProperty("timestamp", out var timestamp) &&
|
||||
timestamp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(timestamp.GetString(), out var parsed))
|
||||
{
|
||||
issuedAt = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return (issuedAt, null);
|
||||
}
|
||||
|
||||
private static Dictionary<string, ComponentInfo> BuildComponentLookup(JsonElement root)
|
||||
{
|
||||
var lookup = new Dictionary<string, ComponentInfo>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Add metadata component
|
||||
if (root.TryGetProperty("metadata", out var metadata) &&
|
||||
metadata.TryGetProperty("component", out var metaComponent))
|
||||
{
|
||||
AddComponentToLookup(lookup, metaComponent);
|
||||
}
|
||||
|
||||
// Add all components
|
||||
if (root.TryGetProperty("components", out var components) &&
|
||||
components.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
AddComponentsRecursively(lookup, components);
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
private static void AddComponentsRecursively(Dictionary<string, ComponentInfo> lookup, JsonElement components)
|
||||
{
|
||||
foreach (var component in components.EnumerateArray())
|
||||
{
|
||||
AddComponentToLookup(lookup, component);
|
||||
|
||||
// Handle nested components
|
||||
if (component.TryGetProperty("components", out var nested) &&
|
||||
nested.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
AddComponentsRecursively(lookup, nested);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddComponentToLookup(Dictionary<string, ComponentInfo> lookup, JsonElement component)
|
||||
{
|
||||
var bomRef = component.TryGetProperty("bom-ref", out var br) ? br.GetString() : null;
|
||||
var name = component.TryGetProperty("name", out var n) ? n.GetString() : null;
|
||||
var version = component.TryGetProperty("version", out var v) ? v.GetString() : null;
|
||||
var purl = component.TryGetProperty("purl", out var p) ? p.GetString() : null;
|
||||
var cpe = component.TryGetProperty("cpe", out var c) ? c.GetString() : null;
|
||||
|
||||
// Extract hashes
|
||||
Dictionary<string, string>? hashes = null;
|
||||
if (component.TryGetProperty("hashes", out var hashArray) &&
|
||||
hashArray.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
hashes = [];
|
||||
foreach (var hash in hashArray.EnumerateArray())
|
||||
{
|
||||
var alg = hash.TryGetProperty("alg", out var a) ? a.GetString() : null;
|
||||
var content = hash.TryGetProperty("content", out var cont) ? cont.GetString() : null;
|
||||
if (!string.IsNullOrWhiteSpace(alg) && !string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
hashes[alg] = content;
|
||||
}
|
||||
}
|
||||
|
||||
if (hashes.Count == 0)
|
||||
{
|
||||
hashes = null;
|
||||
}
|
||||
}
|
||||
|
||||
var info = new ComponentInfo(name, version, purl, cpe, hashes);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(bomRef))
|
||||
{
|
||||
lookup[bomRef] = info;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(purl) && !lookup.ContainsKey(purl))
|
||||
{
|
||||
lookup[purl] = info;
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<NormalizedStatement> ExtractStatements(
|
||||
JsonElement root,
|
||||
Dictionary<string, ComponentInfo> componentLookup,
|
||||
List<NormalizationWarning> warnings,
|
||||
ref int skipped)
|
||||
{
|
||||
if (!root.TryGetProperty("vulnerabilities", out var vulnerabilities) ||
|
||||
vulnerabilities.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_CDX_004",
|
||||
"No vulnerabilities array found",
|
||||
"vulnerabilities"));
|
||||
return [];
|
||||
}
|
||||
|
||||
var statements = new List<NormalizedStatement>();
|
||||
var index = 0;
|
||||
|
||||
foreach (var vuln in vulnerabilities.EnumerateArray())
|
||||
{
|
||||
var vulnStatements = ExtractVulnerabilityStatements(
|
||||
vuln, componentLookup, index, warnings, ref skipped);
|
||||
statements.AddRange(vulnStatements);
|
||||
index += vulnStatements.Count > 0 ? vulnStatements.Count : 1;
|
||||
}
|
||||
|
||||
return statements;
|
||||
}
|
||||
|
||||
private static List<NormalizedStatement> ExtractVulnerabilityStatements(
|
||||
JsonElement vuln,
|
||||
Dictionary<string, ComponentInfo> componentLookup,
|
||||
int startIndex,
|
||||
List<NormalizationWarning> warnings,
|
||||
ref int skipped)
|
||||
{
|
||||
var statements = new List<NormalizedStatement>();
|
||||
|
||||
// Extract vulnerability ID
|
||||
var vulnerabilityId = vuln.TryGetProperty("id", out var id) ? id.GetString() : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(vulnerabilityId))
|
||||
{
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_CDX_005",
|
||||
"Vulnerability missing ID; skipped",
|
||||
"vulnerabilities[].id"));
|
||||
skipped++;
|
||||
return statements;
|
||||
}
|
||||
|
||||
// Extract aliases from references with type = "advisory"
|
||||
var aliases = new List<string>();
|
||||
if (vuln.TryGetProperty("references", out var refs) &&
|
||||
refs.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var reference in refs.EnumerateArray())
|
||||
{
|
||||
if (reference.TryGetProperty("id", out var refId))
|
||||
{
|
||||
var refIdStr = refId.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(refIdStr) && refIdStr != vulnerabilityId)
|
||||
{
|
||||
aliases.Add(refIdStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract affected components
|
||||
if (!vuln.TryGetProperty("affects", out var affects) ||
|
||||
affects.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_CDX_006",
|
||||
$"Vulnerability {vulnerabilityId} has no affects array",
|
||||
"vulnerabilities[].affects"));
|
||||
skipped++;
|
||||
return statements;
|
||||
}
|
||||
|
||||
var localIndex = 0;
|
||||
foreach (var affect in affects.EnumerateArray())
|
||||
{
|
||||
var refStr = affect.TryGetProperty("ref", out var refProp) ? refProp.GetString() : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(refStr))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var product = ResolveProduct(refStr, componentLookup);
|
||||
if (product == null)
|
||||
{
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_CDX_007",
|
||||
$"Could not resolve component ref '{refStr}'",
|
||||
"vulnerabilities[].affects[].ref"));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract analysis/status
|
||||
var status = VexStatus.UnderInvestigation;
|
||||
VexJustification? justification = null;
|
||||
string? statusNotes = null;
|
||||
string? actionStatement = null;
|
||||
|
||||
if (vuln.TryGetProperty("analysis", out var analysis))
|
||||
{
|
||||
var stateStr = analysis.TryGetProperty("state", out var state) ? state.GetString() : null;
|
||||
status = MapAnalysisState(stateStr) ?? VexStatus.UnderInvestigation;
|
||||
|
||||
var justificationStr = analysis.TryGetProperty("justification", out var just) ? just.GetString() : null;
|
||||
justification = MapJustification(justificationStr);
|
||||
|
||||
statusNotes = analysis.TryGetProperty("detail", out var detail) ? detail.GetString() : null;
|
||||
|
||||
if (analysis.TryGetProperty("response", out var response) &&
|
||||
response.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var responses = new List<string>();
|
||||
foreach (var r in response.EnumerateArray())
|
||||
{
|
||||
var rStr = r.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(rStr))
|
||||
{
|
||||
responses.Add(rStr);
|
||||
}
|
||||
}
|
||||
|
||||
if (responses.Count > 0)
|
||||
{
|
||||
actionStatement = string.Join(", ", responses);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract timestamps
|
||||
DateTimeOffset? firstSeen = null;
|
||||
DateTimeOffset? lastSeen = null;
|
||||
|
||||
if (vuln.TryGetProperty("created", out var created) &&
|
||||
created.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(created.GetString(), out var parsed))
|
||||
{
|
||||
firstSeen = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (vuln.TryGetProperty("updated", out var updated) &&
|
||||
updated.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(updated.GetString(), out var parsed))
|
||||
{
|
||||
lastSeen = parsed;
|
||||
}
|
||||
}
|
||||
else if (vuln.TryGetProperty("published", out var published) &&
|
||||
published.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(published.GetString(), out var parsed))
|
||||
{
|
||||
lastSeen = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract version ranges if specified
|
||||
VersionRange? versions = null;
|
||||
if (affect.TryGetProperty("versions", out var versionsArray) &&
|
||||
versionsArray.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var affectedVersions = new List<string>();
|
||||
var fixedVersions = new List<string>();
|
||||
|
||||
foreach (var ver in versionsArray.EnumerateArray())
|
||||
{
|
||||
var verStr = ver.TryGetProperty("version", out var v) ? v.GetString() : null;
|
||||
var statusStr = ver.TryGetProperty("status", out var s) ? s.GetString() : null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(verStr))
|
||||
{
|
||||
if (statusStr?.Equals("affected", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
affectedVersions.Add(verStr);
|
||||
}
|
||||
else if (statusStr?.Equals("unaffected", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
fixedVersions.Add(verStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (affectedVersions.Count > 0 || fixedVersions.Count > 0)
|
||||
{
|
||||
versions = new VersionRange(
|
||||
Affected: affectedVersions.Count > 0 ? affectedVersions : null,
|
||||
Fixed: fixedVersions.Count > 0 ? fixedVersions : null,
|
||||
Unaffected: null);
|
||||
}
|
||||
}
|
||||
|
||||
statements.Add(new NormalizedStatement(
|
||||
StatementId: $"stmt-{startIndex + localIndex}",
|
||||
VulnerabilityId: vulnerabilityId,
|
||||
VulnerabilityAliases: aliases.Count > 0 ? aliases : null,
|
||||
Product: product,
|
||||
Status: status,
|
||||
StatusNotes: statusNotes,
|
||||
Justification: justification,
|
||||
ImpactStatement: null,
|
||||
ActionStatement: actionStatement,
|
||||
ActionStatementTimestamp: null,
|
||||
Versions: versions,
|
||||
Subcomponents: null,
|
||||
FirstSeen: firstSeen,
|
||||
LastSeen: lastSeen ?? firstSeen));
|
||||
|
||||
localIndex++;
|
||||
}
|
||||
|
||||
if (statements.Count == 0)
|
||||
{
|
||||
skipped++;
|
||||
}
|
||||
|
||||
return statements;
|
||||
}
|
||||
|
||||
private static NormalizedProduct? ResolveProduct(string refStr, Dictionary<string, ComponentInfo> componentLookup)
|
||||
{
|
||||
if (componentLookup.TryGetValue(refStr, out var info))
|
||||
{
|
||||
return new NormalizedProduct(
|
||||
Key: info.Purl ?? refStr,
|
||||
Name: info.Name,
|
||||
Version: info.Version,
|
||||
Purl: info.Purl,
|
||||
Cpe: info.Cpe,
|
||||
Hashes: info.Hashes);
|
||||
}
|
||||
|
||||
// If not found in lookup, create a basic product entry
|
||||
if (refStr.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new NormalizedProduct(
|
||||
Key: refStr,
|
||||
Name: null,
|
||||
Version: null,
|
||||
Purl: refStr,
|
||||
Cpe: null,
|
||||
Hashes: null);
|
||||
}
|
||||
|
||||
return new NormalizedProduct(
|
||||
Key: refStr,
|
||||
Name: null,
|
||||
Version: null,
|
||||
Purl: null,
|
||||
Cpe: null,
|
||||
Hashes: null);
|
||||
}
|
||||
|
||||
private static VexStatus? MapAnalysisState(string? state)
|
||||
{
|
||||
return state?.ToLowerInvariant() switch
|
||||
{
|
||||
"not_affected" => VexStatus.NotAffected,
|
||||
"exploitable" or "in_triage" => VexStatus.Affected,
|
||||
"resolved" or "resolved_with_pedigree" => VexStatus.Fixed,
|
||||
"false_positive" => VexStatus.NotAffected,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static VexJustification? MapJustification(string? justification)
|
||||
{
|
||||
return justification?.ToLowerInvariant() switch
|
||||
{
|
||||
"code_not_present" => VexJustification.ComponentNotPresent,
|
||||
"code_not_reachable" => VexJustification.VulnerableCodeNotInExecutePath,
|
||||
"requires_configuration" => VexJustification.VulnerableCodeCannotBeControlledByAdversary,
|
||||
"requires_dependency" => VexJustification.ComponentNotPresent,
|
||||
"requires_environment" => VexJustification.VulnerableCodeCannotBeControlledByAdversary,
|
||||
"protected_by_compiler" or "protected_by_mitigating_control" or "protected_at_runtime" or "protected_at_perimeter" =>
|
||||
VexJustification.InlineMitigationsAlreadyExist,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeDigest(string content)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private sealed record ComponentInfo(
|
||||
string? Name,
|
||||
string? Version,
|
||||
string? Purl,
|
||||
string? Cpe,
|
||||
IReadOnlyDictionary<string, string>? Hashes);
|
||||
}
|
||||
164
src/VexLens/StellaOps.VexLens/Normalization/IVexNormalizer.cs
Normal file
164
src/VexLens/StellaOps.VexLens/Normalization/IVexNormalizer.cs
Normal file
@@ -0,0 +1,164 @@
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Normalization;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for VEX document normalizers.
|
||||
/// Each normalizer handles a specific source format (OpenVEX, CSAF, CycloneDX, etc.)
|
||||
/// </summary>
|
||||
public interface IVexNormalizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the source format this normalizer handles.
|
||||
/// </summary>
|
||||
VexSourceFormat SourceFormat { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks if this normalizer can handle the given document.
|
||||
/// </summary>
|
||||
bool CanNormalize(string content);
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a VEX document to the standard format.
|
||||
/// </summary>
|
||||
Task<NormalizationResult> NormalizeAsync(
|
||||
string content,
|
||||
NormalizationContext context,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context for normalization operation.
|
||||
/// </summary>
|
||||
public sealed record NormalizationContext(
|
||||
string? SourceUri,
|
||||
DateTimeOffset NormalizedAt,
|
||||
string Normalizer,
|
||||
IReadOnlyDictionary<string, object?>? Options);
|
||||
|
||||
/// <summary>
|
||||
/// Result of a normalization operation.
|
||||
/// </summary>
|
||||
public sealed record NormalizationResult(
|
||||
bool Success,
|
||||
NormalizedVexDocument? Document,
|
||||
IReadOnlyList<NormalizationError> Errors,
|
||||
IReadOnlyList<NormalizationWarning> Warnings,
|
||||
NormalizationMetrics Metrics)
|
||||
{
|
||||
public static NormalizationResult Successful(
|
||||
NormalizedVexDocument document,
|
||||
NormalizationMetrics metrics,
|
||||
IEnumerable<NormalizationWarning>? warnings = null)
|
||||
{
|
||||
return new NormalizationResult(
|
||||
Success: true,
|
||||
Document: document,
|
||||
Errors: [],
|
||||
Warnings: warnings?.ToList() ?? [],
|
||||
Metrics: metrics);
|
||||
}
|
||||
|
||||
public static NormalizationResult Failed(
|
||||
IEnumerable<NormalizationError> errors,
|
||||
NormalizationMetrics metrics,
|
||||
IEnumerable<NormalizationWarning>? warnings = null)
|
||||
{
|
||||
return new NormalizationResult(
|
||||
Success: false,
|
||||
Document: null,
|
||||
Errors: errors.ToList(),
|
||||
Warnings: warnings?.ToList() ?? [],
|
||||
Metrics: metrics);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error during normalization.
|
||||
/// </summary>
|
||||
public sealed record NormalizationError(
|
||||
string Code,
|
||||
string Message,
|
||||
string? Path,
|
||||
Exception? Exception);
|
||||
|
||||
/// <summary>
|
||||
/// Warning during normalization.
|
||||
/// </summary>
|
||||
public sealed record NormalizationWarning(
|
||||
string Code,
|
||||
string Message,
|
||||
string? Path);
|
||||
|
||||
/// <summary>
|
||||
/// Metrics from normalization operation.
|
||||
/// </summary>
|
||||
public sealed record NormalizationMetrics(
|
||||
TimeSpan Duration,
|
||||
int SourceBytes,
|
||||
int StatementsExtracted,
|
||||
int StatementsSkipped,
|
||||
int ProductsMapped);
|
||||
|
||||
/// <summary>
|
||||
/// Registry for VEX normalizers.
|
||||
/// </summary>
|
||||
public interface IVexNormalizerRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets all registered normalizers.
|
||||
/// </summary>
|
||||
IReadOnlyList<IVexNormalizer> Normalizers { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the normalizer for a specific source format.
|
||||
/// </summary>
|
||||
IVexNormalizer? GetNormalizer(VexSourceFormat format);
|
||||
|
||||
/// <summary>
|
||||
/// Detects the format and returns the appropriate normalizer.
|
||||
/// </summary>
|
||||
IVexNormalizer? DetectNormalizer(string content);
|
||||
|
||||
/// <summary>
|
||||
/// Registers a normalizer.
|
||||
/// </summary>
|
||||
void Register(IVexNormalizer normalizer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of the normalizer registry.
|
||||
/// </summary>
|
||||
public sealed class VexNormalizerRegistry : IVexNormalizerRegistry
|
||||
{
|
||||
private readonly Dictionary<VexSourceFormat, IVexNormalizer> _normalizers = [];
|
||||
private readonly List<IVexNormalizer> _orderedNormalizers = [];
|
||||
|
||||
public IReadOnlyList<IVexNormalizer> Normalizers => _orderedNormalizers;
|
||||
|
||||
public IVexNormalizer? GetNormalizer(VexSourceFormat format)
|
||||
{
|
||||
return _normalizers.GetValueOrDefault(format);
|
||||
}
|
||||
|
||||
public IVexNormalizer? DetectNormalizer(string content)
|
||||
{
|
||||
foreach (var normalizer in _orderedNormalizers)
|
||||
{
|
||||
if (normalizer.CanNormalize(content))
|
||||
{
|
||||
return normalizer;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public void Register(IVexNormalizer normalizer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(normalizer);
|
||||
|
||||
_normalizers[normalizer.SourceFormat] = normalizer;
|
||||
_orderedNormalizers.Add(normalizer);
|
||||
}
|
||||
}
|
||||
479
src/VexLens/StellaOps.VexLens/Normalization/OpenVexNormalizer.cs
Normal file
479
src/VexLens/StellaOps.VexLens/Normalization/OpenVexNormalizer.cs
Normal file
@@ -0,0 +1,479 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Normalization;
|
||||
|
||||
/// <summary>
|
||||
/// Normalizer for OpenVEX format documents.
|
||||
/// </summary>
|
||||
public sealed class OpenVexNormalizer : IVexNormalizer
|
||||
{
|
||||
public VexSourceFormat SourceFormat => VexSourceFormat.OpenVex;
|
||||
|
||||
public bool CanNormalize(string content)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// OpenVEX documents have @context with openvex
|
||||
if (root.TryGetProperty("@context", out var context))
|
||||
{
|
||||
var contextStr = context.GetString();
|
||||
return contextStr?.Contains("openvex", StringComparison.OrdinalIgnoreCase) == true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<NormalizationResult> NormalizeAsync(
|
||||
string content,
|
||||
NormalizationContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var warnings = new List<NormalizationWarning>();
|
||||
var statementsSkipped = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Extract document ID
|
||||
var documentId = ExtractDocumentId(root);
|
||||
if (string.IsNullOrWhiteSpace(documentId))
|
||||
{
|
||||
documentId = $"openvex:{Guid.NewGuid():N}";
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_OPENVEX_001",
|
||||
"Document ID not found; generated a random ID",
|
||||
"@id"));
|
||||
}
|
||||
|
||||
// Extract issuer
|
||||
var issuer = ExtractIssuer(root, warnings);
|
||||
|
||||
// Extract timestamps
|
||||
var issuedAt = ExtractTimestamp(root, "timestamp");
|
||||
var lastUpdatedAt = ExtractTimestamp(root, "last_updated");
|
||||
|
||||
// Extract statements
|
||||
var statements = ExtractStatements(root, warnings, ref statementsSkipped);
|
||||
|
||||
// Calculate source digest
|
||||
var sourceDigest = ComputeDigest(content);
|
||||
|
||||
// Build provenance
|
||||
var provenance = new NormalizationProvenance(
|
||||
NormalizedAt: context.NormalizedAt,
|
||||
Normalizer: context.Normalizer,
|
||||
SourceRevision: null,
|
||||
TransformationRules: ["openvex-to-normalized-v1"]);
|
||||
|
||||
var normalizedDoc = new NormalizedVexDocument(
|
||||
SchemaVersion: NormalizedVexDocument.CurrentSchemaVersion,
|
||||
DocumentId: documentId,
|
||||
SourceFormat: VexSourceFormat.OpenVex,
|
||||
SourceDigest: sourceDigest,
|
||||
SourceUri: context.SourceUri,
|
||||
Issuer: issuer,
|
||||
IssuedAt: issuedAt,
|
||||
LastUpdatedAt: lastUpdatedAt,
|
||||
Statements: statements,
|
||||
Provenance: provenance);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
return Task.FromResult(NormalizationResult.Successful(
|
||||
normalizedDoc,
|
||||
new NormalizationMetrics(
|
||||
Duration: stopwatch.Elapsed,
|
||||
SourceBytes: Encoding.UTF8.GetByteCount(content),
|
||||
StatementsExtracted: statements.Count,
|
||||
StatementsSkipped: statementsSkipped,
|
||||
ProductsMapped: statements.Count),
|
||||
warnings));
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return Task.FromResult(NormalizationResult.Failed(
|
||||
[new NormalizationError("ERR_OPENVEX_001", "Invalid JSON", ex.Path, ex)],
|
||||
new NormalizationMetrics(
|
||||
Duration: stopwatch.Elapsed,
|
||||
SourceBytes: Encoding.UTF8.GetByteCount(content),
|
||||
StatementsExtracted: 0,
|
||||
StatementsSkipped: 0,
|
||||
ProductsMapped: 0),
|
||||
warnings));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return Task.FromResult(NormalizationResult.Failed(
|
||||
[new NormalizationError("ERR_OPENVEX_999", "Unexpected error during normalization", null, ex)],
|
||||
new NormalizationMetrics(
|
||||
Duration: stopwatch.Elapsed,
|
||||
SourceBytes: Encoding.UTF8.GetByteCount(content),
|
||||
StatementsExtracted: 0,
|
||||
StatementsSkipped: 0,
|
||||
ProductsMapped: 0),
|
||||
warnings));
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractDocumentId(JsonElement root)
|
||||
{
|
||||
if (root.TryGetProperty("@id", out var id))
|
||||
{
|
||||
return id.GetString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static VexIssuer? ExtractIssuer(JsonElement root, List<NormalizationWarning> warnings)
|
||||
{
|
||||
if (!root.TryGetProperty("author", out var author))
|
||||
{
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_OPENVEX_002",
|
||||
"No author/issuer found in document",
|
||||
"author"));
|
||||
return null;
|
||||
}
|
||||
|
||||
var issuerId = author.TryGetProperty("@id", out var idProp)
|
||||
? idProp.GetString() ?? "unknown"
|
||||
: "unknown";
|
||||
|
||||
var issuerName = author.TryGetProperty("name", out var nameProp)
|
||||
? nameProp.GetString() ?? issuerId
|
||||
: issuerId;
|
||||
|
||||
var role = author.TryGetProperty("role", out var roleProp)
|
||||
? roleProp.GetString()
|
||||
: null;
|
||||
|
||||
var category = MapRoleToCategory(role);
|
||||
|
||||
return new VexIssuer(
|
||||
Id: issuerId,
|
||||
Name: issuerName,
|
||||
Category: category,
|
||||
TrustTier: TrustTier.Unknown,
|
||||
KeyFingerprints: null);
|
||||
}
|
||||
|
||||
private static IssuerCategory? MapRoleToCategory(string? role)
|
||||
{
|
||||
return role?.ToLowerInvariant() switch
|
||||
{
|
||||
"vendor" => IssuerCategory.Vendor,
|
||||
"distributor" => IssuerCategory.Distributor,
|
||||
"maintainer" or "community" => IssuerCategory.Community,
|
||||
"aggregator" => IssuerCategory.Aggregator,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ExtractTimestamp(JsonElement root, string propertyName)
|
||||
{
|
||||
if (root.TryGetProperty(propertyName, out var prop) &&
|
||||
prop.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var str = prop.GetString();
|
||||
if (DateTimeOffset.TryParse(str, out var result))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<NormalizedStatement> ExtractStatements(
|
||||
JsonElement root,
|
||||
List<NormalizationWarning> warnings,
|
||||
ref int skipped)
|
||||
{
|
||||
if (!root.TryGetProperty("statements", out var statementsArray) ||
|
||||
statementsArray.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_OPENVEX_003",
|
||||
"No statements array found",
|
||||
"statements"));
|
||||
return [];
|
||||
}
|
||||
|
||||
var statements = new List<NormalizedStatement>();
|
||||
var index = 0;
|
||||
|
||||
foreach (var stmt in statementsArray.EnumerateArray())
|
||||
{
|
||||
var statement = ExtractStatement(stmt, index, warnings, ref skipped);
|
||||
if (statement != null)
|
||||
{
|
||||
statements.Add(statement);
|
||||
}
|
||||
|
||||
index++;
|
||||
}
|
||||
|
||||
return statements;
|
||||
}
|
||||
|
||||
private static NormalizedStatement? ExtractStatement(
|
||||
JsonElement stmt,
|
||||
int index,
|
||||
List<NormalizationWarning> warnings,
|
||||
ref int skipped)
|
||||
{
|
||||
// Extract vulnerability
|
||||
string? vulnerabilityId = null;
|
||||
var aliases = new List<string>();
|
||||
|
||||
if (stmt.TryGetProperty("vulnerability", out var vuln))
|
||||
{
|
||||
if (vuln.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
vulnerabilityId = vuln.GetString();
|
||||
}
|
||||
else if (vuln.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
vulnerabilityId = vuln.TryGetProperty("@id", out var vulnId)
|
||||
? vulnId.GetString()
|
||||
: vuln.TryGetProperty("name", out var vulnName)
|
||||
? vulnName.GetString()
|
||||
: null;
|
||||
|
||||
if (vuln.TryGetProperty("aliases", out var aliasArray) &&
|
||||
aliasArray.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var alias in aliasArray.EnumerateArray())
|
||||
{
|
||||
if (alias.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var aliasStr = alias.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(aliasStr))
|
||||
{
|
||||
aliases.Add(aliasStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(vulnerabilityId))
|
||||
{
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_OPENVEX_004",
|
||||
"Statement missing vulnerability ID; skipped",
|
||||
$"statements[{index}].vulnerability"));
|
||||
skipped++;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract products
|
||||
var products = new List<NormalizedProduct>();
|
||||
if (stmt.TryGetProperty("products", out var productsArray) &&
|
||||
productsArray.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var prod in productsArray.EnumerateArray())
|
||||
{
|
||||
var product = ExtractProduct(prod);
|
||||
if (product != null)
|
||||
{
|
||||
products.Add(product);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (products.Count == 0)
|
||||
{
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_OPENVEX_005",
|
||||
"Statement has no valid products; skipped",
|
||||
$"statements[{index}].products"));
|
||||
skipped++;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract status
|
||||
var statusStr = stmt.TryGetProperty("status", out var statusProp)
|
||||
? statusProp.GetString()
|
||||
: null;
|
||||
|
||||
var status = MapStatus(statusStr);
|
||||
if (!status.HasValue)
|
||||
{
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_OPENVEX_006",
|
||||
$"Unknown status '{statusStr}'; defaulting to under_investigation",
|
||||
$"statements[{index}].status"));
|
||||
status = VexStatus.UnderInvestigation;
|
||||
}
|
||||
|
||||
// Extract justification
|
||||
var justificationStr = stmt.TryGetProperty("justification", out var justProp)
|
||||
? justProp.GetString()
|
||||
: null;
|
||||
|
||||
var justification = MapJustification(justificationStr);
|
||||
|
||||
// Extract other fields
|
||||
var statusNotes = stmt.TryGetProperty("status_notes", out var notesProp)
|
||||
? notesProp.GetString()
|
||||
: null;
|
||||
|
||||
var impactStatement = stmt.TryGetProperty("impact_statement", out var impactProp)
|
||||
? impactProp.GetString()
|
||||
: null;
|
||||
|
||||
var actionStatement = stmt.TryGetProperty("action_statement", out var actionProp)
|
||||
? actionProp.GetString()
|
||||
: null;
|
||||
|
||||
var actionTimestamp = stmt.TryGetProperty("action_statement_timestamp", out var actionTsProp)
|
||||
? ExtractTimestamp(actionTsProp)
|
||||
: null;
|
||||
|
||||
var timestamp = ExtractTimestamp(stmt, "timestamp");
|
||||
|
||||
// For OpenVEX, create one statement per product
|
||||
var primaryProduct = products[0];
|
||||
var subcomponents = products.Count > 1 ? products.Skip(1).ToList() : null;
|
||||
|
||||
return new NormalizedStatement(
|
||||
StatementId: $"stmt-{index}",
|
||||
VulnerabilityId: vulnerabilityId,
|
||||
VulnerabilityAliases: aliases.Count > 0 ? aliases : null,
|
||||
Product: primaryProduct,
|
||||
Status: status.Value,
|
||||
StatusNotes: statusNotes,
|
||||
Justification: justification,
|
||||
ImpactStatement: impactStatement,
|
||||
ActionStatement: actionStatement,
|
||||
ActionStatementTimestamp: actionTimestamp,
|
||||
Versions: null,
|
||||
Subcomponents: subcomponents,
|
||||
FirstSeen: timestamp,
|
||||
LastSeen: timestamp);
|
||||
}
|
||||
|
||||
private static NormalizedProduct? ExtractProduct(JsonElement prod)
|
||||
{
|
||||
string? key = null;
|
||||
string? name = null;
|
||||
string? version = null;
|
||||
string? purl = null;
|
||||
string? cpe = null;
|
||||
|
||||
if (prod.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
key = prod.GetString();
|
||||
if (key?.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
purl = key;
|
||||
}
|
||||
else if (key?.StartsWith("cpe:", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
cpe = key;
|
||||
}
|
||||
}
|
||||
else if (prod.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
key = prod.TryGetProperty("@id", out var idProp) ? idProp.GetString() : null;
|
||||
name = prod.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : null;
|
||||
version = prod.TryGetProperty("version", out var versionProp) ? versionProp.GetString() : null;
|
||||
|
||||
if (prod.TryGetProperty("identifiers", out var identifiers) &&
|
||||
identifiers.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
purl = identifiers.TryGetProperty("purl", out var purlProp) ? purlProp.GetString() : null;
|
||||
cpe = identifiers.TryGetProperty("cpe23", out var cpeProp) ? cpeProp.GetString() : null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(purl) &&
|
||||
prod.TryGetProperty("purl", out var directPurl))
|
||||
{
|
||||
purl = directPurl.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(key) && string.IsNullOrWhiteSpace(purl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new NormalizedProduct(
|
||||
Key: key ?? purl ?? cpe ?? $"unknown-{Guid.NewGuid():N}",
|
||||
Name: name,
|
||||
Version: version,
|
||||
Purl: purl,
|
||||
Cpe: cpe,
|
||||
Hashes: null);
|
||||
}
|
||||
|
||||
private static VexStatus? MapStatus(string? status)
|
||||
{
|
||||
return status?.ToLowerInvariant() switch
|
||||
{
|
||||
"not_affected" => VexStatus.NotAffected,
|
||||
"affected" => VexStatus.Affected,
|
||||
"fixed" => VexStatus.Fixed,
|
||||
"under_investigation" => VexStatus.UnderInvestigation,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static VexJustification? MapJustification(string? justification)
|
||||
{
|
||||
return justification?.ToLowerInvariant() switch
|
||||
{
|
||||
"component_not_present" => VexJustification.ComponentNotPresent,
|
||||
"vulnerable_code_not_present" => VexJustification.VulnerableCodeNotPresent,
|
||||
"vulnerable_code_not_in_execute_path" => VexJustification.VulnerableCodeNotInExecutePath,
|
||||
"vulnerable_code_cannot_be_controlled_by_adversary" => VexJustification.VulnerableCodeCannotBeControlledByAdversary,
|
||||
"inline_mitigations_already_exist" => VexJustification.InlineMitigationsAlreadyExist,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ExtractTimestamp(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var str = element.GetString();
|
||||
if (DateTimeOffset.TryParse(str, out var result))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ComputeDigest(string content)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
452
src/VexLens/StellaOps.VexLens/Observability/VexLensMetrics.cs
Normal file
452
src/VexLens/StellaOps.VexLens/Observability/VexLensMetrics.cs
Normal file
@@ -0,0 +1,452 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// Metrics for VexLens operations.
|
||||
/// </summary>
|
||||
public sealed class VexLensMetrics : IDisposable
|
||||
{
|
||||
private readonly Meter _meter;
|
||||
|
||||
// Normalization metrics
|
||||
private readonly Counter<long> _documentsNormalized;
|
||||
private readonly Counter<long> _normalizationErrors;
|
||||
private readonly Histogram<double> _normalizationDuration;
|
||||
private readonly Counter<long> _statementsExtracted;
|
||||
private readonly Counter<long> _statementsSkipped;
|
||||
|
||||
// Product mapping metrics
|
||||
private readonly Counter<long> _productsMapped;
|
||||
private readonly Counter<long> _productMappingErrors;
|
||||
private readonly Histogram<double> _productMappingDuration;
|
||||
|
||||
// Signature verification metrics
|
||||
private readonly Counter<long> _signaturesVerified;
|
||||
private readonly Counter<long> _signatureVerificationFailures;
|
||||
private readonly Histogram<double> _signatureVerificationDuration;
|
||||
|
||||
// Trust weight metrics
|
||||
private readonly Counter<long> _trustWeightsComputed;
|
||||
private readonly Histogram<double> _trustWeightValue;
|
||||
private readonly Histogram<double> _trustWeightComputationDuration;
|
||||
|
||||
// Consensus metrics
|
||||
private readonly Counter<long> _consensusComputed;
|
||||
private readonly Counter<long> _consensusConflicts;
|
||||
private readonly Histogram<double> _consensusConfidence;
|
||||
private readonly Histogram<double> _consensusComputationDuration;
|
||||
private readonly Counter<long> _statusChanges;
|
||||
|
||||
// Projection metrics
|
||||
private readonly Counter<long> _projectionsStored;
|
||||
private readonly Counter<long> _projectionsQueried;
|
||||
private readonly Histogram<double> _projectionQueryDuration;
|
||||
|
||||
// Issuer directory metrics
|
||||
private readonly Counter<long> _issuersRegistered;
|
||||
private readonly Counter<long> _issuersRevoked;
|
||||
private readonly Counter<long> _keysRegistered;
|
||||
private readonly Counter<long> _keysRevoked;
|
||||
private readonly Counter<long> _trustValidations;
|
||||
|
||||
public VexLensMetrics(IMeterFactory? meterFactory = null)
|
||||
{
|
||||
_meter = meterFactory?.Create("StellaOps.VexLens") ?? new Meter("StellaOps.VexLens", "1.0.0");
|
||||
|
||||
// Normalization
|
||||
_documentsNormalized = _meter.CreateCounter<long>(
|
||||
"vexlens.normalization.documents_total",
|
||||
"documents",
|
||||
"Total number of VEX documents normalized");
|
||||
|
||||
_normalizationErrors = _meter.CreateCounter<long>(
|
||||
"vexlens.normalization.errors_total",
|
||||
"errors",
|
||||
"Total number of normalization errors");
|
||||
|
||||
_normalizationDuration = _meter.CreateHistogram<double>(
|
||||
"vexlens.normalization.duration_seconds",
|
||||
"s",
|
||||
"Duration of normalization operations");
|
||||
|
||||
_statementsExtracted = _meter.CreateCounter<long>(
|
||||
"vexlens.normalization.statements_extracted_total",
|
||||
"statements",
|
||||
"Total number of statements extracted during normalization");
|
||||
|
||||
_statementsSkipped = _meter.CreateCounter<long>(
|
||||
"vexlens.normalization.statements_skipped_total",
|
||||
"statements",
|
||||
"Total number of statements skipped during normalization");
|
||||
|
||||
// Product mapping
|
||||
_productsMapped = _meter.CreateCounter<long>(
|
||||
"vexlens.product_mapping.products_total",
|
||||
"products",
|
||||
"Total number of products mapped");
|
||||
|
||||
_productMappingErrors = _meter.CreateCounter<long>(
|
||||
"vexlens.product_mapping.errors_total",
|
||||
"errors",
|
||||
"Total number of product mapping errors");
|
||||
|
||||
_productMappingDuration = _meter.CreateHistogram<double>(
|
||||
"vexlens.product_mapping.duration_seconds",
|
||||
"s",
|
||||
"Duration of product mapping operations");
|
||||
|
||||
// Signature verification
|
||||
_signaturesVerified = _meter.CreateCounter<long>(
|
||||
"vexlens.signature.verified_total",
|
||||
"signatures",
|
||||
"Total number of signatures verified");
|
||||
|
||||
_signatureVerificationFailures = _meter.CreateCounter<long>(
|
||||
"vexlens.signature.failures_total",
|
||||
"failures",
|
||||
"Total number of signature verification failures");
|
||||
|
||||
_signatureVerificationDuration = _meter.CreateHistogram<double>(
|
||||
"vexlens.signature.duration_seconds",
|
||||
"s",
|
||||
"Duration of signature verification operations");
|
||||
|
||||
// Trust weight
|
||||
_trustWeightsComputed = _meter.CreateCounter<long>(
|
||||
"vexlens.trust.weights_computed_total",
|
||||
"computations",
|
||||
"Total number of trust weights computed");
|
||||
|
||||
_trustWeightValue = _meter.CreateHistogram<double>(
|
||||
"vexlens.trust.weight_value",
|
||||
"{weight}",
|
||||
"Distribution of computed trust weight values");
|
||||
|
||||
_trustWeightComputationDuration = _meter.CreateHistogram<double>(
|
||||
"vexlens.trust.computation_duration_seconds",
|
||||
"s",
|
||||
"Duration of trust weight computation");
|
||||
|
||||
// Consensus
|
||||
_consensusComputed = _meter.CreateCounter<long>(
|
||||
"vexlens.consensus.computed_total",
|
||||
"computations",
|
||||
"Total number of consensus computations");
|
||||
|
||||
_consensusConflicts = _meter.CreateCounter<long>(
|
||||
"vexlens.consensus.conflicts_total",
|
||||
"conflicts",
|
||||
"Total number of conflicts detected during consensus");
|
||||
|
||||
_consensusConfidence = _meter.CreateHistogram<double>(
|
||||
"vexlens.consensus.confidence",
|
||||
"{confidence}",
|
||||
"Distribution of consensus confidence scores");
|
||||
|
||||
_consensusComputationDuration = _meter.CreateHistogram<double>(
|
||||
"vexlens.consensus.duration_seconds",
|
||||
"s",
|
||||
"Duration of consensus computation");
|
||||
|
||||
_statusChanges = _meter.CreateCounter<long>(
|
||||
"vexlens.consensus.status_changes_total",
|
||||
"changes",
|
||||
"Total number of status changes detected");
|
||||
|
||||
// Projections
|
||||
_projectionsStored = _meter.CreateCounter<long>(
|
||||
"vexlens.projection.stored_total",
|
||||
"projections",
|
||||
"Total number of projections stored");
|
||||
|
||||
_projectionsQueried = _meter.CreateCounter<long>(
|
||||
"vexlens.projection.queries_total",
|
||||
"queries",
|
||||
"Total number of projection queries");
|
||||
|
||||
_projectionQueryDuration = _meter.CreateHistogram<double>(
|
||||
"vexlens.projection.query_duration_seconds",
|
||||
"s",
|
||||
"Duration of projection queries");
|
||||
|
||||
// Issuer directory
|
||||
_issuersRegistered = _meter.CreateCounter<long>(
|
||||
"vexlens.issuer.registered_total",
|
||||
"issuers",
|
||||
"Total number of issuers registered");
|
||||
|
||||
_issuersRevoked = _meter.CreateCounter<long>(
|
||||
"vexlens.issuer.revoked_total",
|
||||
"issuers",
|
||||
"Total number of issuers revoked");
|
||||
|
||||
_keysRegistered = _meter.CreateCounter<long>(
|
||||
"vexlens.issuer.keys_registered_total",
|
||||
"keys",
|
||||
"Total number of keys registered");
|
||||
|
||||
_keysRevoked = _meter.CreateCounter<long>(
|
||||
"vexlens.issuer.keys_revoked_total",
|
||||
"keys",
|
||||
"Total number of keys revoked");
|
||||
|
||||
_trustValidations = _meter.CreateCounter<long>(
|
||||
"vexlens.issuer.trust_validations_total",
|
||||
"validations",
|
||||
"Total number of trust validations");
|
||||
}
|
||||
|
||||
// Normalization
|
||||
public void RecordNormalization(VexSourceFormat format, bool success, TimeSpan duration, int statementsExtracted, int statementsSkipped)
|
||||
{
|
||||
var tags = new TagList { { "format", format.ToString() }, { "success", success.ToString() } };
|
||||
_documentsNormalized.Add(1, tags);
|
||||
_normalizationDuration.Record(duration.TotalSeconds, tags);
|
||||
_statementsExtracted.Add(statementsExtracted, tags);
|
||||
_statementsSkipped.Add(statementsSkipped, tags);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
_normalizationErrors.Add(1, tags);
|
||||
}
|
||||
}
|
||||
|
||||
// Product mapping
|
||||
public void RecordProductMapping(bool success, TimeSpan duration, string? ecosystem = null)
|
||||
{
|
||||
var tags = new TagList { { "success", success.ToString() } };
|
||||
if (ecosystem != null) tags.Add("ecosystem", ecosystem);
|
||||
|
||||
_productsMapped.Add(1, tags);
|
||||
_productMappingDuration.Record(duration.TotalSeconds, tags);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
_productMappingErrors.Add(1, tags);
|
||||
}
|
||||
}
|
||||
|
||||
// Signature verification
|
||||
public void RecordSignatureVerification(string format, bool valid, TimeSpan duration)
|
||||
{
|
||||
var tags = new TagList { { "format", format }, { "valid", valid.ToString() } };
|
||||
_signaturesVerified.Add(1, tags);
|
||||
_signatureVerificationDuration.Record(duration.TotalSeconds, tags);
|
||||
|
||||
if (!valid)
|
||||
{
|
||||
_signatureVerificationFailures.Add(1, tags);
|
||||
}
|
||||
}
|
||||
|
||||
// Trust weight
|
||||
public void RecordTrustWeightComputation(double weight, TimeSpan duration, string? issuerCategory = null)
|
||||
{
|
||||
var tags = new TagList();
|
||||
if (issuerCategory != null) tags.Add("issuer_category", issuerCategory);
|
||||
|
||||
_trustWeightsComputed.Add(1, tags);
|
||||
_trustWeightValue.Record(weight, tags);
|
||||
_trustWeightComputationDuration.Record(duration.TotalSeconds, tags);
|
||||
}
|
||||
|
||||
// Consensus
|
||||
public void RecordConsensusComputation(
|
||||
VexStatus status,
|
||||
ConsensusOutcome outcome,
|
||||
double confidence,
|
||||
int conflictCount,
|
||||
bool statusChanged,
|
||||
TimeSpan duration)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "status", status.ToString() },
|
||||
{ "outcome", outcome.ToString() }
|
||||
};
|
||||
|
||||
_consensusComputed.Add(1, tags);
|
||||
_consensusConfidence.Record(confidence, tags);
|
||||
_consensusComputationDuration.Record(duration.TotalSeconds, tags);
|
||||
|
||||
if (conflictCount > 0)
|
||||
{
|
||||
_consensusConflicts.Add(conflictCount, tags);
|
||||
}
|
||||
|
||||
if (statusChanged)
|
||||
{
|
||||
_statusChanges.Add(1, tags);
|
||||
}
|
||||
}
|
||||
|
||||
// Projections
|
||||
public void RecordProjectionStored(VexStatus status, bool statusChanged)
|
||||
{
|
||||
var tags = new TagList { { "status", status.ToString() }, { "status_changed", statusChanged.ToString() } };
|
||||
_projectionsStored.Add(1, tags);
|
||||
}
|
||||
|
||||
public void RecordProjectionQuery(TimeSpan duration, int resultCount)
|
||||
{
|
||||
var tags = new TagList { { "result_count_bucket", GetCountBucket(resultCount) } };
|
||||
_projectionsQueried.Add(1, tags);
|
||||
_projectionQueryDuration.Record(duration.TotalSeconds, tags);
|
||||
}
|
||||
|
||||
// Issuer directory
|
||||
public void RecordIssuerRegistered(string category, string trustTier)
|
||||
{
|
||||
var tags = new TagList { { "category", category }, { "trust_tier", trustTier } };
|
||||
_issuersRegistered.Add(1, tags);
|
||||
}
|
||||
|
||||
public void RecordIssuerRevoked(string category)
|
||||
{
|
||||
var tags = new TagList { { "category", category } };
|
||||
_issuersRevoked.Add(1, tags);
|
||||
}
|
||||
|
||||
public void RecordKeyRegistered(string keyType)
|
||||
{
|
||||
var tags = new TagList { { "key_type", keyType } };
|
||||
_keysRegistered.Add(1, tags);
|
||||
}
|
||||
|
||||
public void RecordKeyRevoked(string keyType)
|
||||
{
|
||||
var tags = new TagList { { "key_type", keyType } };
|
||||
_keysRevoked.Add(1, tags);
|
||||
}
|
||||
|
||||
public void RecordTrustValidation(bool trusted, string? issuerStatus = null)
|
||||
{
|
||||
var tags = new TagList { { "trusted", trusted.ToString() } };
|
||||
if (issuerStatus != null) tags.Add("issuer_status", issuerStatus);
|
||||
_trustValidations.Add(1, tags);
|
||||
}
|
||||
|
||||
private static string GetCountBucket(int count)
|
||||
{
|
||||
return count switch
|
||||
{
|
||||
0 => "0",
|
||||
<= 10 => "1-10",
|
||||
<= 100 => "11-100",
|
||||
<= 1000 => "101-1000",
|
||||
_ => "1000+"
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_meter.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Activity source for VexLens tracing.
|
||||
/// </summary>
|
||||
public static class VexLensActivitySource
|
||||
{
|
||||
public static readonly ActivitySource Source = new("StellaOps.VexLens", "1.0.0");
|
||||
|
||||
public static Activity? StartNormalizationActivity(string format)
|
||||
{
|
||||
return Source.StartActivity("vexlens.normalize", ActivityKind.Internal)?
|
||||
.SetTag("vex.format", format);
|
||||
}
|
||||
|
||||
public static Activity? StartProductMappingActivity()
|
||||
{
|
||||
return Source.StartActivity("vexlens.map_product", ActivityKind.Internal);
|
||||
}
|
||||
|
||||
public static Activity? StartSignatureVerificationActivity(string format)
|
||||
{
|
||||
return Source.StartActivity("vexlens.verify_signature", ActivityKind.Internal)?
|
||||
.SetTag("signature.format", format);
|
||||
}
|
||||
|
||||
public static Activity? StartTrustWeightActivity()
|
||||
{
|
||||
return Source.StartActivity("vexlens.compute_trust_weight", ActivityKind.Internal);
|
||||
}
|
||||
|
||||
public static Activity? StartConsensusActivity(string vulnerabilityId, string productKey)
|
||||
{
|
||||
return Source.StartActivity("vexlens.compute_consensus", ActivityKind.Internal)?
|
||||
.SetTag("vulnerability.id", vulnerabilityId)
|
||||
.SetTag("product.key", productKey);
|
||||
}
|
||||
|
||||
public static Activity? StartProjectionStoreActivity()
|
||||
{
|
||||
return Source.StartActivity("vexlens.store_projection", ActivityKind.Internal);
|
||||
}
|
||||
|
||||
public static Activity? StartProjectionQueryActivity()
|
||||
{
|
||||
return Source.StartActivity("vexlens.query_projections", ActivityKind.Internal);
|
||||
}
|
||||
|
||||
public static Activity? StartIssuerOperationActivity(string operation)
|
||||
{
|
||||
return Source.StartActivity($"vexlens.issuer.{operation}", ActivityKind.Internal);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logging event IDs for VexLens.
|
||||
/// </summary>
|
||||
public static class VexLensLogEvents
|
||||
{
|
||||
// Normalization
|
||||
public const int NormalizationStarted = 1001;
|
||||
public const int NormalizationCompleted = 1002;
|
||||
public const int NormalizationFailed = 1003;
|
||||
public const int StatementSkipped = 1004;
|
||||
|
||||
// Product mapping
|
||||
public const int ProductMappingStarted = 2001;
|
||||
public const int ProductMappingCompleted = 2002;
|
||||
public const int ProductMappingFailed = 2003;
|
||||
public const int PurlParseError = 2004;
|
||||
public const int CpeParseError = 2005;
|
||||
|
||||
// Signature verification
|
||||
public const int SignatureVerificationStarted = 3001;
|
||||
public const int SignatureVerificationCompleted = 3002;
|
||||
public const int SignatureVerificationFailed = 3003;
|
||||
public const int SignatureInvalid = 3004;
|
||||
public const int CertificateExpired = 3005;
|
||||
public const int CertificateRevoked = 3006;
|
||||
|
||||
// Trust weight
|
||||
public const int TrustWeightComputed = 4001;
|
||||
public const int LowTrustWeight = 4002;
|
||||
|
||||
// Consensus
|
||||
public const int ConsensusStarted = 5001;
|
||||
public const int ConsensusCompleted = 5002;
|
||||
public const int ConsensusFailed = 5003;
|
||||
public const int ConflictDetected = 5004;
|
||||
public const int StatusChanged = 5005;
|
||||
public const int NoStatementsAvailable = 5006;
|
||||
|
||||
// Projections
|
||||
public const int ProjectionStored = 6001;
|
||||
public const int ProjectionQueried = 6002;
|
||||
public const int ProjectionPurged = 6003;
|
||||
|
||||
// Issuer directory
|
||||
public const int IssuerRegistered = 7001;
|
||||
public const int IssuerRevoked = 7002;
|
||||
public const int KeyRegistered = 7003;
|
||||
public const int KeyRevoked = 7004;
|
||||
public const int TrustValidationFailed = 7005;
|
||||
}
|
||||
264
src/VexLens/StellaOps.VexLens/Options/VexLensOptions.cs
Normal file
264
src/VexLens/StellaOps.VexLens/Options/VexLensOptions.cs
Normal file
@@ -0,0 +1,264 @@
|
||||
namespace StellaOps.VexLens.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for VexLens consensus engine.
|
||||
/// </summary>
|
||||
public sealed class VexLensOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Section name for configuration binding.
|
||||
/// </summary>
|
||||
public const string SectionName = "VexLens";
|
||||
|
||||
/// <summary>
|
||||
/// Storage configuration.
|
||||
/// </summary>
|
||||
public VexLensStorageOptions Storage { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Trust engine configuration.
|
||||
/// </summary>
|
||||
public VexLensTrustOptions Trust { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Consensus computation configuration.
|
||||
/// </summary>
|
||||
public VexLensConsensusOptions Consensus { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Normalization configuration.
|
||||
/// </summary>
|
||||
public VexLensNormalizationOptions Normalization { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Air-gap mode configuration.
|
||||
/// </summary>
|
||||
public VexLensAirGapOptions AirGap { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Telemetry configuration.
|
||||
/// </summary>
|
||||
public VexLensTelemetryOptions Telemetry { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Storage configuration for VexLens projections.
|
||||
/// </summary>
|
||||
public sealed class VexLensStorageOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Storage driver: "memory" for testing, "mongo" for production.
|
||||
/// Default is "memory".
|
||||
/// </summary>
|
||||
public string Driver { get; set; } = "memory";
|
||||
|
||||
/// <summary>
|
||||
/// MongoDB connection string when using mongo driver.
|
||||
/// </summary>
|
||||
public string? ConnectionString { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Database name for MongoDB storage.
|
||||
/// </summary>
|
||||
public string? Database { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Collection name for consensus projections.
|
||||
/// </summary>
|
||||
public string ProjectionsCollection { get; set; } = "vex_consensus";
|
||||
|
||||
/// <summary>
|
||||
/// Collection name for projection history.
|
||||
/// </summary>
|
||||
public string HistoryCollection { get; set; } = "vex_consensus_history";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum history entries to retain per projection.
|
||||
/// </summary>
|
||||
public int MaxHistoryEntries { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Command timeout in seconds.
|
||||
/// </summary>
|
||||
public int CommandTimeoutSeconds { get; set; } = 30;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trust engine configuration.
|
||||
/// </summary>
|
||||
public sealed class VexLensTrustOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Base weight for Authoritative tier issuers (0.0-1.0).
|
||||
/// </summary>
|
||||
public double AuthoritativeWeight { get; set; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// Base weight for Trusted tier issuers (0.0-1.0).
|
||||
/// </summary>
|
||||
public double TrustedWeight { get; set; } = 0.8;
|
||||
|
||||
/// <summary>
|
||||
/// Base weight for Known tier issuers (0.0-1.0).
|
||||
/// </summary>
|
||||
public double KnownWeight { get; set; } = 0.5;
|
||||
|
||||
/// <summary>
|
||||
/// Base weight for Unknown tier issuers (0.0-1.0).
|
||||
/// </summary>
|
||||
public double UnknownWeight { get; set; } = 0.3;
|
||||
|
||||
/// <summary>
|
||||
/// Base weight for Untrusted tier issuers (0.0-1.0).
|
||||
/// </summary>
|
||||
public double UntrustedWeight { get; set; } = 0.1;
|
||||
|
||||
/// <summary>
|
||||
/// Weight multiplier when statement has valid signature.
|
||||
/// </summary>
|
||||
public double SignedMultiplier { get; set; } = 1.2;
|
||||
|
||||
/// <summary>
|
||||
/// Days after which statements start losing freshness weight.
|
||||
/// </summary>
|
||||
public int FreshnessDecayDays { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum freshness factor (0.0-1.0).
|
||||
/// </summary>
|
||||
public double MinFreshnessFactor { get; set; } = 0.5;
|
||||
|
||||
/// <summary>
|
||||
/// Weight boost for not_affected status with justification.
|
||||
/// </summary>
|
||||
public double JustifiedNotAffectedBoost { get; set; } = 1.1;
|
||||
|
||||
/// <summary>
|
||||
/// Weight boost for fixed status.
|
||||
/// </summary>
|
||||
public double FixedStatusBoost { get; set; } = 1.05;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Consensus computation configuration.
|
||||
/// </summary>
|
||||
public sealed class VexLensConsensusOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Default consensus mode: HighestWeight, WeightedVote, Lattice, AuthoritativeFirst.
|
||||
/// </summary>
|
||||
public string DefaultMode { get; set; } = "WeightedVote";
|
||||
|
||||
/// <summary>
|
||||
/// Minimum weight threshold for a statement to contribute to consensus.
|
||||
/// </summary>
|
||||
public double MinimumWeightThreshold { get; set; } = 0.1;
|
||||
|
||||
/// <summary>
|
||||
/// Weight difference threshold to detect conflicts.
|
||||
/// </summary>
|
||||
public double ConflictThreshold { get; set; } = 0.3;
|
||||
|
||||
/// <summary>
|
||||
/// Require justification for not_affected status to be considered.
|
||||
/// </summary>
|
||||
public bool RequireJustificationForNotAffected { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum statements to consider per consensus computation.
|
||||
/// </summary>
|
||||
public int MaxStatementsPerComputation { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Enable conflict detection and reporting.
|
||||
/// </summary>
|
||||
public bool EnableConflictDetection { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Emit events on consensus computation.
|
||||
/// </summary>
|
||||
public bool EmitEvents { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalization configuration.
|
||||
/// </summary>
|
||||
public sealed class VexLensNormalizationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enabled VEX format normalizers.
|
||||
/// </summary>
|
||||
public string[] EnabledFormats { get; set; } = ["OpenVEX", "CSAF", "CycloneDX"];
|
||||
|
||||
/// <summary>
|
||||
/// Fail normalization on unknown fields (strict mode).
|
||||
/// </summary>
|
||||
public bool StrictMode { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum document size in bytes.
|
||||
/// </summary>
|
||||
public int MaxDocumentSizeBytes { get; set; } = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
/// <summary>
|
||||
/// Maximum statements per document.
|
||||
/// </summary>
|
||||
public int MaxStatementsPerDocument { get; set; } = 10000;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Air-gap mode configuration.
|
||||
/// </summary>
|
||||
public sealed class VexLensAirGapOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enable sealed mode (block external network access).
|
||||
/// </summary>
|
||||
public bool SealedMode { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Path to offline bundle directory for import.
|
||||
/// </summary>
|
||||
public string? BundlePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Verify bundle signatures on import.
|
||||
/// </summary>
|
||||
public bool VerifyBundleSignatures { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Allowed bundle sources (issuer IDs).
|
||||
/// </summary>
|
||||
public string[] AllowedBundleSources { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Export format for offline bundles.
|
||||
/// </summary>
|
||||
public string ExportFormat { get; set; } = "jsonl";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Telemetry configuration.
|
||||
/// </summary>
|
||||
public sealed class VexLensTelemetryOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enable metrics collection.
|
||||
/// </summary>
|
||||
public bool MetricsEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Enable distributed tracing.
|
||||
/// </summary>
|
||||
public bool TracingEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Meter name for metrics.
|
||||
/// </summary>
|
||||
public string MeterName { get; set; } = "StellaOps.VexLens";
|
||||
|
||||
/// <summary>
|
||||
/// Activity source name for tracing.
|
||||
/// </summary>
|
||||
public string ActivitySourceName { get; set; } = "StellaOps.VexLens";
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.VexLens.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Normalized VEX document per vex-normalization.schema.json.
|
||||
/// Supports OpenVEX, CSAF VEX, and CycloneDX VEX formats with unified semantics.
|
||||
/// </summary>
|
||||
public sealed record NormalizedVexDocument
|
||||
{
|
||||
/// <summary>
|
||||
/// Schema version for forward compatibility.
|
||||
/// </summary>
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public int SchemaVersion { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Unique document identifier derived from source VEX.
|
||||
/// </summary>
|
||||
[JsonPropertyName("documentId")]
|
||||
public required string DocumentId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original VEX document format before normalization.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sourceFormat")]
|
||||
public required VexSourceFormat SourceFormat { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 digest of original source document.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sourceDigest")]
|
||||
public string? SourceDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URI where source document was obtained.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sourceUri")]
|
||||
public string? SourceUri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Issuing authority for this VEX document.
|
||||
/// </summary>
|
||||
[JsonPropertyName("issuer")]
|
||||
public VexIssuer? Issuer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ISO-8601 timestamp when VEX was originally issued.
|
||||
/// </summary>
|
||||
[JsonPropertyName("issuedAt")]
|
||||
public DateTimeOffset? IssuedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ISO-8601 timestamp when VEX was last modified.
|
||||
/// </summary>
|
||||
[JsonPropertyName("lastUpdatedAt")]
|
||||
public DateTimeOffset? LastUpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Normalized VEX statements extracted from source.
|
||||
/// </summary>
|
||||
[JsonPropertyName("statements")]
|
||||
public required IReadOnlyList<NormalizedStatement> Statements { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Metadata about the normalization process.
|
||||
/// </summary>
|
||||
[JsonPropertyName("provenance")]
|
||||
public NormalizationProvenance? Provenance { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Original VEX document format.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<VexSourceFormat>))]
|
||||
public enum VexSourceFormat
|
||||
{
|
||||
[JsonPropertyName("OPENVEX")]
|
||||
OpenVex,
|
||||
|
||||
[JsonPropertyName("CSAF_VEX")]
|
||||
CsafVex,
|
||||
|
||||
[JsonPropertyName("CYCLONEDX_VEX")]
|
||||
CycloneDxVex,
|
||||
|
||||
[JsonPropertyName("SPDX_VEX")]
|
||||
SpdxVex,
|
||||
|
||||
[JsonPropertyName("STELLAOPS")]
|
||||
StellaOps
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX issuing authority.
|
||||
/// </summary>
|
||||
public sealed record VexIssuer
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique issuer identifier (e.g., PURL, domain).
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable issuer name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Issuer category for trust weighting.
|
||||
/// </summary>
|
||||
[JsonPropertyName("category")]
|
||||
public IssuerCategory? Category { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trust tier for policy evaluation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("trustTier")]
|
||||
public TrustTier? TrustTier { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Known signing key fingerprints for this issuer.
|
||||
/// </summary>
|
||||
[JsonPropertyName("keyFingerprints")]
|
||||
public IReadOnlyList<string>? KeyFingerprints { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issuer category for trust weighting.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<IssuerCategory>))]
|
||||
public enum IssuerCategory
|
||||
{
|
||||
[JsonPropertyName("VENDOR")]
|
||||
Vendor,
|
||||
|
||||
[JsonPropertyName("DISTRIBUTOR")]
|
||||
Distributor,
|
||||
|
||||
[JsonPropertyName("COMMUNITY")]
|
||||
Community,
|
||||
|
||||
[JsonPropertyName("INTERNAL")]
|
||||
Internal,
|
||||
|
||||
[JsonPropertyName("AGGREGATOR")]
|
||||
Aggregator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trust tier for policy evaluation.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<TrustTier>))]
|
||||
public enum TrustTier
|
||||
{
|
||||
[JsonPropertyName("AUTHORITATIVE")]
|
||||
Authoritative,
|
||||
|
||||
[JsonPropertyName("TRUSTED")]
|
||||
Trusted,
|
||||
|
||||
[JsonPropertyName("UNTRUSTED")]
|
||||
Untrusted,
|
||||
|
||||
[JsonPropertyName("UNKNOWN")]
|
||||
Unknown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalized VEX statement.
|
||||
/// </summary>
|
||||
public sealed record NormalizedStatement
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique statement identifier within this document.
|
||||
/// </summary>
|
||||
[JsonPropertyName("statementId")]
|
||||
public required string StatementId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE, GHSA, or other vulnerability identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Known aliases for this vulnerability.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerabilityAliases")]
|
||||
public IReadOnlyList<string>? VulnerabilityAliases { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Product affected by this statement.
|
||||
/// </summary>
|
||||
[JsonPropertyName("product")]
|
||||
public required NormalizedProduct Product { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Normalized VEX status using OpenVEX terminology.
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public required VexStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional notes about the status determination.
|
||||
/// </summary>
|
||||
[JsonPropertyName("statusNotes")]
|
||||
public string? StatusNotes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Normalized justification when status is not_affected.
|
||||
/// </summary>
|
||||
[JsonPropertyName("justification")]
|
||||
public VexJustificationType? Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Impact description when status is affected.
|
||||
/// </summary>
|
||||
[JsonPropertyName("impactStatement")]
|
||||
public string? ImpactStatement { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Recommended action to remediate.
|
||||
/// </summary>
|
||||
[JsonPropertyName("actionStatement")]
|
||||
public string? ActionStatement { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp for action statement.
|
||||
/// </summary>
|
||||
[JsonPropertyName("actionStatementTimestamp")]
|
||||
public DateTimeOffset? ActionStatementTimestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version constraints for this statement.
|
||||
/// </summary>
|
||||
[JsonPropertyName("versions")]
|
||||
public VersionRange? Versions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Specific subcomponents affected within the product.
|
||||
/// </summary>
|
||||
[JsonPropertyName("subcomponents")]
|
||||
public IReadOnlyList<NormalizedProduct>? Subcomponents { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this statement was first observed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("firstSeen")]
|
||||
public DateTimeOffset? FirstSeen { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this statement was last confirmed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("lastSeen")]
|
||||
public DateTimeOffset? LastSeen { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalized VEX status.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<VexStatus>))]
|
||||
public enum VexStatus
|
||||
{
|
||||
[JsonPropertyName("not_affected")]
|
||||
NotAffected,
|
||||
|
||||
[JsonPropertyName("affected")]
|
||||
Affected,
|
||||
|
||||
[JsonPropertyName("fixed")]
|
||||
Fixed,
|
||||
|
||||
[JsonPropertyName("under_investigation")]
|
||||
UnderInvestigation
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX justification types.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<VexJustificationType>))]
|
||||
public enum VexJustificationType
|
||||
{
|
||||
[JsonPropertyName("component_not_present")]
|
||||
ComponentNotPresent,
|
||||
|
||||
[JsonPropertyName("vulnerable_code_not_present")]
|
||||
VulnerableCodeNotPresent,
|
||||
|
||||
[JsonPropertyName("vulnerable_code_not_in_execute_path")]
|
||||
VulnerableCodeNotInExecutePath,
|
||||
|
||||
[JsonPropertyName("vulnerable_code_cannot_be_controlled_by_adversary")]
|
||||
VulnerableCodeCannotBeControlledByAdversary,
|
||||
|
||||
[JsonPropertyName("inline_mitigations_already_exist")]
|
||||
InlineMitigationsAlreadyExist
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalized product reference.
|
||||
/// </summary>
|
||||
public sealed record NormalizedProduct
|
||||
{
|
||||
/// <summary>
|
||||
/// Canonical product key (preferably PURL).
|
||||
/// </summary>
|
||||
[JsonPropertyName("key")]
|
||||
public required string Key { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable product name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Specific version if applicable.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package URL if available.
|
||||
/// </summary>
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CPE identifier if available.
|
||||
/// </summary>
|
||||
[JsonPropertyName("cpe")]
|
||||
public string? Cpe { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content hashes (algorithm -> value).
|
||||
/// </summary>
|
||||
[JsonPropertyName("hashes")]
|
||||
public IReadOnlyDictionary<string, string>? Hashes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Version range constraints.
|
||||
/// </summary>
|
||||
public sealed record VersionRange
|
||||
{
|
||||
/// <summary>
|
||||
/// Version expressions for affected versions.
|
||||
/// </summary>
|
||||
[JsonPropertyName("affected")]
|
||||
public IReadOnlyList<string>? Affected { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version expressions for fixed versions.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fixed")]
|
||||
public IReadOnlyList<string>? Fixed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version expressions for unaffected versions.
|
||||
/// </summary>
|
||||
[JsonPropertyName("unaffected")]
|
||||
public IReadOnlyList<string>? Unaffected { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalization provenance metadata.
|
||||
/// </summary>
|
||||
public sealed record NormalizationProvenance
|
||||
{
|
||||
/// <summary>
|
||||
/// When normalization was performed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("normalizedAt")]
|
||||
public required DateTimeOffset NormalizedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Service/version that performed normalization.
|
||||
/// </summary>
|
||||
[JsonPropertyName("normalizer")]
|
||||
public required string Normalizer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source document revision if tracked.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sourceRevision")]
|
||||
public string? SourceRevision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Transformation rules applied during normalization.
|
||||
/// </summary>
|
||||
[JsonPropertyName("transformationRules")]
|
||||
public IReadOnlyList<string>? TransformationRules { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using StellaOps.VexLens.Core.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Core.Normalization;
|
||||
|
||||
/// <summary>
|
||||
/// VexLens normalizer interface for translating raw VEX documents
|
||||
/// into the normalized schema per vex-normalization.schema.json.
|
||||
/// </summary>
|
||||
public interface IVexLensNormalizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Normalizes a raw VEX document from any supported format.
|
||||
/// </summary>
|
||||
/// <param name="rawDocument">The raw VEX document bytes.</param>
|
||||
/// <param name="sourceFormat">The source format (OpenVEX, CSAF, CycloneDX, etc.).</param>
|
||||
/// <param name="sourceUri">URI where the document was obtained.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The normalized VEX document.</returns>
|
||||
Task<NormalizedVexDocument> NormalizeAsync(
|
||||
ReadOnlyMemory<byte> rawDocument,
|
||||
VexSourceFormat sourceFormat,
|
||||
string? sourceUri = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Detects the source format from document content.
|
||||
/// </summary>
|
||||
/// <param name="rawDocument">The raw VEX document bytes.</param>
|
||||
/// <returns>The detected format, or null if unknown.</returns>
|
||||
VexSourceFormat? DetectFormat(ReadOnlyMemory<byte> rawDocument);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the supported source formats.
|
||||
/// </summary>
|
||||
IReadOnlyList<VexSourceFormat> SupportedFormats { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a normalization operation with additional metadata.
|
||||
/// </summary>
|
||||
public sealed record NormalizationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The normalized document.
|
||||
/// </summary>
|
||||
public required NormalizedVexDocument Document { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the normalization was successful.
|
||||
/// </summary>
|
||||
public bool Success { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Warnings encountered during normalization.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Number of statements that were skipped due to errors.
|
||||
/// </summary>
|
||||
public int SkippedStatements { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Processing duration in milliseconds.
|
||||
/// </summary>
|
||||
public long ProcessingMs { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.VexLens.Core.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Core.Normalization;
|
||||
|
||||
/// <summary>
|
||||
/// VexLens normalizer service that transforms raw VEX documents from
|
||||
/// OpenVEX, CSAF, and CycloneDX formats into the normalized schema.
|
||||
/// </summary>
|
||||
public sealed class VexLensNormalizer : IVexLensNormalizer
|
||||
{
|
||||
private const string NormalizerVersion = "stellaops-vexlens/1.0.0";
|
||||
|
||||
private readonly VexNormalizerRegistry _excititorRegistry;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<VexLensNormalizer> _logger;
|
||||
|
||||
private static readonly IReadOnlyList<VexSourceFormat> s_supportedFormats = new[]
|
||||
{
|
||||
VexSourceFormat.OpenVex,
|
||||
VexSourceFormat.CsafVex,
|
||||
VexSourceFormat.CycloneDxVex
|
||||
};
|
||||
|
||||
public VexLensNormalizer(
|
||||
VexNormalizerRegistry excititorRegistry,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<VexLensNormalizer> logger)
|
||||
{
|
||||
_excititorRegistry = excititorRegistry ?? throw new ArgumentNullException(nameof(excititorRegistry));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public IReadOnlyList<VexSourceFormat> SupportedFormats => s_supportedFormats;
|
||||
|
||||
public VexSourceFormat? DetectFormat(ReadOnlyMemory<byte> rawDocument)
|
||||
{
|
||||
if (rawDocument.IsEmpty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(rawDocument);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// OpenVEX detection: has "@context" with openvex
|
||||
if (root.TryGetProperty("@context", out var context))
|
||||
{
|
||||
var contextStr = context.ValueKind == JsonValueKind.String
|
||||
? context.GetString()
|
||||
: context.ToString();
|
||||
|
||||
if (contextStr?.Contains("openvex", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
return VexSourceFormat.OpenVex;
|
||||
}
|
||||
}
|
||||
|
||||
// CSAF detection: has "document" with "csaf_version" or "category" containing "vex"
|
||||
if (root.TryGetProperty("document", out var document))
|
||||
{
|
||||
if (document.TryGetProperty("csaf_version", out _))
|
||||
{
|
||||
return VexSourceFormat.CsafVex;
|
||||
}
|
||||
|
||||
if (document.TryGetProperty("category", out var category))
|
||||
{
|
||||
var categoryStr = category.GetString();
|
||||
if (categoryStr?.Contains("vex", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
return VexSourceFormat.CsafVex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CycloneDX detection: has "bomFormat" = "CycloneDX" and "vulnerabilities"
|
||||
if (root.TryGetProperty("bomFormat", out var bomFormat) &&
|
||||
bomFormat.GetString()?.Equals("CycloneDX", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
if (root.TryGetProperty("vulnerabilities", out _))
|
||||
{
|
||||
return VexSourceFormat.CycloneDxVex;
|
||||
}
|
||||
}
|
||||
|
||||
// SPDX VEX detection: has "spdxVersion" and vulnerability annotations
|
||||
if (root.TryGetProperty("spdxVersion", out _))
|
||||
{
|
||||
return VexSourceFormat.SpdxVex;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not valid JSON, can't detect format
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<NormalizedVexDocument> NormalizeAsync(
|
||||
ReadOnlyMemory<byte> rawDocument,
|
||||
VexSourceFormat sourceFormat,
|
||||
string? sourceUri = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfZero(rawDocument.Length, nameof(rawDocument));
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var digest = ComputeDigest(rawDocument.Span);
|
||||
var documentId = GenerateDocumentId(sourceFormat, digest);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Normalizing {Format} document from {Uri} (size={Size}, digest={Digest})",
|
||||
sourceFormat, sourceUri ?? "(inline)", rawDocument.Length, digest);
|
||||
|
||||
// Convert to Excititor's internal format and normalize
|
||||
var excititorFormat = MapToExcititorFormat(sourceFormat);
|
||||
var rawDoc = new VexRawDocument(
|
||||
rawDocument,
|
||||
excititorFormat,
|
||||
sourceUri,
|
||||
digest,
|
||||
now);
|
||||
|
||||
var normalizer = _excititorRegistry.Resolve(rawDoc);
|
||||
if (normalizer is null)
|
||||
{
|
||||
_logger.LogWarning("No normalizer found for format {Format}, using fallback parsing", sourceFormat);
|
||||
return await FallbackNormalizeAsync(rawDocument, sourceFormat, documentId, digest, sourceUri, now, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Use Excititor's provider abstraction
|
||||
var provider = new VexProvider(
|
||||
Id: "vexlens",
|
||||
Name: "VexLens Normalizer",
|
||||
Category: VexProviderCategory.Aggregator,
|
||||
TrustTier: VexProviderTrustTier.Unknown);
|
||||
|
||||
var batch = await normalizer.NormalizeAsync(rawDoc, provider, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Transform Excititor claims to VexLens normalized format
|
||||
var statements = TransformClaims(batch.Claims);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Normalized {Format} document into {Count} statements",
|
||||
sourceFormat, statements.Count);
|
||||
|
||||
return new NormalizedVexDocument
|
||||
{
|
||||
SchemaVersion = 1,
|
||||
DocumentId = documentId,
|
||||
SourceFormat = sourceFormat,
|
||||
SourceDigest = digest,
|
||||
SourceUri = sourceUri,
|
||||
Issuer = ExtractIssuer(batch),
|
||||
IssuedAt = batch.Claims.FirstOrDefault()?.Document.Timestamp,
|
||||
LastUpdatedAt = batch.Claims.LastOrDefault()?.LastObserved,
|
||||
Statements = statements,
|
||||
Provenance = new NormalizationProvenance
|
||||
{
|
||||
NormalizedAt = now,
|
||||
Normalizer = NormalizerVersion,
|
||||
TransformationRules = new[] { $"excititor:{normalizer.Format}" }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<NormalizedVexDocument> FallbackNormalizeAsync(
|
||||
ReadOnlyMemory<byte> rawDocument,
|
||||
VexSourceFormat sourceFormat,
|
||||
string documentId,
|
||||
string digest,
|
||||
string? sourceUri,
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Fallback parsing for unsupported formats
|
||||
var statements = new List<NormalizedStatement>();
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(rawDocument);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Try to extract statements from common patterns
|
||||
if (TryExtractOpenVexStatements(root, out var openVexStatements))
|
||||
{
|
||||
statements.AddRange(openVexStatements);
|
||||
}
|
||||
else if (TryExtractCycloneDxStatements(root, out var cdxStatements))
|
||||
{
|
||||
statements.AddRange(cdxStatements);
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse document for fallback normalization");
|
||||
}
|
||||
|
||||
return new NormalizedVexDocument
|
||||
{
|
||||
SchemaVersion = 1,
|
||||
DocumentId = documentId,
|
||||
SourceFormat = sourceFormat,
|
||||
SourceDigest = digest,
|
||||
SourceUri = sourceUri,
|
||||
Statements = statements,
|
||||
Provenance = new NormalizationProvenance
|
||||
{
|
||||
NormalizedAt = now,
|
||||
Normalizer = NormalizerVersion,
|
||||
TransformationRules = new[] { "fallback:generic" }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryExtractOpenVexStatements(JsonElement root, out List<NormalizedStatement> statements)
|
||||
{
|
||||
statements = new List<NormalizedStatement>();
|
||||
|
||||
if (!root.TryGetProperty("statements", out var statementsElement) ||
|
||||
statementsElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var index = 0;
|
||||
foreach (var stmt in statementsElement.EnumerateArray())
|
||||
{
|
||||
if (stmt.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var vulnId = GetString(stmt, "vulnerability") ?? GetString(stmt, "vuln");
|
||||
if (string.IsNullOrWhiteSpace(vulnId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var status = MapStatusString(GetString(stmt, "status"));
|
||||
var justification = MapJustificationString(GetString(stmt, "justification"));
|
||||
|
||||
// Extract products
|
||||
if (!stmt.TryGetProperty("products", out var products) ||
|
||||
products.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var product in products.EnumerateArray())
|
||||
{
|
||||
var productKey = product.ValueKind == JsonValueKind.String
|
||||
? product.GetString()
|
||||
: GetString(product, "purl") ?? GetString(product, "id");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(productKey))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
statements.Add(new NormalizedStatement
|
||||
{
|
||||
StatementId = GetString(stmt, "id") ?? $"stmt-{index++}",
|
||||
VulnerabilityId = vulnId.Trim(),
|
||||
Product = new NormalizedProduct
|
||||
{
|
||||
Key = productKey.Trim(),
|
||||
Name = GetString(product, "name"),
|
||||
Version = GetString(product, "version"),
|
||||
Purl = productKey.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase) ? productKey : null,
|
||||
Cpe = GetString(product, "cpe")
|
||||
},
|
||||
Status = status,
|
||||
Justification = justification,
|
||||
StatusNotes = GetString(stmt, "statement") ?? GetString(stmt, "remediation")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return statements.Count > 0;
|
||||
}
|
||||
|
||||
private static bool TryExtractCycloneDxStatements(JsonElement root, out List<NormalizedStatement> statements)
|
||||
{
|
||||
statements = new List<NormalizedStatement>();
|
||||
|
||||
if (!root.TryGetProperty("vulnerabilities", out var vulns) ||
|
||||
vulns.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var index = 0;
|
||||
foreach (var vuln in vulns.EnumerateArray())
|
||||
{
|
||||
if (vuln.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var vulnId = GetString(vuln, "id");
|
||||
if (string.IsNullOrWhiteSpace(vulnId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract analysis
|
||||
VexStatus status = VexStatus.UnderInvestigation;
|
||||
VexJustificationType? justification = null;
|
||||
string? statusNotes = null;
|
||||
|
||||
if (vuln.TryGetProperty("analysis", out var analysis))
|
||||
{
|
||||
status = MapStatusString(GetString(analysis, "state"));
|
||||
justification = MapJustificationString(GetString(analysis, "justification"));
|
||||
statusNotes = GetString(analysis, "detail");
|
||||
}
|
||||
|
||||
// Extract affects
|
||||
if (!vuln.TryGetProperty("affects", out var affects) ||
|
||||
affects.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var affect in affects.EnumerateArray())
|
||||
{
|
||||
var refValue = GetString(affect, "ref");
|
||||
if (string.IsNullOrWhiteSpace(refValue))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
statements.Add(new NormalizedStatement
|
||||
{
|
||||
StatementId = $"cdx-{vulnId}-{index++}",
|
||||
VulnerabilityId = vulnId.Trim(),
|
||||
Product = new NormalizedProduct
|
||||
{
|
||||
Key = refValue.Trim(),
|
||||
Purl = refValue.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase) ? refValue : null
|
||||
},
|
||||
Status = status,
|
||||
Justification = justification,
|
||||
StatusNotes = statusNotes
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return statements.Count > 0;
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement element, string propertyName)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return element.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String
|
||||
? value.GetString()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static VexStatus MapStatusString(string? status)
|
||||
{
|
||||
return status?.ToLowerInvariant() switch
|
||||
{
|
||||
"not_affected" or "notaffected" => VexStatus.NotAffected,
|
||||
"affected" => VexStatus.Affected,
|
||||
"fixed" => VexStatus.Fixed,
|
||||
"under_investigation" or "in_triage" => VexStatus.UnderInvestigation,
|
||||
_ => VexStatus.UnderInvestigation
|
||||
};
|
||||
}
|
||||
|
||||
private static VexJustificationType? MapJustificationString(string? justification)
|
||||
{
|
||||
return justification?.ToLowerInvariant().Replace("-", "_") switch
|
||||
{
|
||||
"component_not_present" => VexJustificationType.ComponentNotPresent,
|
||||
"vulnerable_code_not_present" => VexJustificationType.VulnerableCodeNotPresent,
|
||||
"vulnerable_code_not_in_execute_path" => VexJustificationType.VulnerableCodeNotInExecutePath,
|
||||
"vulnerable_code_cannot_be_controlled_by_adversary" => VexJustificationType.VulnerableCodeCannotBeControlledByAdversary,
|
||||
"inline_mitigations_already_exist" => VexJustificationType.InlineMitigationsAlreadyExist,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private IReadOnlyList<NormalizedStatement> TransformClaims(
|
||||
IReadOnlyList<VexClaim> claims)
|
||||
{
|
||||
var statements = new List<NormalizedStatement>(claims.Count);
|
||||
var index = 0;
|
||||
|
||||
foreach (var claim in claims)
|
||||
{
|
||||
var status = MapExcititorStatus(claim.Status);
|
||||
var justification = MapExcititorJustification(claim.Justification);
|
||||
|
||||
statements.Add(new NormalizedStatement
|
||||
{
|
||||
StatementId = $"claim-{index++}",
|
||||
VulnerabilityId = claim.VulnerabilityId,
|
||||
Product = new NormalizedProduct
|
||||
{
|
||||
Key = claim.Product.Key,
|
||||
Name = claim.Product.Name,
|
||||
Version = claim.Product.Version,
|
||||
Purl = claim.Product.Purl,
|
||||
Cpe = claim.Product.Cpe
|
||||
},
|
||||
Status = status,
|
||||
Justification = justification,
|
||||
StatusNotes = claim.Remarks,
|
||||
FirstSeen = claim.FirstObserved,
|
||||
LastSeen = claim.LastObserved
|
||||
});
|
||||
}
|
||||
|
||||
// Deterministic ordering
|
||||
return statements
|
||||
.OrderBy(s => s.VulnerabilityId, StringComparer.Ordinal)
|
||||
.ThenBy(s => s.Product.Key, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static VexStatus MapExcititorStatus(VexClaimStatus status)
|
||||
{
|
||||
return status switch
|
||||
{
|
||||
VexClaimStatus.NotAffected => VexStatus.NotAffected,
|
||||
VexClaimStatus.Affected => VexStatus.Affected,
|
||||
VexClaimStatus.Fixed => VexStatus.Fixed,
|
||||
VexClaimStatus.UnderInvestigation => VexStatus.UnderInvestigation,
|
||||
_ => VexStatus.UnderInvestigation
|
||||
};
|
||||
}
|
||||
|
||||
private static VexJustificationType? MapExcititorJustification(VexJustification? justification)
|
||||
{
|
||||
return justification switch
|
||||
{
|
||||
VexJustification.ComponentNotPresent => VexJustificationType.ComponentNotPresent,
|
||||
VexJustification.VulnerableCodeNotPresent => VexJustificationType.VulnerableCodeNotPresent,
|
||||
VexJustification.VulnerableCodeNotInExecutePath => VexJustificationType.VulnerableCodeNotInExecutePath,
|
||||
VexJustification.VulnerableCodeCannotBeControlledByAdversary => VexJustificationType.VulnerableCodeCannotBeControlledByAdversary,
|
||||
VexJustification.InlineMitigationsAlreadyExist => VexJustificationType.InlineMitigationsAlreadyExist,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static VexIssuer? ExtractIssuer(VexClaimBatch batch)
|
||||
{
|
||||
// Extract issuer from batch metadata if available
|
||||
var metadata = batch.Metadata;
|
||||
|
||||
if (metadata.TryGetValue("issuer.id", out var issuerId) &&
|
||||
metadata.TryGetValue("issuer.name", out var issuerName))
|
||||
{
|
||||
return new VexIssuer
|
||||
{
|
||||
Id = issuerId,
|
||||
Name = issuerName
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static VexDocumentFormat MapToExcititorFormat(VexSourceFormat format)
|
||||
{
|
||||
return format switch
|
||||
{
|
||||
VexSourceFormat.OpenVex => VexDocumentFormat.OpenVex,
|
||||
VexSourceFormat.CsafVex => VexDocumentFormat.Csaf,
|
||||
VexSourceFormat.CycloneDxVex => VexDocumentFormat.CycloneDx,
|
||||
_ => VexDocumentFormat.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeDigest(ReadOnlySpan<byte> data)
|
||||
{
|
||||
var hash = SHA256.HashData(data);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
private static string GenerateDocumentId(VexSourceFormat format, string digest)
|
||||
{
|
||||
var prefix = format switch
|
||||
{
|
||||
VexSourceFormat.OpenVex => "openvex",
|
||||
VexSourceFormat.CsafVex => "csaf",
|
||||
VexSourceFormat.CycloneDxVex => "cdx",
|
||||
VexSourceFormat.SpdxVex => "spdx",
|
||||
VexSourceFormat.StellaOps => "stellaops",
|
||||
_ => "vex"
|
||||
};
|
||||
|
||||
// Use first 16 chars of digest for document ID
|
||||
var shortDigest = digest.Replace("sha256:", "", StringComparison.OrdinalIgnoreCase)[..16];
|
||||
return $"{prefix}:{shortDigest}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.VexLens.Core</RootNamespace>
|
||||
<AssemblyName>StellaOps.VexLens.Core</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-preview.7.24407.12" />
|
||||
<PackageReference Include="System.Text.Json" Version="10.0.0-preview.7.24407.12" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\Excititor\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
19
src/VexLens/StellaOps.VexLens/StellaOps.VexLens.csproj
Normal file
19
src/VexLens/StellaOps.VexLens/StellaOps.VexLens.csproj
Normal file
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RootNamespace>StellaOps.VexLens</RootNamespace>
|
||||
<AssemblyName>StellaOps.VexLens</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,210 @@
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for persisting and querying consensus projections.
|
||||
/// </summary>
|
||||
public interface IConsensusProjectionStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Stores a consensus result.
|
||||
/// </summary>
|
||||
Task<ConsensusProjection> StoreAsync(
|
||||
VexConsensusResult result,
|
||||
StoreProjectionOptions options,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a consensus projection by ID.
|
||||
/// </summary>
|
||||
Task<ConsensusProjection?> GetAsync(
|
||||
string projectionId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest consensus projection for a vulnerability-product pair.
|
||||
/// </summary>
|
||||
Task<ConsensusProjection?> GetLatestAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists consensus projections with filtering and pagination.
|
||||
/// </summary>
|
||||
Task<ProjectionListResult> ListAsync(
|
||||
ProjectionQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the history of consensus projections for a vulnerability-product pair.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ConsensusProjection>> GetHistoryAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
string? tenantId = null,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes projections older than the specified date.
|
||||
/// </summary>
|
||||
Task<int> PurgeAsync(
|
||||
DateTimeOffset olderThan,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A stored consensus projection.
|
||||
/// </summary>
|
||||
public sealed record ConsensusProjection(
|
||||
string ProjectionId,
|
||||
string VulnerabilityId,
|
||||
string ProductKey,
|
||||
string? TenantId,
|
||||
VexStatus Status,
|
||||
VexJustification? Justification,
|
||||
double ConfidenceScore,
|
||||
ConsensusOutcome Outcome,
|
||||
int StatementCount,
|
||||
int ConflictCount,
|
||||
string RationaleSummary,
|
||||
DateTimeOffset ComputedAt,
|
||||
DateTimeOffset StoredAt,
|
||||
string? PreviousProjectionId,
|
||||
bool StatusChanged);
|
||||
|
||||
/// <summary>
|
||||
/// Options for storing a projection.
|
||||
/// </summary>
|
||||
public sealed record StoreProjectionOptions(
|
||||
string? TenantId,
|
||||
bool TrackHistory,
|
||||
bool EmitEvent);
|
||||
|
||||
/// <summary>
|
||||
/// Query for listing projections.
|
||||
/// </summary>
|
||||
public sealed record ProjectionQuery(
|
||||
string? TenantId,
|
||||
string? VulnerabilityId,
|
||||
string? ProductKey,
|
||||
VexStatus? Status,
|
||||
ConsensusOutcome? Outcome,
|
||||
double? MinimumConfidence,
|
||||
DateTimeOffset? ComputedAfter,
|
||||
DateTimeOffset? ComputedBefore,
|
||||
bool? StatusChanged,
|
||||
int Limit,
|
||||
int Offset,
|
||||
ProjectionSortField SortBy,
|
||||
bool SortDescending);
|
||||
|
||||
/// <summary>
|
||||
/// Fields for sorting projections.
|
||||
/// </summary>
|
||||
public enum ProjectionSortField
|
||||
{
|
||||
ComputedAt,
|
||||
StoredAt,
|
||||
VulnerabilityId,
|
||||
ProductKey,
|
||||
ConfidenceScore
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of listing projections.
|
||||
/// </summary>
|
||||
public sealed record ProjectionListResult(
|
||||
IReadOnlyList<ConsensusProjection> Projections,
|
||||
int TotalCount,
|
||||
int Offset,
|
||||
int Limit);
|
||||
|
||||
/// <summary>
|
||||
/// Event emitted when consensus is computed.
|
||||
/// </summary>
|
||||
public interface IConsensusEventEmitter
|
||||
{
|
||||
/// <summary>
|
||||
/// Emits a consensus computed event.
|
||||
/// </summary>
|
||||
Task EmitConsensusComputedAsync(
|
||||
ConsensusComputedEvent @event,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Emits a status changed event.
|
||||
/// </summary>
|
||||
Task EmitStatusChangedAsync(
|
||||
ConsensusStatusChangedEvent @event,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Emits a conflict detected event.
|
||||
/// </summary>
|
||||
Task EmitConflictDetectedAsync(
|
||||
ConsensusConflictDetectedEvent @event,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event when consensus is computed.
|
||||
/// </summary>
|
||||
public sealed record ConsensusComputedEvent(
|
||||
string EventId,
|
||||
string ProjectionId,
|
||||
string VulnerabilityId,
|
||||
string ProductKey,
|
||||
string? TenantId,
|
||||
VexStatus Status,
|
||||
VexJustification? Justification,
|
||||
double ConfidenceScore,
|
||||
ConsensusOutcome Outcome,
|
||||
int StatementCount,
|
||||
DateTimeOffset ComputedAt,
|
||||
DateTimeOffset EmittedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Event when consensus status changes.
|
||||
/// </summary>
|
||||
public sealed record ConsensusStatusChangedEvent(
|
||||
string EventId,
|
||||
string ProjectionId,
|
||||
string VulnerabilityId,
|
||||
string ProductKey,
|
||||
string? TenantId,
|
||||
VexStatus PreviousStatus,
|
||||
VexStatus NewStatus,
|
||||
string? ChangeReason,
|
||||
DateTimeOffset ComputedAt,
|
||||
DateTimeOffset EmittedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Event when conflicts are detected during consensus.
|
||||
/// </summary>
|
||||
public sealed record ConsensusConflictDetectedEvent(
|
||||
string EventId,
|
||||
string ProjectionId,
|
||||
string VulnerabilityId,
|
||||
string ProductKey,
|
||||
string? TenantId,
|
||||
int ConflictCount,
|
||||
ConflictSeverity MaxSeverity,
|
||||
IReadOnlyList<ConflictSummary> Conflicts,
|
||||
DateTimeOffset DetectedAt,
|
||||
DateTimeOffset EmittedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a conflict for events.
|
||||
/// </summary>
|
||||
public sealed record ConflictSummary(
|
||||
string Issuer1,
|
||||
string Issuer2,
|
||||
VexStatus Status1,
|
||||
VexStatus Status2,
|
||||
ConflictSeverity Severity);
|
||||
@@ -0,0 +1,403 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IConsensusProjectionStore"/>.
|
||||
/// Suitable for testing and single-instance deployments.
|
||||
/// </summary>
|
||||
public sealed class InMemoryConsensusProjectionStore : IConsensusProjectionStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConsensusProjection> _projectionsById = new();
|
||||
private readonly ConcurrentDictionary<string, List<ConsensusProjection>> _projectionsByKey = new();
|
||||
private readonly IConsensusEventEmitter? _eventEmitter;
|
||||
|
||||
public InMemoryConsensusProjectionStore(IConsensusEventEmitter? eventEmitter = null)
|
||||
{
|
||||
_eventEmitter = eventEmitter;
|
||||
}
|
||||
|
||||
public async Task<ConsensusProjection> StoreAsync(
|
||||
VexConsensusResult result,
|
||||
StoreProjectionOptions options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = GetKey(result.VulnerabilityId, result.ProductKey, options.TenantId);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// Get previous projection for history tracking
|
||||
ConsensusProjection? previous = null;
|
||||
bool statusChanged = false;
|
||||
|
||||
if (options.TrackHistory)
|
||||
{
|
||||
previous = await GetLatestAsync(
|
||||
result.VulnerabilityId,
|
||||
result.ProductKey,
|
||||
options.TenantId,
|
||||
cancellationToken);
|
||||
|
||||
if (previous != null)
|
||||
{
|
||||
statusChanged = previous.Status != result.ConsensusStatus;
|
||||
}
|
||||
}
|
||||
|
||||
var projection = new ConsensusProjection(
|
||||
ProjectionId: $"proj-{Guid.NewGuid():N}",
|
||||
VulnerabilityId: result.VulnerabilityId,
|
||||
ProductKey: result.ProductKey,
|
||||
TenantId: options.TenantId,
|
||||
Status: result.ConsensusStatus,
|
||||
Justification: result.ConsensusJustification,
|
||||
ConfidenceScore: result.ConfidenceScore,
|
||||
Outcome: result.Outcome,
|
||||
StatementCount: result.Contributions.Count,
|
||||
ConflictCount: result.Conflicts?.Count ?? 0,
|
||||
RationaleSummary: result.Rationale.Summary,
|
||||
ComputedAt: result.ComputedAt,
|
||||
StoredAt: now,
|
||||
PreviousProjectionId: previous?.ProjectionId,
|
||||
StatusChanged: statusChanged);
|
||||
|
||||
_projectionsById[projection.ProjectionId] = projection;
|
||||
|
||||
// Add to history
|
||||
if (!_projectionsByKey.TryGetValue(key, out var history))
|
||||
{
|
||||
history = [];
|
||||
_projectionsByKey[key] = history;
|
||||
}
|
||||
|
||||
lock (history)
|
||||
{
|
||||
history.Add(projection);
|
||||
}
|
||||
|
||||
// Emit events
|
||||
if (options.EmitEvent && _eventEmitter != null)
|
||||
{
|
||||
await EmitEventsAsync(projection, result, previous, cancellationToken);
|
||||
}
|
||||
|
||||
return projection;
|
||||
}
|
||||
|
||||
public Task<ConsensusProjection?> GetAsync(
|
||||
string projectionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_projectionsById.TryGetValue(projectionId, out var projection);
|
||||
return Task.FromResult(projection);
|
||||
}
|
||||
|
||||
public Task<ConsensusProjection?> GetLatestAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = GetKey(vulnerabilityId, productKey, tenantId);
|
||||
|
||||
if (_projectionsByKey.TryGetValue(key, out var history))
|
||||
{
|
||||
lock (history)
|
||||
{
|
||||
var latest = history
|
||||
.OrderByDescending(p => p.ComputedAt)
|
||||
.FirstOrDefault();
|
||||
|
||||
return Task.FromResult(latest);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<ConsensusProjection?>(null);
|
||||
}
|
||||
|
||||
public Task<ProjectionListResult> ListAsync(
|
||||
ProjectionQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var allProjections = _projectionsById.Values.AsEnumerable();
|
||||
|
||||
// Apply filters
|
||||
if (!string.IsNullOrEmpty(query.TenantId))
|
||||
{
|
||||
allProjections = allProjections.Where(p => p.TenantId == query.TenantId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.VulnerabilityId))
|
||||
{
|
||||
allProjections = allProjections.Where(p =>
|
||||
p.VulnerabilityId.Contains(query.VulnerabilityId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.ProductKey))
|
||||
{
|
||||
allProjections = allProjections.Where(p =>
|
||||
p.ProductKey.Contains(query.ProductKey, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (query.Status.HasValue)
|
||||
{
|
||||
allProjections = allProjections.Where(p => p.Status == query.Status.Value);
|
||||
}
|
||||
|
||||
if (query.Outcome.HasValue)
|
||||
{
|
||||
allProjections = allProjections.Where(p => p.Outcome == query.Outcome.Value);
|
||||
}
|
||||
|
||||
if (query.MinimumConfidence.HasValue)
|
||||
{
|
||||
allProjections = allProjections.Where(p => p.ConfidenceScore >= query.MinimumConfidence.Value);
|
||||
}
|
||||
|
||||
if (query.ComputedAfter.HasValue)
|
||||
{
|
||||
allProjections = allProjections.Where(p => p.ComputedAt >= query.ComputedAfter.Value);
|
||||
}
|
||||
|
||||
if (query.ComputedBefore.HasValue)
|
||||
{
|
||||
allProjections = allProjections.Where(p => p.ComputedAt <= query.ComputedBefore.Value);
|
||||
}
|
||||
|
||||
if (query.StatusChanged.HasValue)
|
||||
{
|
||||
allProjections = allProjections.Where(p => p.StatusChanged == query.StatusChanged.Value);
|
||||
}
|
||||
|
||||
// Get total count before pagination
|
||||
var list = allProjections.ToList();
|
||||
var totalCount = list.Count;
|
||||
|
||||
// Apply sorting
|
||||
list = query.SortBy switch
|
||||
{
|
||||
ProjectionSortField.ComputedAt => query.SortDescending
|
||||
? list.OrderByDescending(p => p.ComputedAt).ToList()
|
||||
: list.OrderBy(p => p.ComputedAt).ToList(),
|
||||
ProjectionSortField.StoredAt => query.SortDescending
|
||||
? list.OrderByDescending(p => p.StoredAt).ToList()
|
||||
: list.OrderBy(p => p.StoredAt).ToList(),
|
||||
ProjectionSortField.VulnerabilityId => query.SortDescending
|
||||
? list.OrderByDescending(p => p.VulnerabilityId).ToList()
|
||||
: list.OrderBy(p => p.VulnerabilityId).ToList(),
|
||||
ProjectionSortField.ProductKey => query.SortDescending
|
||||
? list.OrderByDescending(p => p.ProductKey).ToList()
|
||||
: list.OrderBy(p => p.ProductKey).ToList(),
|
||||
ProjectionSortField.ConfidenceScore => query.SortDescending
|
||||
? list.OrderByDescending(p => p.ConfidenceScore).ToList()
|
||||
: list.OrderBy(p => p.ConfidenceScore).ToList(),
|
||||
_ => list
|
||||
};
|
||||
|
||||
// Apply pagination
|
||||
var paginated = list
|
||||
.Skip(query.Offset)
|
||||
.Take(query.Limit)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult(new ProjectionListResult(
|
||||
Projections: paginated,
|
||||
TotalCount: totalCount,
|
||||
Offset: query.Offset,
|
||||
Limit: query.Limit));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ConsensusProjection>> GetHistoryAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
string? tenantId = null,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = GetKey(vulnerabilityId, productKey, tenantId);
|
||||
|
||||
if (_projectionsByKey.TryGetValue(key, out var history))
|
||||
{
|
||||
lock (history)
|
||||
{
|
||||
var ordered = history
|
||||
.OrderByDescending(p => p.ComputedAt)
|
||||
.AsEnumerable();
|
||||
|
||||
if (limit.HasValue)
|
||||
{
|
||||
ordered = ordered.Take(limit.Value);
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ConsensusProjection>>(ordered.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ConsensusProjection>>([]);
|
||||
}
|
||||
|
||||
public Task<int> PurgeAsync(
|
||||
DateTimeOffset olderThan,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var toRemove = _projectionsById.Values
|
||||
.Where(p => p.ComputedAt < olderThan)
|
||||
.Where(p => tenantId == null || p.TenantId == tenantId)
|
||||
.ToList();
|
||||
|
||||
foreach (var projection in toRemove)
|
||||
{
|
||||
_projectionsById.TryRemove(projection.ProjectionId, out _);
|
||||
|
||||
var key = GetKey(projection.VulnerabilityId, projection.ProductKey, projection.TenantId);
|
||||
if (_projectionsByKey.TryGetValue(key, out var history))
|
||||
{
|
||||
lock (history)
|
||||
{
|
||||
history.RemoveAll(p => p.ProjectionId == projection.ProjectionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(toRemove.Count);
|
||||
}
|
||||
|
||||
private static string GetKey(string vulnerabilityId, string productKey, string? tenantId)
|
||||
{
|
||||
return $"{tenantId ?? "_"}:{vulnerabilityId}:{productKey}";
|
||||
}
|
||||
|
||||
private async Task EmitEventsAsync(
|
||||
ConsensusProjection projection,
|
||||
VexConsensusResult result,
|
||||
ConsensusProjection? previous,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_eventEmitter == null) return;
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// Always emit computed event
|
||||
await _eventEmitter.EmitConsensusComputedAsync(
|
||||
new ConsensusComputedEvent(
|
||||
EventId: $"evt-{Guid.NewGuid():N}",
|
||||
ProjectionId: projection.ProjectionId,
|
||||
VulnerabilityId: projection.VulnerabilityId,
|
||||
ProductKey: projection.ProductKey,
|
||||
TenantId: projection.TenantId,
|
||||
Status: projection.Status,
|
||||
Justification: projection.Justification,
|
||||
ConfidenceScore: projection.ConfidenceScore,
|
||||
Outcome: projection.Outcome,
|
||||
StatementCount: projection.StatementCount,
|
||||
ComputedAt: projection.ComputedAt,
|
||||
EmittedAt: now),
|
||||
cancellationToken);
|
||||
|
||||
// Emit status changed if applicable
|
||||
if (projection.StatusChanged && previous != null)
|
||||
{
|
||||
await _eventEmitter.EmitStatusChangedAsync(
|
||||
new ConsensusStatusChangedEvent(
|
||||
EventId: $"evt-{Guid.NewGuid():N}",
|
||||
ProjectionId: projection.ProjectionId,
|
||||
VulnerabilityId: projection.VulnerabilityId,
|
||||
ProductKey: projection.ProductKey,
|
||||
TenantId: projection.TenantId,
|
||||
PreviousStatus: previous.Status,
|
||||
NewStatus: projection.Status,
|
||||
ChangeReason: $"Consensus updated: {result.Rationale.Summary}",
|
||||
ComputedAt: projection.ComputedAt,
|
||||
EmittedAt: now),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
// Emit conflict event if conflicts detected
|
||||
if (result.Conflicts is { Count: > 0 })
|
||||
{
|
||||
var maxSeverity = result.Conflicts.Max(c => c.Severity);
|
||||
var summaries = result.Conflicts.Select(c => new ConflictSummary(
|
||||
Issuer1: c.Statement1Id,
|
||||
Issuer2: c.Statement2Id,
|
||||
Status1: c.Status1,
|
||||
Status2: c.Status2,
|
||||
Severity: c.Severity)).ToList();
|
||||
|
||||
await _eventEmitter.EmitConflictDetectedAsync(
|
||||
new ConsensusConflictDetectedEvent(
|
||||
EventId: $"evt-{Guid.NewGuid():N}",
|
||||
ProjectionId: projection.ProjectionId,
|
||||
VulnerabilityId: projection.VulnerabilityId,
|
||||
ProductKey: projection.ProductKey,
|
||||
TenantId: projection.TenantId,
|
||||
ConflictCount: result.Conflicts.Count,
|
||||
MaxSeverity: maxSeverity,
|
||||
Conflicts: summaries,
|
||||
DetectedAt: projection.ComputedAt,
|
||||
EmittedAt: now),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory event emitter for testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryConsensusEventEmitter : IConsensusEventEmitter
|
||||
{
|
||||
private readonly List<object> _events = [];
|
||||
|
||||
public IReadOnlyList<object> Events => _events;
|
||||
|
||||
public IReadOnlyList<ConsensusComputedEvent> ComputedEvents =>
|
||||
_events.OfType<ConsensusComputedEvent>().ToList();
|
||||
|
||||
public IReadOnlyList<ConsensusStatusChangedEvent> StatusChangedEvents =>
|
||||
_events.OfType<ConsensusStatusChangedEvent>().ToList();
|
||||
|
||||
public IReadOnlyList<ConsensusConflictDetectedEvent> ConflictEvents =>
|
||||
_events.OfType<ConsensusConflictDetectedEvent>().ToList();
|
||||
|
||||
public Task EmitConsensusComputedAsync(
|
||||
ConsensusComputedEvent @event,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_events)
|
||||
{
|
||||
_events.Add(@event);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task EmitStatusChangedAsync(
|
||||
ConsensusStatusChangedEvent @event,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_events)
|
||||
{
|
||||
_events.Add(@event);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task EmitConflictDetectedAsync(
|
||||
ConsensusConflictDetectedEvent @event,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_events)
|
||||
{
|
||||
_events.Add(@event);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
lock (_events)
|
||||
{
|
||||
_events.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,21 +2,21 @@
|
||||
|
||||
| Task ID | Status | Sprint | Dependency | Notes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| VEXLENS-30-001 | BLOCKED | SPRINT_0129_0001_0001_policy_reasoning | — | Blocked: normalization schema + issuer directory + API governance specs not published. |
|
||||
| VEXLENS-30-002 | BLOCKED | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-001 | Product mapping library; depends on normalization shapes. |
|
||||
| VEXLENS-30-003 | BLOCKED | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-002 | Signature verification (Ed25519/DSSE/PKIX); issuer directory inputs pending. |
|
||||
| VEXLENS-30-004 | BLOCKED | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-003 | Trust weighting engine; needs policy config contract. |
|
||||
| VEXLENS-30-005 | BLOCKED | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-004 | Consensus algorithm; blocked by trust weighting inputs. |
|
||||
| VEXLENS-30-006 | BLOCKED | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-005 | Projection storage/events; awaiting consensus output schema. |
|
||||
| VEXLENS-30-007 | BLOCKED | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-006 | Consensus APIs + OpenAPI; pending upstream API governance guidance. |
|
||||
| VEXLENS-30-008 | BLOCKED | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-007 | Policy Engine/Vuln Explorer integration; needs upstream contracts. |
|
||||
| VEXLENS-30-009 | BLOCKED | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-008 | Telemetry (metrics/logs/traces); observability schema not published. |
|
||||
| VEXLENS-30-010 | BLOCKED | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-009 | Tests + determinism harness; fixtures pending normalization outputs. |
|
||||
| VEXLENS-30-011 | BLOCKED | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-010 | Deployment/runbooks/offline kit; depends on API/telemetry shapes. |
|
||||
| VEXLENS-AIAI-31-001 | TODO | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-011 | Consensus rationale API enhancements; needs consensus API finalization. |
|
||||
| VEXLENS-AIAI-31-002 | TODO | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-AIAI-31-001 | Caching hooks for Advisory AI; requires rationale API shape. |
|
||||
| VEXLENS-EXPORT-35-001 | TODO | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-011 | Snapshot API for mirror bundles; export profile pending. |
|
||||
| VEXLENS-ORCH-33-001 | TODO | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-011 | Register consensus compute job; orchestrator contract TBD. |
|
||||
| VEXLENS-ORCH-34-001 | TODO | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-ORCH-33-001 | Emit completion events to orchestrator ledger; needs job spec. |
|
||||
| VEXLENS-30-001 | TODO | SPRINT_0129_0001_0001_policy_reasoning | — | Unblocked 2025-12-05: vex-normalization.schema.json + api-baseline.schema.json created. |
|
||||
| VEXLENS-30-002 | TODO | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-001 | Product mapping library; depends on normalization shapes. |
|
||||
| VEXLENS-30-003 | TODO | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-002 | Signature verification (Ed25519/DSSE/PKIX). |
|
||||
| VEXLENS-30-004 | TODO | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-003 | Trust weighting engine. |
|
||||
| VEXLENS-30-005 | TODO | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-004 | Consensus algorithm. |
|
||||
| VEXLENS-30-006 | TODO | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-005 | Projection storage/events. |
|
||||
| VEXLENS-30-007 | TODO | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-006 | Consensus APIs + OpenAPI. |
|
||||
| VEXLENS-30-008 | TODO | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-007 | Policy Engine/Vuln Explorer integration. |
|
||||
| VEXLENS-30-009 | TODO | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-008 | Telemetry (metrics/logs/traces). |
|
||||
| VEXLENS-30-010 | TODO | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-009 | Tests + determinism harness. |
|
||||
| VEXLENS-30-011 | TODO | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-010 | Deployment/runbooks/offline kit. |
|
||||
| VEXLENS-AIAI-31-001 | BLOCKED | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-011 | Consensus rationale API enhancements; needs consensus API finalization. |
|
||||
| VEXLENS-AIAI-31-002 | BLOCKED | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-AIAI-31-001 | Caching hooks for Advisory AI; requires rationale API shape. |
|
||||
| VEXLENS-EXPORT-35-001 | BLOCKED | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-011 | Snapshot API for mirror bundles; export profile pending. |
|
||||
| VEXLENS-ORCH-33-001 | BLOCKED | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-30-011 | Register consensus compute job; orchestrator contract TBD. |
|
||||
| VEXLENS-ORCH-34-001 | BLOCKED | SPRINT_0129_0001_0001_policy_reasoning | VEXLENS-ORCH-33-001 | Emit completion events to orchestrator ledger; needs job spec. |
|
||||
|
||||
Status source of truth: `docs/implplan/SPRINT_0129_0001_0001_policy_reasoning.md`. Update both files together. Keep UTC dates when advancing status.
|
||||
|
||||
476
src/VexLens/StellaOps.VexLens/Testing/VexLensTestHarness.cs
Normal file
476
src/VexLens/StellaOps.VexLens/Testing/VexLensTestHarness.cs
Normal file
@@ -0,0 +1,476 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.Normalization;
|
||||
using StellaOps.VexLens.Storage;
|
||||
using StellaOps.VexLens.Trust;
|
||||
using StellaOps.VexLens.Verification;
|
||||
|
||||
namespace StellaOps.VexLens.Testing;
|
||||
|
||||
/// <summary>
|
||||
/// Test harness for VexLens operations with determinism verification.
|
||||
/// </summary>
|
||||
public sealed class VexLensTestHarness : IDisposable
|
||||
{
|
||||
private readonly VexNormalizerRegistry _normalizerRegistry;
|
||||
private readonly InMemoryIssuerDirectory _issuerDirectory;
|
||||
private readonly InMemoryConsensusEventEmitter _eventEmitter;
|
||||
private readonly InMemoryConsensusProjectionStore _projectionStore;
|
||||
private readonly TrustWeightEngine _trustWeightEngine;
|
||||
private readonly VexConsensusEngine _consensusEngine;
|
||||
|
||||
public VexLensTestHarness()
|
||||
{
|
||||
_normalizerRegistry = new VexNormalizerRegistry();
|
||||
_normalizerRegistry.Register(new OpenVexNormalizer());
|
||||
_normalizerRegistry.Register(new CsafVexNormalizer());
|
||||
_normalizerRegistry.Register(new CycloneDxVexNormalizer());
|
||||
|
||||
_issuerDirectory = new InMemoryIssuerDirectory();
|
||||
_eventEmitter = new InMemoryConsensusEventEmitter();
|
||||
_projectionStore = new InMemoryConsensusProjectionStore(_eventEmitter);
|
||||
_trustWeightEngine = new TrustWeightEngine();
|
||||
_consensusEngine = new VexConsensusEngine();
|
||||
}
|
||||
|
||||
public IVexNormalizerRegistry NormalizerRegistry => _normalizerRegistry;
|
||||
public IIssuerDirectory IssuerDirectory => _issuerDirectory;
|
||||
public IConsensusEventEmitter EventEmitter => _eventEmitter;
|
||||
public InMemoryConsensusEventEmitter TestEventEmitter => _eventEmitter;
|
||||
public IConsensusProjectionStore ProjectionStore => _projectionStore;
|
||||
public ITrustWeightEngine TrustWeightEngine => _trustWeightEngine;
|
||||
public IVexConsensusEngine ConsensusEngine => _consensusEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes VEX content and returns the result.
|
||||
/// </summary>
|
||||
public async Task<NormalizationResult> NormalizeAsync(
|
||||
string content,
|
||||
string? sourceUri = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizer = _normalizerRegistry.DetectNormalizer(content);
|
||||
if (normalizer == null)
|
||||
{
|
||||
throw new InvalidOperationException("No normalizer found for content");
|
||||
}
|
||||
|
||||
var context = new NormalizationContext(
|
||||
SourceUri: sourceUri,
|
||||
NormalizedAt: DateTimeOffset.UtcNow,
|
||||
Normalizer: "VexLensTestHarness",
|
||||
Options: null);
|
||||
|
||||
return await normalizer.NormalizeAsync(content, context, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes trust weight for a statement.
|
||||
/// </summary>
|
||||
public async Task<TrustWeightResult> ComputeTrustWeightAsync(
|
||||
NormalizedStatement statement,
|
||||
VexIssuer? issuer = null,
|
||||
DateTimeOffset? documentIssuedAt = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var request = new TrustWeightRequest(
|
||||
Statement: statement,
|
||||
Issuer: issuer,
|
||||
SignatureVerification: null,
|
||||
DocumentIssuedAt: documentIssuedAt,
|
||||
Context: new TrustWeightContext(
|
||||
TenantId: null,
|
||||
EvaluationTime: DateTimeOffset.UtcNow,
|
||||
CustomFactors: null));
|
||||
|
||||
return await _trustWeightEngine.ComputeWeightAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes consensus from weighted statements.
|
||||
/// </summary>
|
||||
public async Task<VexConsensusResult> ComputeConsensusAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
IEnumerable<WeightedStatement> statements,
|
||||
ConsensusMode mode = ConsensusMode.WeightedVote,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var request = new VexConsensusRequest(
|
||||
VulnerabilityId: vulnerabilityId,
|
||||
ProductKey: productKey,
|
||||
Statements: statements.ToList(),
|
||||
Context: new ConsensusContext(
|
||||
TenantId: null,
|
||||
EvaluationTime: DateTimeOffset.UtcNow,
|
||||
Policy: new ConsensusPolicy(
|
||||
Mode: mode,
|
||||
MinimumWeightThreshold: 0.1,
|
||||
ConflictThreshold: 0.3,
|
||||
RequireJustificationForNotAffected: false,
|
||||
PreferredIssuers: null)));
|
||||
|
||||
return await _consensusEngine.ComputeConsensusAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a test issuer.
|
||||
/// </summary>
|
||||
public async Task<IssuerRecord> RegisterTestIssuerAsync(
|
||||
string issuerId,
|
||||
string name,
|
||||
IssuerCategory category = IssuerCategory.Vendor,
|
||||
TrustTier trustTier = TrustTier.Trusted,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var registration = new IssuerRegistration(
|
||||
IssuerId: issuerId,
|
||||
Name: name,
|
||||
Category: category,
|
||||
TrustTier: trustTier,
|
||||
InitialKeys: null,
|
||||
Metadata: null);
|
||||
|
||||
return await _issuerDirectory.RegisterIssuerAsync(registration, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a test statement.
|
||||
/// </summary>
|
||||
public static NormalizedStatement CreateTestStatement(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
VexStatus status,
|
||||
VexJustification? justification = null,
|
||||
string? statementId = null)
|
||||
{
|
||||
return new NormalizedStatement(
|
||||
StatementId: statementId ?? $"stmt-{Guid.NewGuid():N}",
|
||||
VulnerabilityId: vulnerabilityId,
|
||||
VulnerabilityAliases: null,
|
||||
Product: new NormalizedProduct(
|
||||
Key: productKey,
|
||||
Name: null,
|
||||
Version: null,
|
||||
Purl: productKey.StartsWith("pkg:") ? productKey : null,
|
||||
Cpe: null,
|
||||
Hashes: null),
|
||||
Status: status,
|
||||
StatusNotes: null,
|
||||
Justification: justification,
|
||||
ImpactStatement: null,
|
||||
ActionStatement: null,
|
||||
ActionStatementTimestamp: null,
|
||||
Versions: null,
|
||||
Subcomponents: null,
|
||||
FirstSeen: DateTimeOffset.UtcNow,
|
||||
LastSeen: DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a test issuer.
|
||||
/// </summary>
|
||||
public static VexIssuer CreateTestIssuer(
|
||||
string id,
|
||||
string name,
|
||||
IssuerCategory category = IssuerCategory.Vendor,
|
||||
TrustTier trustTier = TrustTier.Trusted)
|
||||
{
|
||||
return new VexIssuer(
|
||||
Id: id,
|
||||
Name: name,
|
||||
Category: category,
|
||||
TrustTier: trustTier,
|
||||
KeyFingerprints: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all test data.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
_eventEmitter.Clear();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Cleanup if needed
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determinism verification harness for VexLens operations.
|
||||
/// </summary>
|
||||
public sealed class DeterminismHarness
|
||||
{
|
||||
private readonly VexLensTestHarness _harness;
|
||||
|
||||
public DeterminismHarness()
|
||||
{
|
||||
_harness = new VexLensTestHarness();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that normalization produces deterministic results.
|
||||
/// </summary>
|
||||
public async Task<DeterminismResult> VerifyNormalizationDeterminismAsync(
|
||||
string content,
|
||||
int iterations = 3,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = new List<string>();
|
||||
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
var result = await _harness.NormalizeAsync(content, cancellationToken: cancellationToken);
|
||||
if (result.Success && result.Document != null)
|
||||
{
|
||||
var hash = ComputeDocumentHash(result.Document);
|
||||
results.Add(hash);
|
||||
}
|
||||
else
|
||||
{
|
||||
results.Add($"error:{result.Errors.FirstOrDefault()?.Code}");
|
||||
}
|
||||
}
|
||||
|
||||
var isEqual = results.Distinct().Count() == 1;
|
||||
return new DeterminismResult(
|
||||
Operation: "normalization",
|
||||
IsDeterministic: isEqual,
|
||||
Iterations: iterations,
|
||||
DistinctResults: results.Distinct().Count(),
|
||||
FirstResult: results.FirstOrDefault(),
|
||||
Discrepancies: isEqual ? null : results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that consensus produces deterministic results.
|
||||
/// </summary>
|
||||
public async Task<DeterminismResult> VerifyConsensusDeterminismAsync(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
IEnumerable<(NormalizedStatement Statement, VexIssuer? Issuer)> statements,
|
||||
int iterations = 3,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = new List<string>();
|
||||
var stmtList = statements.ToList();
|
||||
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
var weighted = new List<WeightedStatement>();
|
||||
|
||||
foreach (var (stmt, issuer) in stmtList)
|
||||
{
|
||||
var weight = await _harness.ComputeTrustWeightAsync(stmt, issuer, cancellationToken: cancellationToken);
|
||||
weighted.Add(new WeightedStatement(stmt, weight, issuer, null));
|
||||
}
|
||||
|
||||
var result = await _harness.ComputeConsensusAsync(
|
||||
vulnerabilityId,
|
||||
productKey,
|
||||
weighted,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
var hash = ComputeConsensusHash(result);
|
||||
results.Add(hash);
|
||||
}
|
||||
|
||||
var isEqual = results.Distinct().Count() == 1;
|
||||
return new DeterminismResult(
|
||||
Operation: "consensus",
|
||||
IsDeterministic: isEqual,
|
||||
Iterations: iterations,
|
||||
DistinctResults: results.Distinct().Count(),
|
||||
FirstResult: results.FirstOrDefault(),
|
||||
Discrepancies: isEqual ? null : results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that trust weight computation produces deterministic results.
|
||||
/// </summary>
|
||||
public async Task<DeterminismResult> VerifyTrustWeightDeterminismAsync(
|
||||
NormalizedStatement statement,
|
||||
VexIssuer? issuer,
|
||||
int iterations = 3,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = new List<string>();
|
||||
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
var result = await _harness.ComputeTrustWeightAsync(statement, issuer, cancellationToken: cancellationToken);
|
||||
var hash = $"{result.Weight:F10}";
|
||||
results.Add(hash);
|
||||
}
|
||||
|
||||
var isEqual = results.Distinct().Count() == 1;
|
||||
return new DeterminismResult(
|
||||
Operation: "trust_weight",
|
||||
IsDeterministic: isEqual,
|
||||
Iterations: iterations,
|
||||
DistinctResults: results.Distinct().Count(),
|
||||
FirstResult: results.FirstOrDefault(),
|
||||
Discrepancies: isEqual ? null : results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs all determinism checks.
|
||||
/// </summary>
|
||||
public async Task<DeterminismReport> RunFullDeterminismCheckAsync(
|
||||
string vexContent,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = new List<DeterminismResult>();
|
||||
|
||||
// Normalization
|
||||
var normResult = await VerifyNormalizationDeterminismAsync(vexContent, cancellationToken: cancellationToken);
|
||||
results.Add(normResult);
|
||||
|
||||
// If normalization succeeded, test downstream operations
|
||||
if (normResult.IsDeterministic)
|
||||
{
|
||||
var normalizeResult = await _harness.NormalizeAsync(vexContent, cancellationToken: cancellationToken);
|
||||
if (normalizeResult.Success && normalizeResult.Document != null && normalizeResult.Document.Statements.Count > 0)
|
||||
{
|
||||
var statement = normalizeResult.Document.Statements[0];
|
||||
var issuer = normalizeResult.Document.Issuer;
|
||||
|
||||
// Trust weight
|
||||
var trustResult = await VerifyTrustWeightDeterminismAsync(statement, issuer, cancellationToken: cancellationToken);
|
||||
results.Add(trustResult);
|
||||
|
||||
// Consensus
|
||||
var consensusResult = await VerifyConsensusDeterminismAsync(
|
||||
statement.VulnerabilityId,
|
||||
statement.Product.Key,
|
||||
[(statement, issuer)],
|
||||
cancellationToken: cancellationToken);
|
||||
results.Add(consensusResult);
|
||||
}
|
||||
}
|
||||
|
||||
return new DeterminismReport(
|
||||
Results: results,
|
||||
AllDeterministic: results.All(r => r.IsDeterministic),
|
||||
GeneratedAt: DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
private static string ComputeDocumentHash(NormalizedVexDocument doc)
|
||||
{
|
||||
// Create a stable representation for hashing
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(doc.DocumentId);
|
||||
sb.Append(doc.SourceFormat);
|
||||
sb.Append(doc.Issuer?.Id ?? "null");
|
||||
|
||||
foreach (var stmt in doc.Statements.OrderBy(s => s.StatementId))
|
||||
{
|
||||
sb.Append(stmt.VulnerabilityId);
|
||||
sb.Append(stmt.Product.Key);
|
||||
sb.Append(stmt.Status);
|
||||
sb.Append(stmt.Justification?.ToString() ?? "null");
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ComputeConsensusHash(VexConsensusResult result)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(result.ConsensusStatus);
|
||||
sb.Append(result.ConsensusJustification?.ToString() ?? "null");
|
||||
sb.Append($"{result.ConfidenceScore:F10}");
|
||||
sb.Append(result.Outcome);
|
||||
|
||||
foreach (var contrib in result.Contributions.OrderBy(c => c.StatementId))
|
||||
{
|
||||
sb.Append(contrib.StatementId);
|
||||
sb.Append($"{contrib.Weight:F10}");
|
||||
sb.Append(contrib.IsWinner);
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a determinism check.
|
||||
/// </summary>
|
||||
public sealed record DeterminismResult(
|
||||
string Operation,
|
||||
bool IsDeterministic,
|
||||
int Iterations,
|
||||
int DistinctResults,
|
||||
string? FirstResult,
|
||||
IReadOnlyList<string>? Discrepancies);
|
||||
|
||||
/// <summary>
|
||||
/// Report of determinism checks.
|
||||
/// </summary>
|
||||
public sealed record DeterminismReport(
|
||||
IReadOnlyList<DeterminismResult> Results,
|
||||
bool AllDeterministic,
|
||||
DateTimeOffset GeneratedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Test data generators for VexLens.
|
||||
/// </summary>
|
||||
public static class VexLensTestData
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a sample OpenVEX document.
|
||||
/// </summary>
|
||||
public static string GenerateOpenVexDocument(
|
||||
string vulnerabilityId,
|
||||
string productPurl,
|
||||
VexStatus status,
|
||||
VexJustification? justification = null)
|
||||
{
|
||||
var doc = new
|
||||
{
|
||||
@context = "https://openvex.dev/ns/v0.2.0",
|
||||
@id = $"urn:uuid:{Guid.NewGuid()}",
|
||||
author = new { @id = "test-vendor", name = "Test Vendor" },
|
||||
timestamp = DateTimeOffset.UtcNow.ToString("O"),
|
||||
statements = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
vulnerability = vulnerabilityId,
|
||||
products = new[] { productPurl },
|
||||
status = status.ToString().ToLowerInvariant().Replace("notaffected", "not_affected").Replace("underinvestigation", "under_investigation"),
|
||||
justification = justification?.ToString().ToLowerInvariant()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates sample statements for consensus testing.
|
||||
/// </summary>
|
||||
public static IEnumerable<(NormalizedStatement Statement, VexIssuer Issuer)> GenerateConflictingStatements(
|
||||
string vulnerabilityId,
|
||||
string productKey)
|
||||
{
|
||||
yield return (
|
||||
VexLensTestHarness.CreateTestStatement(vulnerabilityId, productKey, VexStatus.NotAffected, VexJustification.ComponentNotPresent, "stmt-1"),
|
||||
VexLensTestHarness.CreateTestIssuer("vendor-1", "Vendor A", IssuerCategory.Vendor, TrustTier.Authoritative));
|
||||
|
||||
yield return (
|
||||
VexLensTestHarness.CreateTestStatement(vulnerabilityId, productKey, VexStatus.Affected, null, "stmt-2"),
|
||||
VexLensTestHarness.CreateTestIssuer("researcher-1", "Security Researcher", IssuerCategory.Community, TrustTier.Trusted));
|
||||
|
||||
yield return (
|
||||
VexLensTestHarness.CreateTestStatement(vulnerabilityId, productKey, VexStatus.UnderInvestigation, null, "stmt-3"),
|
||||
VexLensTestHarness.CreateTestIssuer("aggregator-1", "VEX Aggregator", IssuerCategory.Aggregator, TrustTier.Unknown));
|
||||
}
|
||||
}
|
||||
152
src/VexLens/StellaOps.VexLens/Trust/ITrustWeightEngine.cs
Normal file
152
src/VexLens/StellaOps.VexLens/Trust/ITrustWeightEngine.cs
Normal file
@@ -0,0 +1,152 @@
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.Verification;
|
||||
|
||||
namespace StellaOps.VexLens.Trust;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for computing trust weights for VEX statements.
|
||||
/// </summary>
|
||||
public interface ITrustWeightEngine
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the trust weight for a VEX statement.
|
||||
/// </summary>
|
||||
Task<TrustWeightResult> ComputeWeightAsync(
|
||||
TrustWeightRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Computes trust weights for multiple statements in batch.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<TrustWeightResult>> ComputeWeightsBatchAsync(
|
||||
IEnumerable<TrustWeightRequest> requests,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current trust weight configuration.
|
||||
/// </summary>
|
||||
TrustWeightConfiguration GetConfiguration();
|
||||
|
||||
/// <summary>
|
||||
/// Updates the trust weight configuration.
|
||||
/// </summary>
|
||||
void UpdateConfiguration(TrustWeightConfiguration configuration);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for trust weight computation.
|
||||
/// </summary>
|
||||
public sealed record TrustWeightRequest(
|
||||
NormalizedStatement Statement,
|
||||
VexIssuer? Issuer,
|
||||
SignatureVerificationResult? SignatureVerification,
|
||||
DateTimeOffset? DocumentIssuedAt,
|
||||
TrustWeightContext Context);
|
||||
|
||||
/// <summary>
|
||||
/// Context for trust weight computation.
|
||||
/// </summary>
|
||||
public sealed record TrustWeightContext(
|
||||
string? TenantId,
|
||||
DateTimeOffset EvaluationTime,
|
||||
IReadOnlyDictionary<string, object?>? CustomFactors);
|
||||
|
||||
/// <summary>
|
||||
/// Result of trust weight computation.
|
||||
/// </summary>
|
||||
public sealed record TrustWeightResult(
|
||||
NormalizedStatement Statement,
|
||||
double Weight,
|
||||
TrustWeightBreakdown Breakdown,
|
||||
IReadOnlyList<TrustWeightFactor> Factors,
|
||||
IReadOnlyList<string> Warnings);
|
||||
|
||||
/// <summary>
|
||||
/// Breakdown of trust weight by component.
|
||||
/// </summary>
|
||||
public sealed record TrustWeightBreakdown(
|
||||
double IssuerWeight,
|
||||
double SignatureWeight,
|
||||
double FreshnessWeight,
|
||||
double SourceFormatWeight,
|
||||
double StatusSpecificityWeight,
|
||||
double CustomWeight);
|
||||
|
||||
/// <summary>
|
||||
/// Individual factor contributing to trust weight.
|
||||
/// </summary>
|
||||
public sealed record TrustWeightFactor(
|
||||
string FactorId,
|
||||
string Name,
|
||||
double RawValue,
|
||||
double WeightedValue,
|
||||
double Multiplier,
|
||||
string? Reason);
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for trust weight computation.
|
||||
/// </summary>
|
||||
public sealed record TrustWeightConfiguration(
|
||||
IssuerTrustWeights IssuerWeights,
|
||||
SignatureTrustWeights SignatureWeights,
|
||||
FreshnessTrustWeights FreshnessWeights,
|
||||
SourceFormatWeights SourceFormatWeights,
|
||||
StatusSpecificityWeights StatusSpecificityWeights,
|
||||
double MinimumWeight,
|
||||
double MaximumWeight);
|
||||
|
||||
/// <summary>
|
||||
/// Trust weights based on issuer category and tier.
|
||||
/// </summary>
|
||||
public sealed record IssuerTrustWeights(
|
||||
double VendorMultiplier,
|
||||
double DistributorMultiplier,
|
||||
double CommunityMultiplier,
|
||||
double InternalMultiplier,
|
||||
double AggregatorMultiplier,
|
||||
double UnknownIssuerMultiplier,
|
||||
double AuthoritativeTierBonus,
|
||||
double TrustedTierBonus,
|
||||
double UntrustedTierPenalty);
|
||||
|
||||
/// <summary>
|
||||
/// Trust weights based on signature verification.
|
||||
/// </summary>
|
||||
public sealed record SignatureTrustWeights(
|
||||
double ValidSignatureMultiplier,
|
||||
double InvalidSignaturePenalty,
|
||||
double NoSignaturePenalty,
|
||||
double ExpiredCertificatePenalty,
|
||||
double RevokedCertificatePenalty,
|
||||
double TimestampedBonus);
|
||||
|
||||
/// <summary>
|
||||
/// Trust weights based on document freshness.
|
||||
/// </summary>
|
||||
public sealed record FreshnessTrustWeights(
|
||||
TimeSpan FreshThreshold,
|
||||
TimeSpan StaleThreshold,
|
||||
TimeSpan ExpiredThreshold,
|
||||
double FreshMultiplier,
|
||||
double StaleMultiplier,
|
||||
double ExpiredMultiplier);
|
||||
|
||||
/// <summary>
|
||||
/// Trust weights based on source format.
|
||||
/// </summary>
|
||||
public sealed record SourceFormatWeights(
|
||||
double OpenVexMultiplier,
|
||||
double CsafVexMultiplier,
|
||||
double CycloneDxVexMultiplier,
|
||||
double SpdxVexMultiplier,
|
||||
double StellaOpsMultiplier);
|
||||
|
||||
/// <summary>
|
||||
/// Trust weights based on status specificity.
|
||||
/// </summary>
|
||||
public sealed record StatusSpecificityWeights(
|
||||
double NotAffectedBonus,
|
||||
double FixedBonus,
|
||||
double AffectedNeutral,
|
||||
double UnderInvestigationPenalty,
|
||||
double JustificationBonus);
|
||||
445
src/VexLens/StellaOps.VexLens/Trust/TrustWeightEngine.cs
Normal file
445
src/VexLens/StellaOps.VexLens/Trust/TrustWeightEngine.cs
Normal file
@@ -0,0 +1,445 @@
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.Verification;
|
||||
|
||||
namespace StellaOps.VexLens.Trust;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="ITrustWeightEngine"/>.
|
||||
/// Computes trust weights based on issuer, signature, freshness, and other factors.
|
||||
/// </summary>
|
||||
public sealed class TrustWeightEngine : ITrustWeightEngine
|
||||
{
|
||||
private TrustWeightConfiguration _configuration;
|
||||
|
||||
public TrustWeightEngine(TrustWeightConfiguration? configuration = null)
|
||||
{
|
||||
_configuration = configuration ?? CreateDefaultConfiguration();
|
||||
}
|
||||
|
||||
public Task<TrustWeightResult> ComputeWeightAsync(
|
||||
TrustWeightRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var factors = new List<TrustWeightFactor>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
// Compute issuer weight
|
||||
var issuerWeight = ComputeIssuerWeight(request.Issuer, factors);
|
||||
|
||||
// Compute signature weight
|
||||
var signatureWeight = ComputeSignatureWeight(request.SignatureVerification, factors);
|
||||
|
||||
// Compute freshness weight
|
||||
var freshnessWeight = ComputeFreshnessWeight(
|
||||
request.DocumentIssuedAt,
|
||||
request.Statement.FirstSeen,
|
||||
request.Context.EvaluationTime,
|
||||
factors);
|
||||
|
||||
// Compute source format weight
|
||||
var sourceFormatWeight = ComputeSourceFormatWeight(request.Statement, factors);
|
||||
|
||||
// Compute status specificity weight
|
||||
var statusWeight = ComputeStatusSpecificityWeight(request.Statement, factors);
|
||||
|
||||
// Compute custom weight
|
||||
var customWeight = ComputeCustomWeight(request.Context.CustomFactors, factors);
|
||||
|
||||
// Combine weights
|
||||
var breakdown = new TrustWeightBreakdown(
|
||||
IssuerWeight: issuerWeight,
|
||||
SignatureWeight: signatureWeight,
|
||||
FreshnessWeight: freshnessWeight,
|
||||
SourceFormatWeight: sourceFormatWeight,
|
||||
StatusSpecificityWeight: statusWeight,
|
||||
CustomWeight: customWeight);
|
||||
|
||||
var combinedWeight = CombineWeights(breakdown);
|
||||
|
||||
// Clamp to configured range
|
||||
var finalWeight = Math.Clamp(combinedWeight, _configuration.MinimumWeight, _configuration.MaximumWeight);
|
||||
|
||||
if (finalWeight != combinedWeight)
|
||||
{
|
||||
warnings.Add($"Weight clamped from {combinedWeight:F4} to {finalWeight:F4}");
|
||||
}
|
||||
|
||||
return Task.FromResult(new TrustWeightResult(
|
||||
Statement: request.Statement,
|
||||
Weight: finalWeight,
|
||||
Breakdown: breakdown,
|
||||
Factors: factors,
|
||||
Warnings: warnings));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TrustWeightResult>> ComputeWeightsBatchAsync(
|
||||
IEnumerable<TrustWeightRequest> requests,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = new List<TrustWeightResult>();
|
||||
|
||||
foreach (var request in requests)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var result = await ComputeWeightAsync(request, cancellationToken);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public TrustWeightConfiguration GetConfiguration() => _configuration;
|
||||
|
||||
public void UpdateConfiguration(TrustWeightConfiguration configuration)
|
||||
{
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
private double ComputeIssuerWeight(VexIssuer? issuer, List<TrustWeightFactor> factors)
|
||||
{
|
||||
var config = _configuration.IssuerWeights;
|
||||
|
||||
if (issuer == null)
|
||||
{
|
||||
factors.Add(new TrustWeightFactor(
|
||||
FactorId: "issuer_unknown",
|
||||
Name: "Unknown Issuer",
|
||||
RawValue: 0.0,
|
||||
WeightedValue: config.UnknownIssuerMultiplier,
|
||||
Multiplier: config.UnknownIssuerMultiplier,
|
||||
Reason: "No issuer information available"));
|
||||
|
||||
return config.UnknownIssuerMultiplier;
|
||||
}
|
||||
|
||||
// Base weight from category
|
||||
var categoryMultiplier = issuer.Category switch
|
||||
{
|
||||
IssuerCategory.Vendor => config.VendorMultiplier,
|
||||
IssuerCategory.Distributor => config.DistributorMultiplier,
|
||||
IssuerCategory.Community => config.CommunityMultiplier,
|
||||
IssuerCategory.Internal => config.InternalMultiplier,
|
||||
IssuerCategory.Aggregator => config.AggregatorMultiplier,
|
||||
_ => config.UnknownIssuerMultiplier
|
||||
};
|
||||
|
||||
factors.Add(new TrustWeightFactor(
|
||||
FactorId: "issuer_category",
|
||||
Name: $"Issuer Category: {issuer.Category}",
|
||||
RawValue: 1.0,
|
||||
WeightedValue: categoryMultiplier,
|
||||
Multiplier: categoryMultiplier,
|
||||
Reason: $"Category '{issuer.Category}' has multiplier {categoryMultiplier:F2}"));
|
||||
|
||||
// Trust tier adjustment
|
||||
var tierAdjustment = issuer.TrustTier switch
|
||||
{
|
||||
TrustTier.Authoritative => config.AuthoritativeTierBonus,
|
||||
TrustTier.Trusted => config.TrustedTierBonus,
|
||||
TrustTier.Untrusted => config.UntrustedTierPenalty,
|
||||
_ => 0.0
|
||||
};
|
||||
|
||||
if (Math.Abs(tierAdjustment) > 0.001)
|
||||
{
|
||||
factors.Add(new TrustWeightFactor(
|
||||
FactorId: "issuer_tier",
|
||||
Name: $"Trust Tier: {issuer.TrustTier}",
|
||||
RawValue: tierAdjustment,
|
||||
WeightedValue: tierAdjustment,
|
||||
Multiplier: 1.0,
|
||||
Reason: $"Trust tier '{issuer.TrustTier}' adjustment: {tierAdjustment:+0.00;-0.00}"));
|
||||
}
|
||||
|
||||
return categoryMultiplier + tierAdjustment;
|
||||
}
|
||||
|
||||
private double ComputeSignatureWeight(SignatureVerificationResult? verification, List<TrustWeightFactor> factors)
|
||||
{
|
||||
var config = _configuration.SignatureWeights;
|
||||
|
||||
if (verification == null)
|
||||
{
|
||||
factors.Add(new TrustWeightFactor(
|
||||
FactorId: "signature_none",
|
||||
Name: "No Signature",
|
||||
RawValue: 0.0,
|
||||
WeightedValue: config.NoSignaturePenalty,
|
||||
Multiplier: config.NoSignaturePenalty,
|
||||
Reason: "Document has no signature or signature not verified"));
|
||||
|
||||
return config.NoSignaturePenalty;
|
||||
}
|
||||
|
||||
double weight;
|
||||
string reason;
|
||||
|
||||
switch (verification.Status)
|
||||
{
|
||||
case SignatureVerificationStatus.Valid:
|
||||
weight = config.ValidSignatureMultiplier;
|
||||
reason = "Signature is valid and verified";
|
||||
break;
|
||||
|
||||
case SignatureVerificationStatus.InvalidSignature:
|
||||
weight = config.InvalidSignaturePenalty;
|
||||
reason = "Signature verification failed";
|
||||
break;
|
||||
|
||||
case SignatureVerificationStatus.ExpiredCertificate:
|
||||
weight = config.ExpiredCertificatePenalty;
|
||||
reason = "Certificate has expired";
|
||||
break;
|
||||
|
||||
case SignatureVerificationStatus.RevokedCertificate:
|
||||
weight = config.RevokedCertificatePenalty;
|
||||
reason = "Certificate has been revoked";
|
||||
break;
|
||||
|
||||
case SignatureVerificationStatus.UntrustedIssuer:
|
||||
weight = config.NoSignaturePenalty;
|
||||
reason = "Signature from untrusted issuer";
|
||||
break;
|
||||
|
||||
default:
|
||||
weight = config.NoSignaturePenalty;
|
||||
reason = $"Signature status: {verification.Status}";
|
||||
break;
|
||||
}
|
||||
|
||||
factors.Add(new TrustWeightFactor(
|
||||
FactorId: "signature_status",
|
||||
Name: $"Signature: {verification.Status}",
|
||||
RawValue: verification.IsValid ? 1.0 : 0.0,
|
||||
WeightedValue: weight,
|
||||
Multiplier: weight,
|
||||
Reason: reason));
|
||||
|
||||
// Timestamp bonus
|
||||
if (verification.IsValid && verification.Timestamp?.IsValid == true)
|
||||
{
|
||||
factors.Add(new TrustWeightFactor(
|
||||
FactorId: "signature_timestamped",
|
||||
Name: "Timestamped Signature",
|
||||
RawValue: 1.0,
|
||||
WeightedValue: config.TimestampedBonus,
|
||||
Multiplier: 1.0,
|
||||
Reason: $"Signature has valid timestamp from {verification.Timestamp.TimestampAuthority}"));
|
||||
|
||||
weight += config.TimestampedBonus;
|
||||
}
|
||||
|
||||
return weight;
|
||||
}
|
||||
|
||||
private double ComputeFreshnessWeight(
|
||||
DateTimeOffset? documentIssuedAt,
|
||||
DateTimeOffset? statementFirstSeen,
|
||||
DateTimeOffset evaluationTime,
|
||||
List<TrustWeightFactor> factors)
|
||||
{
|
||||
var config = _configuration.FreshnessWeights;
|
||||
var referenceTime = documentIssuedAt ?? statementFirstSeen;
|
||||
|
||||
if (!referenceTime.HasValue)
|
||||
{
|
||||
factors.Add(new TrustWeightFactor(
|
||||
FactorId: "freshness_unknown",
|
||||
Name: "Unknown Age",
|
||||
RawValue: 0.0,
|
||||
WeightedValue: config.StaleMultiplier,
|
||||
Multiplier: config.StaleMultiplier,
|
||||
Reason: "No timestamp available to determine freshness"));
|
||||
|
||||
return config.StaleMultiplier;
|
||||
}
|
||||
|
||||
var age = evaluationTime - referenceTime.Value;
|
||||
double weight;
|
||||
string category;
|
||||
|
||||
if (age < config.FreshThreshold)
|
||||
{
|
||||
weight = config.FreshMultiplier;
|
||||
category = "Fresh";
|
||||
}
|
||||
else if (age < config.StaleThreshold)
|
||||
{
|
||||
weight = config.StaleMultiplier;
|
||||
category = "Stale";
|
||||
}
|
||||
else
|
||||
{
|
||||
weight = config.ExpiredMultiplier;
|
||||
category = "Expired";
|
||||
}
|
||||
|
||||
factors.Add(new TrustWeightFactor(
|
||||
FactorId: "freshness",
|
||||
Name: $"Freshness: {category}",
|
||||
RawValue: age.TotalDays,
|
||||
WeightedValue: weight,
|
||||
Multiplier: weight,
|
||||
Reason: $"Document age: {FormatAge(age)} ({category})"));
|
||||
|
||||
return weight;
|
||||
}
|
||||
|
||||
private double ComputeSourceFormatWeight(NormalizedStatement statement, List<TrustWeightFactor> factors)
|
||||
{
|
||||
// Note: We don't have direct access to source format from statement
|
||||
// This would typically come from the document context
|
||||
// For now, return neutral weight
|
||||
var config = _configuration.SourceFormatWeights;
|
||||
|
||||
factors.Add(new TrustWeightFactor(
|
||||
FactorId: "source_format",
|
||||
Name: "Source Format",
|
||||
RawValue: 1.0,
|
||||
WeightedValue: 1.0,
|
||||
Multiplier: 1.0,
|
||||
Reason: "Source format weight applied at document level"));
|
||||
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
private double ComputeStatusSpecificityWeight(NormalizedStatement statement, List<TrustWeightFactor> factors)
|
||||
{
|
||||
var config = _configuration.StatusSpecificityWeights;
|
||||
|
||||
var statusWeight = statement.Status switch
|
||||
{
|
||||
VexStatus.NotAffected => config.NotAffectedBonus,
|
||||
VexStatus.Fixed => config.FixedBonus,
|
||||
VexStatus.Affected => config.AffectedNeutral,
|
||||
VexStatus.UnderInvestigation => config.UnderInvestigationPenalty,
|
||||
_ => 0.0
|
||||
};
|
||||
|
||||
factors.Add(new TrustWeightFactor(
|
||||
FactorId: "status",
|
||||
Name: $"Status: {statement.Status}",
|
||||
RawValue: 1.0,
|
||||
WeightedValue: statusWeight,
|
||||
Multiplier: 1.0,
|
||||
Reason: $"Status '{statement.Status}' weight adjustment"));
|
||||
|
||||
// Justification bonus for not_affected
|
||||
if (statement.Status == VexStatus.NotAffected && statement.Justification.HasValue)
|
||||
{
|
||||
factors.Add(new TrustWeightFactor(
|
||||
FactorId: "justification",
|
||||
Name: $"Justification: {statement.Justification}",
|
||||
RawValue: 1.0,
|
||||
WeightedValue: config.JustificationBonus,
|
||||
Multiplier: 1.0,
|
||||
Reason: $"Has justification: {statement.Justification}"));
|
||||
|
||||
statusWeight += config.JustificationBonus;
|
||||
}
|
||||
|
||||
return statusWeight;
|
||||
}
|
||||
|
||||
private double ComputeCustomWeight(
|
||||
IReadOnlyDictionary<string, object?>? customFactors,
|
||||
List<TrustWeightFactor> factors)
|
||||
{
|
||||
if (customFactors == null || customFactors.Count == 0)
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
double totalCustomWeight = 0.0;
|
||||
|
||||
foreach (var (key, value) in customFactors)
|
||||
{
|
||||
if (value is double d)
|
||||
{
|
||||
factors.Add(new TrustWeightFactor(
|
||||
FactorId: $"custom_{key}",
|
||||
Name: $"Custom: {key}",
|
||||
RawValue: d,
|
||||
WeightedValue: d,
|
||||
Multiplier: 1.0,
|
||||
Reason: $"Custom factor '{key}'"));
|
||||
|
||||
totalCustomWeight += d;
|
||||
}
|
||||
}
|
||||
|
||||
return totalCustomWeight;
|
||||
}
|
||||
|
||||
private double CombineWeights(TrustWeightBreakdown breakdown)
|
||||
{
|
||||
// Multiplicative combination with additive adjustments
|
||||
var baseWeight = breakdown.IssuerWeight * breakdown.SignatureWeight * breakdown.FreshnessWeight;
|
||||
var adjustments = breakdown.StatusSpecificityWeight + breakdown.CustomWeight;
|
||||
|
||||
return baseWeight + adjustments;
|
||||
}
|
||||
|
||||
private static string FormatAge(TimeSpan age)
|
||||
{
|
||||
if (age.TotalDays >= 365)
|
||||
{
|
||||
return $"{age.TotalDays / 365:F1} years";
|
||||
}
|
||||
|
||||
if (age.TotalDays >= 30)
|
||||
{
|
||||
return $"{age.TotalDays / 30:F1} months";
|
||||
}
|
||||
|
||||
if (age.TotalDays >= 1)
|
||||
{
|
||||
return $"{age.TotalDays:F1} days";
|
||||
}
|
||||
|
||||
return $"{age.TotalHours:F1} hours";
|
||||
}
|
||||
|
||||
public static TrustWeightConfiguration CreateDefaultConfiguration()
|
||||
{
|
||||
return new TrustWeightConfiguration(
|
||||
IssuerWeights: new IssuerTrustWeights(
|
||||
VendorMultiplier: 1.0,
|
||||
DistributorMultiplier: 0.9,
|
||||
CommunityMultiplier: 0.7,
|
||||
InternalMultiplier: 0.8,
|
||||
AggregatorMultiplier: 0.6,
|
||||
UnknownIssuerMultiplier: 0.3,
|
||||
AuthoritativeTierBonus: 0.2,
|
||||
TrustedTierBonus: 0.1,
|
||||
UntrustedTierPenalty: -0.3),
|
||||
SignatureWeights: new SignatureTrustWeights(
|
||||
ValidSignatureMultiplier: 1.0,
|
||||
InvalidSignaturePenalty: 0.1,
|
||||
NoSignaturePenalty: 0.5,
|
||||
ExpiredCertificatePenalty: 0.3,
|
||||
RevokedCertificatePenalty: 0.1,
|
||||
TimestampedBonus: 0.1),
|
||||
FreshnessWeights: new FreshnessTrustWeights(
|
||||
FreshThreshold: TimeSpan.FromDays(7),
|
||||
StaleThreshold: TimeSpan.FromDays(90),
|
||||
ExpiredThreshold: TimeSpan.FromDays(365),
|
||||
FreshMultiplier: 1.0,
|
||||
StaleMultiplier: 0.8,
|
||||
ExpiredMultiplier: 0.5),
|
||||
SourceFormatWeights: new SourceFormatWeights(
|
||||
OpenVexMultiplier: 1.0,
|
||||
CsafVexMultiplier: 1.0,
|
||||
CycloneDxVexMultiplier: 0.95,
|
||||
SpdxVexMultiplier: 0.9,
|
||||
StellaOpsMultiplier: 1.0),
|
||||
StatusSpecificityWeights: new StatusSpecificityWeights(
|
||||
NotAffectedBonus: 0.1,
|
||||
FixedBonus: 0.05,
|
||||
AffectedNeutral: 0.0,
|
||||
UnderInvestigationPenalty: -0.1,
|
||||
JustificationBonus: 0.1),
|
||||
MinimumWeight: 0.0,
|
||||
MaximumWeight: 1.5);
|
||||
}
|
||||
}
|
||||
206
src/VexLens/StellaOps.VexLens/Verification/IIssuerDirectory.cs
Normal file
206
src/VexLens/StellaOps.VexLens/Verification/IIssuerDirectory.cs
Normal file
@@ -0,0 +1,206 @@
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Verification;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for managing VEX document issuers and their trust configuration.
|
||||
/// </summary>
|
||||
public interface IIssuerDirectory
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets an issuer by ID.
|
||||
/// </summary>
|
||||
Task<IssuerRecord?> GetIssuerAsync(
|
||||
string issuerId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an issuer by key fingerprint.
|
||||
/// </summary>
|
||||
Task<IssuerRecord?> GetIssuerByKeyFingerprintAsync(
|
||||
string fingerprint,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all registered issuers.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<IssuerRecord>> ListIssuersAsync(
|
||||
IssuerListOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Registers or updates an issuer.
|
||||
/// </summary>
|
||||
Task<IssuerRecord> RegisterIssuerAsync(
|
||||
IssuerRegistration registration,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Revokes an issuer's trust.
|
||||
/// </summary>
|
||||
Task<bool> RevokeIssuerAsync(
|
||||
string issuerId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a key fingerprint to an issuer.
|
||||
/// </summary>
|
||||
Task<IssuerRecord> AddKeyFingerprintAsync(
|
||||
string issuerId,
|
||||
KeyFingerprintRegistration keyRegistration,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Revokes a key fingerprint.
|
||||
/// </summary>
|
||||
Task<bool> RevokeKeyFingerprintAsync(
|
||||
string issuerId,
|
||||
string fingerprint,
|
||||
string reason,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validates an issuer's trust status.
|
||||
/// </summary>
|
||||
Task<IssuerTrustValidation> ValidateTrustAsync(
|
||||
string issuerId,
|
||||
string? keyFingerprint,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record for a registered issuer.
|
||||
/// </summary>
|
||||
public sealed record IssuerRecord(
|
||||
string IssuerId,
|
||||
string Name,
|
||||
IssuerCategory Category,
|
||||
TrustTier TrustTier,
|
||||
IssuerStatus Status,
|
||||
IReadOnlyList<KeyFingerprintRecord> KeyFingerprints,
|
||||
IssuerMetadata? Metadata,
|
||||
DateTimeOffset RegisteredAt,
|
||||
DateTimeOffset? LastUpdatedAt,
|
||||
DateTimeOffset? RevokedAt,
|
||||
string? RevocationReason);
|
||||
|
||||
/// <summary>
|
||||
/// Status of an issuer.
|
||||
/// </summary>
|
||||
public enum IssuerStatus
|
||||
{
|
||||
Active,
|
||||
Suspended,
|
||||
Revoked
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record for a key fingerprint.
|
||||
/// </summary>
|
||||
public sealed record KeyFingerprintRecord(
|
||||
string Fingerprint,
|
||||
KeyType KeyType,
|
||||
string? Algorithm,
|
||||
KeyFingerprintStatus Status,
|
||||
DateTimeOffset RegisteredAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
DateTimeOffset? RevokedAt,
|
||||
string? RevocationReason);
|
||||
|
||||
/// <summary>
|
||||
/// Type of cryptographic key.
|
||||
/// </summary>
|
||||
public enum KeyType
|
||||
{
|
||||
Pgp,
|
||||
X509,
|
||||
Jwk,
|
||||
Ssh,
|
||||
Sigstore
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of a key fingerprint.
|
||||
/// </summary>
|
||||
public enum KeyFingerprintStatus
|
||||
{
|
||||
Active,
|
||||
Expired,
|
||||
Revoked
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for an issuer.
|
||||
/// </summary>
|
||||
public sealed record IssuerMetadata(
|
||||
string? Description,
|
||||
string? Uri,
|
||||
string? Email,
|
||||
string? LogoUri,
|
||||
IReadOnlyList<string>? Tags,
|
||||
IReadOnlyDictionary<string, string>? Custom);
|
||||
|
||||
/// <summary>
|
||||
/// Options for listing issuers.
|
||||
/// </summary>
|
||||
public sealed record IssuerListOptions(
|
||||
IssuerCategory? Category,
|
||||
TrustTier? MinimumTrustTier,
|
||||
IssuerStatus? Status,
|
||||
string? SearchTerm,
|
||||
int? Limit,
|
||||
int? Offset);
|
||||
|
||||
/// <summary>
|
||||
/// Registration for a new issuer.
|
||||
/// </summary>
|
||||
public sealed record IssuerRegistration(
|
||||
string IssuerId,
|
||||
string Name,
|
||||
IssuerCategory Category,
|
||||
TrustTier TrustTier,
|
||||
IReadOnlyList<KeyFingerprintRegistration>? InitialKeys,
|
||||
IssuerMetadata? Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Registration for a key fingerprint.
|
||||
/// </summary>
|
||||
public sealed record KeyFingerprintRegistration(
|
||||
string Fingerprint,
|
||||
KeyType KeyType,
|
||||
string? Algorithm,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
byte[]? PublicKey);
|
||||
|
||||
/// <summary>
|
||||
/// Result of trust validation.
|
||||
/// </summary>
|
||||
public sealed record IssuerTrustValidation(
|
||||
bool IsTrusted,
|
||||
TrustTier EffectiveTrustTier,
|
||||
IssuerTrustStatus IssuerStatus,
|
||||
KeyTrustStatus? KeyStatus,
|
||||
IReadOnlyList<string> Warnings);
|
||||
|
||||
/// <summary>
|
||||
/// Trust status of an issuer.
|
||||
/// </summary>
|
||||
public enum IssuerTrustStatus
|
||||
{
|
||||
Trusted,
|
||||
NotRegistered,
|
||||
Suspended,
|
||||
Revoked
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trust status of a key.
|
||||
/// </summary>
|
||||
public enum KeyTrustStatus
|
||||
{
|
||||
Valid,
|
||||
NotRegistered,
|
||||
Expired,
|
||||
Revoked
|
||||
}
|
||||
182
src/VexLens/StellaOps.VexLens/Verification/ISignatureVerifier.cs
Normal file
182
src/VexLens/StellaOps.VexLens/Verification/ISignatureVerifier.cs
Normal file
@@ -0,0 +1,182 @@
|
||||
namespace StellaOps.VexLens.Verification;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for VEX document signature verification.
|
||||
/// </summary>
|
||||
public interface ISignatureVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the signature formats this verifier supports.
|
||||
/// </summary>
|
||||
IReadOnlyList<SignatureFormat> SupportedFormats { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the signature on a VEX document.
|
||||
/// </summary>
|
||||
Task<SignatureVerificationResult> VerifyAsync(
|
||||
SignatureVerificationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts signature information without full verification.
|
||||
/// </summary>
|
||||
Task<SignatureExtractionResult> ExtractSignatureInfoAsync(
|
||||
byte[] signedData,
|
||||
SignatureFormat format,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for signature verification.
|
||||
/// </summary>
|
||||
public sealed record SignatureVerificationRequest(
|
||||
byte[] Content,
|
||||
byte[]? DetachedSignature,
|
||||
SignatureFormat Format,
|
||||
SignatureVerificationOptions Options);
|
||||
|
||||
/// <summary>
|
||||
/// Options for signature verification.
|
||||
/// </summary>
|
||||
public sealed record SignatureVerificationOptions(
|
||||
bool RequireTimestamp,
|
||||
bool AllowExpiredCertificates,
|
||||
bool CheckRevocation,
|
||||
IReadOnlyList<string>? TrustedIssuers,
|
||||
IReadOnlyList<string>? TrustedKeyFingerprints,
|
||||
DateTimeOffset? VerificationTime);
|
||||
|
||||
/// <summary>
|
||||
/// Result of signature verification.
|
||||
/// </summary>
|
||||
public sealed record SignatureVerificationResult(
|
||||
bool IsValid,
|
||||
SignatureVerificationStatus Status,
|
||||
SignerInfo? Signer,
|
||||
IReadOnlyList<CertificateInfo>? CertificateChain,
|
||||
TimestampInfo? Timestamp,
|
||||
IReadOnlyList<SignatureVerificationError> Errors,
|
||||
IReadOnlyList<SignatureVerificationWarning> Warnings);
|
||||
|
||||
/// <summary>
|
||||
/// Status of signature verification.
|
||||
/// </summary>
|
||||
public enum SignatureVerificationStatus
|
||||
{
|
||||
Valid,
|
||||
InvalidSignature,
|
||||
ExpiredCertificate,
|
||||
RevokedCertificate,
|
||||
UntrustedIssuer,
|
||||
MissingSignature,
|
||||
UnsupportedFormat,
|
||||
CertificateChainError,
|
||||
TimestampError,
|
||||
UnknownError
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about the signer.
|
||||
/// </summary>
|
||||
public sealed record SignerInfo(
|
||||
string IssuerId,
|
||||
string? Name,
|
||||
string? Email,
|
||||
string? Organization,
|
||||
string KeyFingerprint,
|
||||
string Algorithm,
|
||||
DateTimeOffset? SignedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Information about a certificate in the chain.
|
||||
/// </summary>
|
||||
public sealed record CertificateInfo(
|
||||
string Subject,
|
||||
string Issuer,
|
||||
string SerialNumber,
|
||||
string Fingerprint,
|
||||
DateTimeOffset NotBefore,
|
||||
DateTimeOffset NotAfter,
|
||||
IReadOnlyList<string> KeyUsages,
|
||||
bool IsSelfSigned,
|
||||
bool IsCA);
|
||||
|
||||
/// <summary>
|
||||
/// Information about a timestamp.
|
||||
/// </summary>
|
||||
public sealed record TimestampInfo(
|
||||
DateTimeOffset Timestamp,
|
||||
string? TimestampAuthority,
|
||||
string? TimestampAuthorityUri,
|
||||
bool IsValid);
|
||||
|
||||
/// <summary>
|
||||
/// Error during signature verification.
|
||||
/// </summary>
|
||||
public sealed record SignatureVerificationError(
|
||||
string Code,
|
||||
string Message,
|
||||
string? Detail);
|
||||
|
||||
/// <summary>
|
||||
/// Warning during signature verification.
|
||||
/// </summary>
|
||||
public sealed record SignatureVerificationWarning(
|
||||
string Code,
|
||||
string Message);
|
||||
|
||||
/// <summary>
|
||||
/// Result of signature extraction.
|
||||
/// </summary>
|
||||
public sealed record SignatureExtractionResult(
|
||||
bool Success,
|
||||
SignatureFormat? DetectedFormat,
|
||||
SignerInfo? Signer,
|
||||
IReadOnlyList<CertificateInfo>? Certificates,
|
||||
string? ErrorMessage);
|
||||
|
||||
/// <summary>
|
||||
/// Supported signature formats.
|
||||
/// </summary>
|
||||
public enum SignatureFormat
|
||||
{
|
||||
/// <summary>
|
||||
/// Detached PGP/GPG signature (.sig, .asc).
|
||||
/// </summary>
|
||||
PgpDetached,
|
||||
|
||||
/// <summary>
|
||||
/// Inline PGP/GPG signature (cleartext signed).
|
||||
/// </summary>
|
||||
PgpInline,
|
||||
|
||||
/// <summary>
|
||||
/// PKCS#7/CMS detached signature (.p7s).
|
||||
/// </summary>
|
||||
Pkcs7Detached,
|
||||
|
||||
/// <summary>
|
||||
/// PKCS#7/CMS enveloped signature.
|
||||
/// </summary>
|
||||
Pkcs7Enveloped,
|
||||
|
||||
/// <summary>
|
||||
/// JSON Web Signature (JWS).
|
||||
/// </summary>
|
||||
Jws,
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope (Dead Simple Signing Envelope).
|
||||
/// </summary>
|
||||
Dsse,
|
||||
|
||||
/// <summary>
|
||||
/// Sigstore bundle format.
|
||||
/// </summary>
|
||||
SigstoreBundle,
|
||||
|
||||
/// <summary>
|
||||
/// in-toto attestation envelope.
|
||||
/// </summary>
|
||||
InToto
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Verification;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IIssuerDirectory"/>.
|
||||
/// Suitable for testing and single-instance deployments.
|
||||
/// </summary>
|
||||
public sealed class InMemoryIssuerDirectory : IIssuerDirectory
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, IssuerRecord> _issuers = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, string> _fingerprintToIssuer = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public Task<IssuerRecord?> GetIssuerAsync(
|
||||
string issuerId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_issuers.TryGetValue(issuerId, out var issuer);
|
||||
return Task.FromResult(issuer);
|
||||
}
|
||||
|
||||
public Task<IssuerRecord?> GetIssuerByKeyFingerprintAsync(
|
||||
string fingerprint,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_fingerprintToIssuer.TryGetValue(fingerprint, out var issuerId))
|
||||
{
|
||||
_issuers.TryGetValue(issuerId, out var issuer);
|
||||
return Task.FromResult(issuer);
|
||||
}
|
||||
|
||||
return Task.FromResult<IssuerRecord?>(null);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<IssuerRecord>> ListIssuersAsync(
|
||||
IssuerListOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _issuers.Values.AsEnumerable();
|
||||
|
||||
if (options != null)
|
||||
{
|
||||
if (options.Category.HasValue)
|
||||
{
|
||||
query = query.Where(i => i.Category == options.Category.Value);
|
||||
}
|
||||
|
||||
if (options.MinimumTrustTier.HasValue)
|
||||
{
|
||||
query = query.Where(i => i.TrustTier >= options.MinimumTrustTier.Value);
|
||||
}
|
||||
|
||||
if (options.Status.HasValue)
|
||||
{
|
||||
query = query.Where(i => i.Status == options.Status.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.SearchTerm))
|
||||
{
|
||||
var term = options.SearchTerm;
|
||||
query = query.Where(i =>
|
||||
i.Name.Contains(term, StringComparison.OrdinalIgnoreCase) ||
|
||||
i.IssuerId.Contains(term, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (options.Offset.HasValue)
|
||||
{
|
||||
query = query.Skip(options.Offset.Value);
|
||||
}
|
||||
|
||||
if (options.Limit.HasValue)
|
||||
{
|
||||
query = query.Take(options.Limit.Value);
|
||||
}
|
||||
}
|
||||
|
||||
var result = query
|
||||
.OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<IssuerRecord>>(result);
|
||||
}
|
||||
|
||||
public Task<IssuerRecord> RegisterIssuerAsync(
|
||||
IssuerRegistration registration,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var keyRecords = new List<KeyFingerprintRecord>();
|
||||
|
||||
if (registration.InitialKeys != null)
|
||||
{
|
||||
foreach (var key in registration.InitialKeys)
|
||||
{
|
||||
keyRecords.Add(new KeyFingerprintRecord(
|
||||
Fingerprint: key.Fingerprint,
|
||||
KeyType: key.KeyType,
|
||||
Algorithm: key.Algorithm,
|
||||
Status: KeyFingerprintStatus.Active,
|
||||
RegisteredAt: now,
|
||||
ExpiresAt: key.ExpiresAt,
|
||||
RevokedAt: null,
|
||||
RevocationReason: null));
|
||||
|
||||
_fingerprintToIssuer[key.Fingerprint] = registration.IssuerId;
|
||||
}
|
||||
}
|
||||
|
||||
var record = new IssuerRecord(
|
||||
IssuerId: registration.IssuerId,
|
||||
Name: registration.Name,
|
||||
Category: registration.Category,
|
||||
TrustTier: registration.TrustTier,
|
||||
Status: IssuerStatus.Active,
|
||||
KeyFingerprints: keyRecords,
|
||||
Metadata: registration.Metadata,
|
||||
RegisteredAt: now,
|
||||
LastUpdatedAt: null,
|
||||
RevokedAt: null,
|
||||
RevocationReason: null);
|
||||
|
||||
_issuers[registration.IssuerId] = record;
|
||||
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
|
||||
public Task<bool> RevokeIssuerAsync(
|
||||
string issuerId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_issuers.TryGetValue(issuerId, out var current))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var updated = current with
|
||||
{
|
||||
Status = IssuerStatus.Revoked,
|
||||
RevokedAt = now,
|
||||
RevocationReason = reason,
|
||||
LastUpdatedAt = now
|
||||
};
|
||||
|
||||
_issuers[issuerId] = updated;
|
||||
|
||||
// Also revoke all keys
|
||||
foreach (var key in current.KeyFingerprints)
|
||||
{
|
||||
_fingerprintToIssuer.TryRemove(key.Fingerprint, out _);
|
||||
}
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task<IssuerRecord> AddKeyFingerprintAsync(
|
||||
string issuerId,
|
||||
KeyFingerprintRegistration keyRegistration,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_issuers.TryGetValue(issuerId, out var current))
|
||||
{
|
||||
throw new InvalidOperationException($"Issuer '{issuerId}' not found");
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var newKey = new KeyFingerprintRecord(
|
||||
Fingerprint: keyRegistration.Fingerprint,
|
||||
KeyType: keyRegistration.KeyType,
|
||||
Algorithm: keyRegistration.Algorithm,
|
||||
Status: KeyFingerprintStatus.Active,
|
||||
RegisteredAt: now,
|
||||
ExpiresAt: keyRegistration.ExpiresAt,
|
||||
RevokedAt: null,
|
||||
RevocationReason: null);
|
||||
|
||||
var updatedKeys = current.KeyFingerprints.Append(newKey).ToList();
|
||||
var updated = current with
|
||||
{
|
||||
KeyFingerprints = updatedKeys,
|
||||
LastUpdatedAt = now
|
||||
};
|
||||
|
||||
_issuers[issuerId] = updated;
|
||||
_fingerprintToIssuer[keyRegistration.Fingerprint] = issuerId;
|
||||
|
||||
return Task.FromResult(updated);
|
||||
}
|
||||
|
||||
public Task<bool> RevokeKeyFingerprintAsync(
|
||||
string issuerId,
|
||||
string fingerprint,
|
||||
string reason,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_issuers.TryGetValue(issuerId, out var current))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var keyIndex = current.KeyFingerprints
|
||||
.Select((k, i) => (k, i))
|
||||
.FirstOrDefault(x => x.k.Fingerprint == fingerprint);
|
||||
|
||||
if (keyIndex.k == null)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var revokedKey = keyIndex.k with
|
||||
{
|
||||
Status = KeyFingerprintStatus.Revoked,
|
||||
RevokedAt = now,
|
||||
RevocationReason = reason
|
||||
};
|
||||
|
||||
var updatedKeys = current.KeyFingerprints.ToList();
|
||||
updatedKeys[keyIndex.i] = revokedKey;
|
||||
|
||||
var updated = current with
|
||||
{
|
||||
KeyFingerprints = updatedKeys,
|
||||
LastUpdatedAt = now
|
||||
};
|
||||
|
||||
_issuers[issuerId] = updated;
|
||||
_fingerprintToIssuer.TryRemove(fingerprint, out _);
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task<IssuerTrustValidation> ValidateTrustAsync(
|
||||
string issuerId,
|
||||
string? keyFingerprint,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
|
||||
if (!_issuers.TryGetValue(issuerId, out var issuer))
|
||||
{
|
||||
return Task.FromResult(new IssuerTrustValidation(
|
||||
IsTrusted: false,
|
||||
EffectiveTrustTier: TrustTier.Unknown,
|
||||
IssuerStatus: IssuerTrustStatus.NotRegistered,
|
||||
KeyStatus: null,
|
||||
Warnings: ["Issuer is not registered in the directory"]));
|
||||
}
|
||||
|
||||
var issuerStatus = issuer.Status switch
|
||||
{
|
||||
IssuerStatus.Active => IssuerTrustStatus.Trusted,
|
||||
IssuerStatus.Suspended => IssuerTrustStatus.Suspended,
|
||||
IssuerStatus.Revoked => IssuerTrustStatus.Revoked,
|
||||
_ => IssuerTrustStatus.NotRegistered
|
||||
};
|
||||
|
||||
if (issuerStatus != IssuerTrustStatus.Trusted)
|
||||
{
|
||||
return Task.FromResult(new IssuerTrustValidation(
|
||||
IsTrusted: false,
|
||||
EffectiveTrustTier: TrustTier.Untrusted,
|
||||
IssuerStatus: issuerStatus,
|
||||
KeyStatus: null,
|
||||
Warnings: [$"Issuer status is {issuer.Status}"]));
|
||||
}
|
||||
|
||||
KeyTrustStatus? keyStatus = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(keyFingerprint))
|
||||
{
|
||||
var key = issuer.KeyFingerprints
|
||||
.FirstOrDefault(k => k.Fingerprint.Equals(keyFingerprint, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (key == null)
|
||||
{
|
||||
keyStatus = KeyTrustStatus.NotRegistered;
|
||||
warnings.Add("Key fingerprint is not registered for this issuer");
|
||||
}
|
||||
else if (key.Status == KeyFingerprintStatus.Revoked)
|
||||
{
|
||||
keyStatus = KeyTrustStatus.Revoked;
|
||||
warnings.Add($"Key was revoked: {key.RevocationReason}");
|
||||
}
|
||||
else if (key.ExpiresAt.HasValue && key.ExpiresAt.Value < DateTimeOffset.UtcNow)
|
||||
{
|
||||
keyStatus = KeyTrustStatus.Expired;
|
||||
warnings.Add($"Key expired on {key.ExpiresAt.Value:O}");
|
||||
}
|
||||
else
|
||||
{
|
||||
keyStatus = KeyTrustStatus.Valid;
|
||||
}
|
||||
}
|
||||
|
||||
var isTrusted = issuerStatus == IssuerTrustStatus.Trusted &&
|
||||
(keyStatus == null || keyStatus == KeyTrustStatus.Valid);
|
||||
|
||||
var effectiveTier = isTrusted ? issuer.TrustTier : TrustTier.Untrusted;
|
||||
|
||||
return Task.FromResult(new IssuerTrustValidation(
|
||||
IsTrusted: isTrusted,
|
||||
EffectiveTrustTier: effectiveTier,
|
||||
IssuerStatus: issuerStatus,
|
||||
KeyStatus: keyStatus,
|
||||
Warnings: warnings));
|
||||
}
|
||||
}
|
||||
424
src/VexLens/StellaOps.VexLens/Verification/SignatureVerifier.cs
Normal file
424
src/VexLens/StellaOps.VexLens/Verification/SignatureVerifier.cs
Normal file
@@ -0,0 +1,424 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.VexLens.Verification;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="ISignatureVerifier"/>.
|
||||
/// Provides basic signature verification with extensible format support.
|
||||
/// </summary>
|
||||
public sealed class SignatureVerifier : ISignatureVerifier
|
||||
{
|
||||
private readonly IIssuerDirectory? _issuerDirectory;
|
||||
private readonly Dictionary<SignatureFormat, ISignatureFormatHandler> _handlers = [];
|
||||
|
||||
public SignatureVerifier(IIssuerDirectory? issuerDirectory = null)
|
||||
{
|
||||
_issuerDirectory = issuerDirectory;
|
||||
|
||||
// Register default handlers
|
||||
RegisterHandler(new DsseSignatureHandler());
|
||||
RegisterHandler(new JwsSignatureHandler());
|
||||
}
|
||||
|
||||
public IReadOnlyList<SignatureFormat> SupportedFormats =>
|
||||
_handlers.Keys.ToList();
|
||||
|
||||
public void RegisterHandler(ISignatureFormatHandler handler)
|
||||
{
|
||||
_handlers[handler.Format] = handler;
|
||||
}
|
||||
|
||||
public async Task<SignatureVerificationResult> VerifyAsync(
|
||||
SignatureVerificationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_handlers.TryGetValue(request.Format, out var handler))
|
||||
{
|
||||
return new SignatureVerificationResult(
|
||||
IsValid: false,
|
||||
Status: SignatureVerificationStatus.UnsupportedFormat,
|
||||
Signer: null,
|
||||
CertificateChain: null,
|
||||
Timestamp: null,
|
||||
Errors: [new SignatureVerificationError(
|
||||
"ERR_SIG_001",
|
||||
$"Unsupported signature format: {request.Format}",
|
||||
null)],
|
||||
Warnings: []);
|
||||
}
|
||||
|
||||
var result = await handler.VerifyAsync(request, cancellationToken);
|
||||
|
||||
// Validate against issuer directory if available
|
||||
if (result.IsValid && _issuerDirectory != null && result.Signer != null)
|
||||
{
|
||||
var trustValidation = await _issuerDirectory.ValidateTrustAsync(
|
||||
result.Signer.IssuerId,
|
||||
result.Signer.KeyFingerprint,
|
||||
cancellationToken);
|
||||
|
||||
if (!trustValidation.IsTrusted)
|
||||
{
|
||||
var warnings = result.Warnings.ToList();
|
||||
warnings.AddRange(trustValidation.Warnings.Select(w =>
|
||||
new SignatureVerificationWarning("WARN_TRUST", w)));
|
||||
|
||||
return result with
|
||||
{
|
||||
Status = trustValidation.IssuerStatus switch
|
||||
{
|
||||
IssuerTrustStatus.NotRegistered => SignatureVerificationStatus.UntrustedIssuer,
|
||||
IssuerTrustStatus.Revoked => SignatureVerificationStatus.RevokedCertificate,
|
||||
_ => SignatureVerificationStatus.UntrustedIssuer
|
||||
},
|
||||
Warnings = warnings
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<SignatureExtractionResult> ExtractSignatureInfoAsync(
|
||||
byte[] signedData,
|
||||
SignatureFormat format,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_handlers.TryGetValue(format, out var handler))
|
||||
{
|
||||
return new SignatureExtractionResult(
|
||||
Success: false,
|
||||
DetectedFormat: null,
|
||||
Signer: null,
|
||||
Certificates: null,
|
||||
ErrorMessage: $"Unsupported signature format: {format}");
|
||||
}
|
||||
|
||||
return await handler.ExtractInfoAsync(signedData, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for signature format-specific handlers.
|
||||
/// </summary>
|
||||
public interface ISignatureFormatHandler
|
||||
{
|
||||
SignatureFormat Format { get; }
|
||||
|
||||
Task<SignatureVerificationResult> VerifyAsync(
|
||||
SignatureVerificationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<SignatureExtractionResult> ExtractInfoAsync(
|
||||
byte[] signedData,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for DSSE (Dead Simple Signing Envelope) signatures.
|
||||
/// </summary>
|
||||
public sealed class DsseSignatureHandler : ISignatureFormatHandler
|
||||
{
|
||||
public SignatureFormat Format => SignatureFormat.Dsse;
|
||||
|
||||
public Task<SignatureVerificationResult> VerifyAsync(
|
||||
SignatureVerificationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var envelope = ParseDsseEnvelope(request.Content);
|
||||
if (envelope == null)
|
||||
{
|
||||
return Task.FromResult(CreateError("ERR_DSSE_001", "Invalid DSSE envelope format"));
|
||||
}
|
||||
|
||||
if (envelope.Signatures == null || envelope.Signatures.Count == 0)
|
||||
{
|
||||
return Task.FromResult(CreateError("ERR_DSSE_002", "DSSE envelope has no signatures"));
|
||||
}
|
||||
|
||||
// Extract signer info from first signature
|
||||
var firstSig = envelope.Signatures[0];
|
||||
var signer = ExtractSignerFromDsse(firstSig);
|
||||
|
||||
// For now, we validate structure but don't perform cryptographic verification
|
||||
// Full verification would require access to public keys
|
||||
var warnings = new List<SignatureVerificationWarning>
|
||||
{
|
||||
new("WARN_DSSE_001", "Cryptographic verification not performed; structure validated only")
|
||||
};
|
||||
|
||||
return Task.FromResult(new SignatureVerificationResult(
|
||||
IsValid: true,
|
||||
Status: SignatureVerificationStatus.Valid,
|
||||
Signer: signer,
|
||||
CertificateChain: null,
|
||||
Timestamp: null,
|
||||
Errors: [],
|
||||
Warnings: warnings));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(CreateError("ERR_DSSE_999", $"DSSE parsing error: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<SignatureExtractionResult> ExtractInfoAsync(
|
||||
byte[] signedData,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var envelope = ParseDsseEnvelope(signedData);
|
||||
if (envelope == null)
|
||||
{
|
||||
return Task.FromResult(new SignatureExtractionResult(
|
||||
Success: false,
|
||||
DetectedFormat: SignatureFormat.Dsse,
|
||||
Signer: null,
|
||||
Certificates: null,
|
||||
ErrorMessage: "Invalid DSSE envelope format"));
|
||||
}
|
||||
|
||||
var signer = envelope.Signatures?.Count > 0
|
||||
? ExtractSignerFromDsse(envelope.Signatures[0])
|
||||
: null;
|
||||
|
||||
return Task.FromResult(new SignatureExtractionResult(
|
||||
Success: true,
|
||||
DetectedFormat: SignatureFormat.Dsse,
|
||||
Signer: signer,
|
||||
Certificates: null,
|
||||
ErrorMessage: null));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(new SignatureExtractionResult(
|
||||
Success: false,
|
||||
DetectedFormat: SignatureFormat.Dsse,
|
||||
Signer: null,
|
||||
Certificates: null,
|
||||
ErrorMessage: ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private static DsseEnvelope? ParseDsseEnvelope(byte[] data)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = Encoding.UTF8.GetString(data);
|
||||
return JsonSerializer.Deserialize<DsseEnvelope>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static SignerInfo? ExtractSignerFromDsse(DsseSignature sig)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sig.KeyId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Compute fingerprint from keyid
|
||||
var fingerprint = sig.KeyId;
|
||||
if (fingerprint.StartsWith("SHA256:"))
|
||||
{
|
||||
fingerprint = fingerprint[7..];
|
||||
}
|
||||
|
||||
return new SignerInfo(
|
||||
IssuerId: sig.KeyId,
|
||||
Name: null,
|
||||
Email: null,
|
||||
Organization: null,
|
||||
KeyFingerprint: fingerprint,
|
||||
Algorithm: "unknown",
|
||||
SignedAt: null);
|
||||
}
|
||||
|
||||
private static SignatureVerificationResult CreateError(string code, string message)
|
||||
{
|
||||
return new SignatureVerificationResult(
|
||||
IsValid: false,
|
||||
Status: SignatureVerificationStatus.InvalidSignature,
|
||||
Signer: null,
|
||||
CertificateChain: null,
|
||||
Timestamp: null,
|
||||
Errors: [new SignatureVerificationError(code, message, null)],
|
||||
Warnings: []);
|
||||
}
|
||||
|
||||
private sealed class DsseEnvelope
|
||||
{
|
||||
public string? PayloadType { get; set; }
|
||||
public string? Payload { get; set; }
|
||||
public List<DsseSignature>? Signatures { get; set; }
|
||||
}
|
||||
|
||||
private sealed class DsseSignature
|
||||
{
|
||||
public string? KeyId { get; set; }
|
||||
public string? Sig { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for JWS (JSON Web Signature) signatures.
|
||||
/// </summary>
|
||||
public sealed class JwsSignatureHandler : ISignatureFormatHandler
|
||||
{
|
||||
public SignatureFormat Format => SignatureFormat.Jws;
|
||||
|
||||
public Task<SignatureVerificationResult> VerifyAsync(
|
||||
SignatureVerificationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var jwsString = Encoding.UTF8.GetString(request.Content);
|
||||
var parts = jwsString.Split('.');
|
||||
|
||||
if (parts.Length != 3)
|
||||
{
|
||||
return Task.FromResult(CreateError("ERR_JWS_001", "Invalid JWS format: expected 3 parts"));
|
||||
}
|
||||
|
||||
// Parse header
|
||||
var headerJson = Base64UrlDecode(parts[0]);
|
||||
var header = JsonSerializer.Deserialize<JwsHeader>(headerJson, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
|
||||
if (header == null)
|
||||
{
|
||||
return Task.FromResult(CreateError("ERR_JWS_002", "Invalid JWS header"));
|
||||
}
|
||||
|
||||
var signer = new SignerInfo(
|
||||
IssuerId: header.Kid ?? "unknown",
|
||||
Name: null,
|
||||
Email: null,
|
||||
Organization: null,
|
||||
KeyFingerprint: header.Kid ?? ComputeFingerprint(parts[0]),
|
||||
Algorithm: header.Alg ?? "unknown",
|
||||
SignedAt: null);
|
||||
|
||||
var warnings = new List<SignatureVerificationWarning>
|
||||
{
|
||||
new("WARN_JWS_001", "Cryptographic verification not performed; structure validated only")
|
||||
};
|
||||
|
||||
return Task.FromResult(new SignatureVerificationResult(
|
||||
IsValid: true,
|
||||
Status: SignatureVerificationStatus.Valid,
|
||||
Signer: signer,
|
||||
CertificateChain: null,
|
||||
Timestamp: null,
|
||||
Errors: [],
|
||||
Warnings: warnings));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(CreateError("ERR_JWS_999", $"JWS parsing error: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<SignatureExtractionResult> ExtractInfoAsync(
|
||||
byte[] signedData,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var jwsString = Encoding.UTF8.GetString(signedData);
|
||||
var parts = jwsString.Split('.');
|
||||
|
||||
if (parts.Length != 3)
|
||||
{
|
||||
return Task.FromResult(new SignatureExtractionResult(
|
||||
Success: false,
|
||||
DetectedFormat: SignatureFormat.Jws,
|
||||
Signer: null,
|
||||
Certificates: null,
|
||||
ErrorMessage: "Invalid JWS format"));
|
||||
}
|
||||
|
||||
var headerJson = Base64UrlDecode(parts[0]);
|
||||
var header = JsonSerializer.Deserialize<JwsHeader>(headerJson, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
|
||||
var signer = new SignerInfo(
|
||||
IssuerId: header?.Kid ?? "unknown",
|
||||
Name: null,
|
||||
Email: null,
|
||||
Organization: null,
|
||||
KeyFingerprint: header?.Kid ?? ComputeFingerprint(parts[0]),
|
||||
Algorithm: header?.Alg ?? "unknown",
|
||||
SignedAt: null);
|
||||
|
||||
return Task.FromResult(new SignatureExtractionResult(
|
||||
Success: true,
|
||||
DetectedFormat: SignatureFormat.Jws,
|
||||
Signer: signer,
|
||||
Certificates: null,
|
||||
ErrorMessage: null));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(new SignatureExtractionResult(
|
||||
Success: false,
|
||||
DetectedFormat: SignatureFormat.Jws,
|
||||
Signer: null,
|
||||
Certificates: null,
|
||||
ErrorMessage: ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private static string Base64UrlDecode(string input)
|
||||
{
|
||||
var output = input.Replace('-', '+').Replace('_', '/');
|
||||
switch (output.Length % 4)
|
||||
{
|
||||
case 2: output += "=="; break;
|
||||
case 3: output += "="; break;
|
||||
}
|
||||
var bytes = Convert.FromBase64String(output);
|
||||
return Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
|
||||
private static string ComputeFingerprint(string headerBase64)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(headerBase64));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static SignatureVerificationResult CreateError(string code, string message)
|
||||
{
|
||||
return new SignatureVerificationResult(
|
||||
IsValid: false,
|
||||
Status: SignatureVerificationStatus.InvalidSignature,
|
||||
Signer: null,
|
||||
CertificateChain: null,
|
||||
Timestamp: null,
|
||||
Errors: [new SignatureVerificationError(code, message, null)],
|
||||
Warnings: []);
|
||||
}
|
||||
|
||||
private sealed class JwsHeader
|
||||
{
|
||||
public string? Alg { get; set; }
|
||||
public string? Kid { get; set; }
|
||||
public string? Typ { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,11 @@ import { Injectable } from '@angular/core';
|
||||
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
|
||||
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
|
||||
import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker';
|
||||
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker';
|
||||
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
|
||||
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
|
||||
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker&inline';
|
||||
import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker&inline';
|
||||
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker&inline';
|
||||
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker&inline';
|
||||
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker&inline';
|
||||
|
||||
import {
|
||||
defineStellaDslTheme,
|
||||
|
||||
@@ -64,12 +64,11 @@ describe('PolicyEditorComponent', () => {
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('loads pack content into the editor model', fakeAsync(() => {
|
||||
tick();
|
||||
it('loads pack content into the editor model', () => {
|
||||
expect(monacoLoader.model?.getValue()).toContain('package "demo"');
|
||||
}));
|
||||
});
|
||||
|
||||
it('applies lint diagnostics as Monaco markers', fakeAsync(() => {
|
||||
it('applies lint diagnostics as Monaco markers', () => {
|
||||
const lintResult = {
|
||||
valid: false,
|
||||
errors: [
|
||||
@@ -89,11 +88,10 @@ describe('PolicyEditorComponent', () => {
|
||||
policyApi.lint.and.returnValue(of(lintResult) as any);
|
||||
|
||||
component.triggerLint();
|
||||
tick();
|
||||
|
||||
expect(monacoLoader.lastMarkers.length).toBe(1);
|
||||
expect(monacoLoader.lastMarkers[0].message).toContain('Missing rule header');
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
class MonacoLoaderStub {
|
||||
@@ -102,34 +100,7 @@ class MonacoLoaderStub {
|
||||
lastMarkers: Monaco.editor.IMarkerData[] = [];
|
||||
|
||||
load = jasmine.createSpy('load').and.callFake(async () => {
|
||||
const self = this;
|
||||
return {
|
||||
editor: {
|
||||
createModel: (value: string) => {
|
||||
this.model = new FakeModel(value);
|
||||
this.editor = new FakeEditor(this.model);
|
||||
return this.model as unknown as Monaco.editor.ITextModel;
|
||||
},
|
||||
create: () => this.editor as unknown as Monaco.editor.IStandaloneCodeEditor,
|
||||
setModelMarkers: (
|
||||
_model: Monaco.editor.ITextModel,
|
||||
_owner: string,
|
||||
markers: Monaco.editor.IMarkerData[]
|
||||
) => {
|
||||
self.lastMarkers = markers;
|
||||
},
|
||||
},
|
||||
languages: {
|
||||
register: () => undefined,
|
||||
setMonarchTokensProvider: () => undefined,
|
||||
setLanguageConfiguration: () => undefined,
|
||||
},
|
||||
MarkerSeverity: {
|
||||
Error: 8,
|
||||
Warning: 4,
|
||||
Info: 2,
|
||||
},
|
||||
} as unknown as MonacoNamespace;
|
||||
return mockMonaco(this);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -173,3 +144,27 @@ class FakeEditor {
|
||||
}
|
||||
|
||||
type MonacoNamespace = typeof import('monaco-editor');
|
||||
|
||||
function mockMonaco(loader: MonacoLoaderStub): MonacoNamespace {
|
||||
const severity = { Error: 8, Warning: 4, Info: 2 };
|
||||
return {
|
||||
editor: {
|
||||
createModel: (value: string) => {
|
||||
loader.model = new FakeModel(value);
|
||||
loader.editor = new FakeEditor(loader.model);
|
||||
return loader.model as unknown as Monaco.editor.ITextModel;
|
||||
},
|
||||
create: () => loader.editor as unknown as Monaco.editor.IStandaloneCodeEditor,
|
||||
setModelMarkers: (_model: Monaco.editor.ITextModel, _owner: string, markers: Monaco.editor.IMarkerData[]) => {
|
||||
loader.lastMarkers = markers;
|
||||
},
|
||||
setTheme: () => undefined,
|
||||
},
|
||||
languages: {
|
||||
register: () => undefined,
|
||||
setMonarchTokensProvider: () => undefined,
|
||||
setLanguageConfiguration: () => undefined,
|
||||
},
|
||||
MarkerSeverity: severity as unknown as Monaco.editor.IMarkerSeverity,
|
||||
} as unknown as MonacoNamespace;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
{
|
||||
"status": "interrupted",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { policyAuthorSession } from '../src/app/testing';
|
||||
|
||||
const mockConfig = {
|
||||
authority: {
|
||||
@@ -24,7 +25,7 @@ const mockConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
page.on('console', (message) => {
|
||||
// bubble up browser logs for debugging
|
||||
console.log('[browser]', message.type(), message.text());
|
||||
@@ -32,7 +33,7 @@ test.beforeEach(async ({ page }) => {
|
||||
page.on('pageerror', (error) => {
|
||||
console.log('[pageerror]', error.message);
|
||||
});
|
||||
await page.addInitScript(() => {
|
||||
await page.addInitScript(() => {
|
||||
// Capture attempted redirects so the test can assert against them.
|
||||
(window as any).__stellaopsAssignedUrls = [];
|
||||
const originalAssign = window.location.assign.bind(window.location);
|
||||
@@ -40,8 +41,10 @@ test.beforeEach(async ({ page }) => {
|
||||
(window as any).__stellaopsAssignedUrls.push(url.toString());
|
||||
};
|
||||
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
window.sessionStorage.clear();
|
||||
// Seed a default Policy Studio author session so guarded routes load in e2e
|
||||
(window as any).__stellaopsTestSession = policyAuthorSession;
|
||||
});
|
||||
await page.route('**/config.json', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
|
||||
Reference in New Issue
Block a user